Compare commits

...

21 Commits

Author SHA1 Message Date
bjoernpoettker 32ba772e4e fix(frontend): weiße Seite bei fehlgeschlagenem Chunk-Import beheben
Build and Push Multi-Platform Images / build-and-push (push) Successful in 18s
Alle Seiten werden per React.lazy als eigene Chunks nachgeladen. Schlug ein
Import fehl (Safari: "Importing a module script failed" — z.B. nach einem
Deployment mit neuen Chunk-Namen oder bei beschädigtem, wegen
"Cache-Control: immutable" nie revalidiertem Browser-Cache), riss der Fehler
mangels ErrorBoundary den kompletten React-Baum in eine weiße Seite.

- main.tsx: Bei 'vite:preloadError' automatisch neu laden (max. 1×/Minute,
  sessionStorage-Guard gegen Reload-Schleifen)
- AppErrorBoundary (neu): Fehlermeldung mit "Seite neu laden"-Button statt
  weißer Seite; bei Chunk-Ladefehlern mit Hinweis auf Cache-Leeren
- App.tsx: ErrorBoundary um alle Routen gelegt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:20:25 +02:00
bjoernpoettker 095cc4bb02 fix: Warteschlange blieb stehen – Altersvergleich in die DB verlagert
Build and Push Multi-Platform Images / build-and-push (push) Successful in 32s
Der Mindest-Wartezeit-Check verglich Date.now() (Node-Uhr) mit dem aus der DB
gelesenen createdAt. Ohne gesetzte timezone (mysql2-Default 'local') wird
createdAt bei abweichender DB-Zeitzone als zukünftig interpretiert → Alter
negativ → jeder Eintrag galt als "zu jung" und wurde nie verarbeitet.

Der Vergleich läuft jetzt in der DB (createdAt <= NOW(6) - INTERVAL 5s via
TypeORM Raw), unabhängig von Zeitzone/Uhr des Node-Prozesses. Bereits
wartende Einträge werden dadurch nach dem Deploy sofort abgearbeitet.
Test-Mock bildet den DB-Alters-Filter nach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 12:07:51 +02:00
bjoernpoettker 7b2a79be2a fix: Dateinamen mit Pfadtrennzeichen beim Mail-Import absichern
Build and Push Multi-Platform Images / build-and-push (push) Successful in 31s
Anhänge mit '/' im Dateinamen (z.B. "Rechnung - 2026/002650322.PDF")
führten zu einem Fehler, weil '/' als Verzeichnistrenner interpretiert
wurde und der temp. Pfad nicht existierte.

- sanitizeFilename() ersetzt '/' und '\' durch '-', entfernt '..' und Null-Bytes
- Wird beim Speichern in die DB (email-download) und beim Aufbau des
  temp. Pfads (email-import) angewendet (defense-in-depth)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 11:10:36 +02:00
bjoernpoettker 1c70473cef feat: Mindest-Wartezeit (5000 ms) zwischen Einreihen und Verarbeitung
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
processQueue verarbeitet den ältesten Warteschlangen-Eintrag erst, wenn er
mindestens MIN_QUEUE_AGE_MS (Default 5000 ms, ENV WEBHOOK_QUEUE_MIN_AGE_MS)
in der Tabelle lag. Ist der älteste Eintrag (FIFO) noch zu jung, bricht der
Tick ab und prüft beim nächsten Intervall erneut. Unit-Test ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:03:10 +02:00
bjoernpoettker beaa1be4a5 feat(backend): Metadaten direkt beim Paperless-Upload übergeben
Build and Push Multi-Platform Images / build-and-push (push) Successful in 31s
E-Mail-Import und External-Upload setzen ASN, Custom Fields (CF3/CF7/CF9),
Dokumenttyp, Tags, Owner und Belegdatum jetzt bereits beim post_document,
statt erst nachträglich über den Task-Processor-Cron (30 s + PATCH).

- Neu: deriveTaskMetadata() in task-metadata.util.ts als gemeinsame
  Ableitung (Task-Felder → Paperless-Metadaten) für beide Upload-Pfade
  und den Task-Processor – identische Parsing-Regeln, kein Copy-Paste
- Task-Processor nutzt den Helper (verhaltensidentischer Refactor) und
  patcht weiterhin idempotent nach; zuständig bleibt er für alles
  ID-Abhängige (CF8-Verknüpfungen, Anlage-Titel, Owner-Entfernung, Notizen)
- FAILURE-Zweig loggt Paperless-Fehlertext und Belegnummer vor dem
  Löschen des Tasks (ASN-Kollision kann jetzt den Consume scheitern lassen)
- Tests: 18 Unit-Tests für deriveTaskMetadata, 3 Charakterisierungstests
  pinnen den PATCH-Payload von processSuccessfulTask

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:46:42 +02:00
bjoernpoettker 57c8964384 feat(frontend): Manuell bearbeiten – Speichern trotz fehlender Pflichtfelder
Build and Push Multi-Platform Images / build-and-push (push) Successful in 17s
Im "Manuell bearbeiten"-Dialog (DocumentEditModal, isPosteingang=false) kann
jetzt trotz leerer Pflichtfelder gespeichert werden: Schlägt die Formular-
Validierung fehl, erscheint ein Bestätigungsdialog, der die fehlenden Pflicht-
felder auflistet; per "Trotzdem speichern" wird ohne erneute Validierung
gespeichert. Der Posteingang (isPosteingang=true) blockiert wie bisher.

Die Kontonummer-Logik wurde in runSave() ausgelagert und von beiden Pfaden
(normal + "trotzdem speichern") wiederverwendet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:55:04 +02:00
bjoernpoettker 8390d03869 Merge pull request 'Freigabe → main: Mobile UI, Webhook, IMAP, Zahlung-Workflow und weitere Features' (#5) from Freigabe into main
Build and Push Multi-Platform Images / build-and-push (push) Successful in 8s
Reviewed-on: #5
2026-07-14 07:44:32 +00:00
bjoernpoettker 3cbb64686a chore: add .gitea_token to .gitignore
Build and Push Multi-Platform Images / build-and-push (push) Successful in 10s
2026-07-14 09:43:53 +02:00
bjoernpoettker 7076eef57b feat: add mobile-responsive layout and card views across all pages
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
- New useIsMobile hook and MobileCardList component
- AppLayout: hamburger menu + Drawer navigation on mobile
- All list pages (Inbox, Posteingang, Manuell, Mail, Freigabe, Zahlung, TaskLog)
  show card layout on mobile instead of tables
- CSS: responsive modal height, horizontal table scroll, text-size-adjust
- Backend: Agrarmonitor polling fixes, Zahlung service improvements,
  IMAP folder service extended, misc controller fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 09:22:58 +02:00
bjoernpoettker 0765d14d3b fix(agrarmonitor): Karenzzeit vor "Von AM zurück"-Markierung
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
Belege, die per E-Mail an Agrarmonitor gesendet werden, brauchen bis zu
10 Minuten bis zum Import in den Dateieingang. Bisher wurden sie im
Upload-Check sofort als "Manuell bearbeiten" + Tag 19 markiert, wenn sie
weder verbucht noch im Dateieingang waren – auch mitten im Import-Fenster.

Jetzt wird vor dem Markieren geprüft, ob die jüngste Notiz mit dem
konfigurierbaren Marker-Text jünger als die Wartezeit ist; falls ja, wird
der Beleg übersprungen und beim nächsten Lauf erneut geprüft. Wartezeit
(agrarmonitor_import_wartezeit_minuten, Standard 10) und Marker
(agrarmonitor_notiz_marker, Standard "Agrarmonitor") sind als Settings
konfigurierbar und in der Settings-Seite editierbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:40:59 +02:00
bjoernpoettker 8fda248683 fix(frontend): seltener Relogin durch stillen Token-Refresh
Build and Push Multi-Platform Images / build-and-push (push) Successful in 17s
Zwei Lücken behoben, die bei abgelaufenem Access-Token (v.a. nach inaktivem
Tab, dessen Renew-Timer gedrosselt wurde) zu vollständigen Neuanmeldungen
führten:

- getAccessToken erneuert das Token jetzt proaktiv (signinSilent), wenn es
  abgelaufen ist, statt ein abgelaufenes Token zu senden. Profitiert auch den
  direkten fetch-Aufrufen (Bilder/PDFs).
- Der 401-Interceptor versucht zuerst einen stillen Refresh und wiederholt die
  Anfrage einmal; erst wenn das scheitert (Refresh-Token ungültig), erfolgt der
  Login-Redirect.

Gleichzeitige Refreshes werden dedupliziert (renewToken), um invalid_grant bei
Refresh-Token-Rotation zu vermeiden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:53:38 +02:00
bjoernpoettker 156b0401b3 fix(auth): JWT nicht als API-Key prüfen (Bearer-Fallback nur für pm_-Keys)
Build and Push Multi-Platform Images / build-and-push (push) Successful in 30s
Beim JWT-Ablauf fällt der JwtOrApiKeyGuard auf den ApiKeyGuard zurück. Dieser
nahm bisher jedes Authorization-Bearer-Token als API-Key-Kandidaten – also auch
das (abgelaufene) JWT – und loggte eine irreführende "Invalid API Key"-Warnung
(samt JWT-Präfix). Der Bearer-Fallback akzeptiert nun nur noch Token mit dem
API-Key-Präfix "pm_"; ein JWT wird ignoriert. Ergebnis bleibt 401 (Frontend
re-authentifiziert), aber ohne irreführenden Log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:12:53 +02:00
bjoernpoettker e50111a731 fix(frontend): einheitliche Schriftart in AntD-Komponenten
Build and Push Multi-Platform Images / build-and-push (push) Successful in 17s
fontFamily-Token im ConfigProvider auf denselben Inter-Stack wie der Body
gesetzt. Zuvor fehlte das Token, sodass AntD-Eingaben (z.B. das Select-
Suchfeld) im AntD-Default-Stack abwichen, während der angezeigte Wert die
Body-Schrift (Inter) nutzte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:09:52 +02:00
bjoernpoettker 9718d6888a feat: Webhook-Warteschlange in Datenbank persistieren
Build and Push Multi-Platform Images / build-and-push (push) Successful in 31s
Die Warteschlange liegt nicht mehr im Speicher, sondern in der neuen Tabelle
webhook_queue (Entity WebhookQueueItem, documentId als Primärschlüssel). So
überstehen ausstehende IDs einen Neustart und werden nach dem Boot
weiterverarbeitet.

- Neue Entity + Migration (CreateWebhookQueue); in data-source.ts/Barrel
  registriert. Dev: synchronize legt die Tabelle an; Prod: migrationsRun.
- enqueue nutzt INSERT IGNORE (atomar, race-sicher) -> Dedup über den PK.
- processQueue holt FIFO (createdAt ASC), entfernt die Zeile vor der
  Verarbeitung, arbeitet sequenziell (isProcessing-Guard).
- getStatus/queueSize lesen die DB-Anzahl. Controller awaitet enqueue.
- Unit-Test mit simuliertem FIFO-Repo: Dedup, Remove-on-start, Re-Enqueue,
  No-Parallel (deterministisch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 17:54:16 +02:00
bjoernpoettker 66a2cccd20 feat: Webhook-Warteschlange für sequenzielle, deduplizierte Verarbeitung
Build and Push Multi-Platform Images / build-and-push (push) Successful in 37s
Der Webhook reiht eingehende Dokument-IDs nur noch in eine Warteschlange ein
und antwortet sofort (status "queued"). Ein separater Intervall-Prozess
(WebhookQueueService) arbeitet die IDs nacheinander ab – ohne Überschneidung,
falls ein Dokument mehrfach kurz hintereinander gespeichert wird:

- Jede ID kommt nur einmal in der Liste vor (Dedup).
- Beim Verarbeitungsstart wird die ID sofort entfernt; ein erneutes Feuern
  während der Verarbeitung reiht sie wieder ein (ein weiterer Lauf folgt).
- Es läuft immer nur eine Verarbeitung gleichzeitig (isProcessing-Guard).
- Prüfintervall sehr kurz (WEBHOOK_QUEUE_INTERVAL_MS, Default 1000 ms).

Status-Recording (last_webhook_call) + GET /api/webhook/status wandern in den
Queue-Service und liefern zusätzlich die aktuelle Warteschlangen-Größe.
Frontend-Tab zeigt Status "In Warteschlange" und die Queue-Größe an.
Unit-Test deckt Dedup, Remove-on-start, Re-Enqueue und No-Parallel ab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 17:09:23 +02:00
bjoernpoettker b77a283ab2 feat: Webhook verarbeitet jedes Dokument ohne Tag-16-Vorprüfung
Build and Push Multi-Platform Images / build-and-push (push) Successful in 38s
processDocumentById verarbeitet nun jedes vom Paperless-Webhook gemeldete
Dokument, unabhängig vom Tag "paperlessmanager" (ID 16). Die Tag-Vorprüfung
(skipped/tag-missing) entfällt. Die Konstante PAPERLESSMANAGER_TAG_ID bleibt
für den manuellen Batch-Lauf processDocuments erhalten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:58:34 +02:00
bjoernpoettker 1c37936d8c feat(frontend): Webhook-Status-Tab in den Einstellungen
Neuer Tab "Webhook" in der SettingsPage zeigt den letzten Paperless-
Webhook-Aufruf (Zeitpunkt, Dokument-ID, Aktion, Ergebnis) über
GET /api/webhook/status an. Neue API-Datei src/api/webhook.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:01:27 +02:00
bjoernpoettker 32d9e84985 feat: letzten Paperless-Webhook-Aufruf in Settings festhalten
Pro Webhook-Aufruf wird ein Status in der settings-Tabelle gepflegt
(Tag "last_webhook_call", JSON mit Zeitpunkt, Dokument, action, Ergebnis).
Neuer Endpunkt GET /api/webhook/status liefert den letzten Aufruf zum
Auslesen (JWT oder API-Key). Persistent über Neustarts, anders als die
Container-Logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:57:36 +02:00
bjoernpoettker 56596d4482 feat: Paperless-Webhook ersetzt Tag-16-Cron-Job durch Echtzeit-Verarbeitung
Build and Push Multi-Platform Images / build-and-push (push) Successful in 42s
Statt stündlichem Cron ruft Paperless-NGX nun per Webhook das Backend auf,
sobald ein Dokument bearbeitet wurde. Der Endpunkt verarbeitet ein einzelnes
Dokument (per doc_url/document_id), prüft weiterhin den Tag "paperlessmanager"
(ID 16) und ist per API-Key (X-API-Key) abgesichert, sodass nur Paperless ihn
aufrufen kann.

- paperless-processor.service.ts: @Cron entfernt; neue Methode
  processDocumentById; Tag-16 als Konstante; gemeinsamer Helper
  processAndEvaluate (Batch + Webhook)
- paperless.module.ts: PaperlessProcessorService exportiert
- webhook.controller.ts: Route auf api/webhook (erreichbar via /api-Proxy);
  @Public() -> @UseGuards(ApiKeyGuard); ID-Extraktion aus {{doc_url}}
- webhook.module.ts: PaperlessModule + AuthModule importiert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:20:19 +02:00
bjoernpoettker 4d05a94681 feat: implement two-step Freigabe→Zahlung workflow
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 <noreply@anthropic.com>
2026-06-18 21:29:50 +02:00
bjoernpoettker c665451abf Merge pull request 'Freigabe' (#4) from Freigabe into main
Build and Push Multi-Platform Images / build-and-push (push) Successful in 9s
Reviewed-on: #4
2026-06-16 14:49:23 +00:00
57 changed files with 2995 additions and 580 deletions
+3
View File
@@ -14,3 +14,6 @@ dist/
# Docker # Docker
docker-compose.override.yml docker-compose.override.yml
# Secrets
.gitea_token
+6
View File
@@ -13,6 +13,12 @@ Paperless Manager is a document automation platform that extends [Paperless-NGX]
UI labels and comments are in **German**. 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 ## Structure
``` ```
@@ -56,6 +56,8 @@ export class AgrarmonitorPollingService implements OnModuleInit {
await this.upsertSetting('agrarmonitor_tag_hochgeladen', ''); await this.upsertSetting('agrarmonitor_tag_hochgeladen', '');
await this.upsertSetting('agrarmonitor_link_field', ''); await this.upsertSetting('agrarmonitor_link_field', '');
await this.upsertSetting('agrarmonitor_tag_manuell', ''); await this.upsertSetting('agrarmonitor_tag_manuell', '');
await this.upsertSetting('agrarmonitor_import_wartezeit_minuten', '10');
await this.upsertSetting('agrarmonitor_notiz_marker', 'Agrarmonitor');
} }
@Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *') @Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *')
@@ -80,14 +82,27 @@ export class AgrarmonitorPollingService implements OnModuleInit {
tagHochgeladen: string; tagHochgeladen: string;
linkField: string; linkField: string;
tagManuell: string; tagManuell: string;
importWartezeitMinuten: string;
notizMarker: string;
}> { }> {
const [fertig, verbucht, hochgeladen, linkField, manuell] = const [
await Promise.all([ fertig,
verbucht,
hochgeladen,
linkField,
manuell,
wartezeit,
marker,
] = await Promise.all([
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
this.settingRepo.findOneBy({
Tag: 'agrarmonitor_import_wartezeit_minuten',
}),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_notiz_marker' }),
]); ]);
return { return {
tagFertig: fertig?.Wert ?? '4', tagFertig: fertig?.Wert ?? '4',
@@ -95,6 +110,8 @@ export class AgrarmonitorPollingService implements OnModuleInit {
tagHochgeladen: hochgeladen?.Wert ?? '', tagHochgeladen: hochgeladen?.Wert ?? '',
linkField: linkField?.Wert ?? '', linkField: linkField?.Wert ?? '',
tagManuell: manuell?.Wert ?? '', tagManuell: manuell?.Wert ?? '',
importWartezeitMinuten: wartezeit?.Wert ?? '10',
notizMarker: marker?.Wert ?? 'Agrarmonitor',
}; };
} }
@@ -104,12 +121,16 @@ export class AgrarmonitorPollingService implements OnModuleInit {
tagHochgeladen: string, tagHochgeladen: string,
linkField: string, linkField: string,
tagManuell: string, tagManuell: string,
importWartezeitMinuten: string,
notizMarker: string,
): Promise<{ ): Promise<{
tagFertig: string; tagFertig: string;
tagVerbucht: string; tagVerbucht: string;
tagHochgeladen: string; tagHochgeladen: string;
linkField: string; linkField: string;
tagManuell: string; tagManuell: string;
importWartezeitMinuten: string;
notizMarker: string;
}> { }> {
await Promise.all([ await Promise.all([
this.settingRepo.update( this.settingRepo.update(
@@ -132,8 +153,24 @@ export class AgrarmonitorPollingService implements OnModuleInit {
{ Tag: 'agrarmonitor_tag_manuell' }, { Tag: 'agrarmonitor_tag_manuell' },
{ Wert: tagManuell }, { Wert: tagManuell },
), ),
this.settingRepo.update(
{ Tag: 'agrarmonitor_import_wartezeit_minuten' },
{ Wert: importWartezeitMinuten },
),
this.settingRepo.update(
{ Tag: 'agrarmonitor_notiz_marker' },
{ Wert: notizMarker },
),
]); ]);
return { tagFertig, tagVerbucht, tagHochgeladen, linkField, tagManuell }; return {
tagFertig,
tagVerbucht,
tagHochgeladen,
linkField,
tagManuell,
importWartezeitMinuten,
notizMarker,
};
} }
async runPolling(): Promise<PollingResult> { async runPolling(): Promise<PollingResult> {
@@ -393,17 +430,26 @@ export class AgrarmonitorPollingService implements OnModuleInit {
fertigSetting, fertigSetting,
linkFieldSetting, linkFieldSetting,
manuellSetting, manuellSetting,
wartezeitSetting,
markerSetting,
] = await Promise.all([ ] = await Promise.all([
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
this.settingRepo.findOneBy({
Tag: 'agrarmonitor_import_wartezeit_minuten',
}),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_notiz_marker' }),
]); ]);
const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10); const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10);
const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10); const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10);
const linkFieldId = parseInt(linkFieldSetting?.Wert ?? '', 10); const linkFieldId = parseInt(linkFieldSetting?.Wert ?? '', 10);
const tagManuellId = parseInt(manuellSetting?.Wert ?? '', 10); const tagManuellId = parseInt(manuellSetting?.Wert ?? '', 10);
const importWartezeitMinuten =
parseInt(wartezeitSetting?.Wert ?? '10', 10) || 10;
const notizMarker = (markerSetting?.Wert ?? 'Agrarmonitor').trim();
if (isNaN(tagHochgeladenId)) { if (isNaN(tagHochgeladenId)) {
this.logger.warn( this.logger.warn(
@@ -512,6 +558,40 @@ export class AgrarmonitorPollingService implements OnModuleInit {
continue; continue;
} }
// Karenzzeit: evtl. hat Agrarmonitor die E-Mail noch nicht importiert.
// Ist die Sende-Notiz jünger als die konfigurierte Wartezeit, nicht markieren.
const marker = notizMarker.toLowerCase();
if (marker) {
let notes: Array<{ note: string; created: string }>;
try {
notes = await this.paperlessService.getNotes(doc.id as number);
} catch (err: unknown) {
// Vorsichtig: bei Notiz-Abruf-Fehler NICHT markieren, nächster Lauf prüft erneut
this.logger.warn(
`${interneBelegnummer}: Notiz-Abruf fehlgeschlagen — nicht markiert: ${err instanceof Error ? err.message : err}`,
);
result.skipped++;
await this.delay(500);
continue;
}
const newest = notes
.filter((n) => (n.note ?? '').toLowerCase().includes(marker))
.map((n) => new Date(n.created).getTime())
.filter((t) => !isNaN(t))
.reduce((max, t) => Math.max(max, t), 0);
if (
newest > 0 &&
Date.now() - newest < importWartezeitMinuten * 60 * 1000
) {
this.logger.log(
`${interneBelegnummer}: Sende-Notiz jünger als ${importWartezeitMinuten} Min — warte auf Import`,
);
result.skipped++;
await this.delay(500);
continue;
}
}
// Weder verbucht noch im Dateieingang → Tags "Manuell bearbeiten" + "Von AM zurück" setzen // Weder verbucht noch im Dateieingang → Tags "Manuell bearbeiten" + "Von AM zurück" setzen
if (!isNaN(tagManuellId)) { if (!isNaN(tagManuellId)) {
const currentTags: number[] = (doc.tags as number[]) ?? []; const currentTags: number[] = (doc.tags as number[]) ?? [];
@@ -42,6 +42,8 @@ export class AgrarmonitorController {
tagHochgeladen: string; tagHochgeladen: string;
linkField: string; linkField: string;
tagManuell: string; tagManuell: string;
importWartezeitMinuten: string;
notizMarker: string;
}, },
) { ) {
return this.pollingService.updatePollingConfig( return this.pollingService.updatePollingConfig(
@@ -50,6 +52,8 @@ export class AgrarmonitorController {
body.tagHochgeladen, body.tagHochgeladen,
body.linkField, body.linkField,
body.tagManuell ?? '', body.tagManuell ?? '',
body.importWartezeitMinuten ?? '10',
body.notizMarker ?? 'Agrarmonitor',
); );
} }
+2
View File
@@ -20,6 +20,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module';
import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module'; import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module';
import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module'; import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module';
import { FreigabeModule } from './freigabe/freigabe.module'; import { FreigabeModule } from './freigabe/freigabe.module';
import { ZahlungModule } from './zahlung/zahlung.module';
import { DailyDigestModule } from './daily-digest/daily-digest.module'; import { DailyDigestModule } from './daily-digest/daily-digest.module';
import * as path from 'path'; import * as path from 'path';
@@ -52,6 +53,7 @@ import * as path from 'path';
LabelPrintAgentModule, LabelPrintAgentModule,
AgrarmonitorModule, AgrarmonitorModule,
FreigabeModule, FreigabeModule,
ZahlungModule,
DailyDigestModule, DailyDigestModule,
], ],
}) })
+9 -2
View File
@@ -28,14 +28,21 @@ export class ApiKeyGuard implements CanActivate {
if (apiKey) source = 'apiKey query param'; if (apiKey) source = 'apiKey query param';
} }
// Fallback to Authorization: Bearer (used by SSE clients that can't set X-API-Key) // Fallback to Authorization: Bearer (used by SSE clients that can't set
// X-API-Key). Nur akzeptieren, wenn das Token wie ein API-Key aussieht
// (Präfix "pm_"). Ein (abgelaufenes) JWT als Bearer-Token wird hier ignoriert,
// statt es fälschlich als API-Key zu prüfen das vermeidet die irreführende
// "Invalid API Key"-Warnung beim normalen JWT-Ablauf.
if (!apiKey) { if (!apiKey) {
const auth: string | undefined = request.headers['authorization']; const auth: string | undefined = request.headers['authorization'];
if (auth?.startsWith('Bearer ')) { if (auth?.startsWith('Bearer ')) {
apiKey = auth.slice(7); const token = auth.slice(7);
if (token.startsWith('pm_')) {
apiKey = token;
source = 'Authorization: Bearer'; source = 'Authorization: Bearer';
} }
} }
}
this.logger.log( this.logger.log(
`[${method} ${url}] key source: ${apiKey ? source : 'NONE'} | ` + `[${method} ${url}] key source: ${apiKey ? source : 'NONE'} | ` +
@@ -6,6 +6,7 @@ export const Permission = {
VIEW_SCANNER: 'VIEW_SCANNER', VIEW_SCANNER: 'VIEW_SCANNER',
MANAGE_SETTINGS: 'MANAGE_SETTINGS', MANAGE_SETTINGS: 'MANAGE_SETTINGS',
VIEW_FREIGABE: 'VIEW_FREIGABE', VIEW_FREIGABE: 'VIEW_FREIGABE',
VIEW_ZAHLUNG: 'VIEW_ZAHLUNG',
} as const; } as const;
export type Permission = (typeof Permission)[keyof typeof Permission]; export type Permission = (typeof Permission)[keyof typeof Permission];
@@ -27,6 +28,7 @@ export function mapGroupsToPermissions(
permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.VIEW_SCANNER);
permissions.add(Permission.MANAGE_SETTINGS); permissions.add(Permission.MANAGE_SETTINGS);
permissions.add(Permission.VIEW_FREIGABE); permissions.add(Permission.VIEW_FREIGABE);
permissions.add(Permission.VIEW_ZAHLUNG);
return Array.from(permissions); 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_Posteingang')) permissions.add(Permission.VIEW_INBOX);
if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER); if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER);
if (groups.includes('PM_Freigabe')) permissions.add(Permission.VIEW_FREIGABE); if (groups.includes('PM_Freigabe')) permissions.add(Permission.VIEW_FREIGABE);
if (groups.includes('PM_Zahlung')) permissions.add(Permission.VIEW_ZAHLUNG);
return Array.from(permissions); return Array.from(permissions);
} }
@@ -26,6 +26,7 @@ import {
CorrespondentEmailMapping, CorrespondentEmailMapping,
UserSettings, UserSettings,
LabelPrintJob, LabelPrintJob,
WebhookQueueItem,
} from './entities'; } from './entities';
// CLI-Kontext: .env laden (Laufzeit im Container liefert die Variablen via Docker, // CLI-Kontext: .env laden (Laufzeit im Container liefert die Variablen via Docker,
@@ -57,6 +58,7 @@ export const entities = [
CorrespondentEmailMapping, CorrespondentEmailMapping,
UserSettings, UserSettings,
LabelPrintJob, LabelPrintJob,
WebhookQueueItem,
]; ];
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
@@ -21,3 +21,4 @@ export { InboxPostprocessingAction } from './inbox-postprocessing-action.entity'
export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity'; export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity';
export { UserSettings } from './user-settings.entity'; export { UserSettings } from './user-settings.entity';
export { LabelPrintJob } from './label-print-job.entity'; export { LabelPrintJob } from './label-print-job.entity';
export { WebhookQueueItem } from './webhook-queue-item.entity';
@@ -0,0 +1,26 @@
import {
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
/**
* Persistente Warteschlange der vom Paperless-Webhook gemeldeten Dokument-IDs.
* `documentId` ist Primärschlüssel und erzwingt damit, dass jede ID nur einmal
* in der Warteschlange steht (Dedup auf DB-Ebene). Die Tabelle übersteht
* Neustarts; ausstehende IDs werden nach dem Boot weiterverarbeitet.
*/
@Entity('webhook_queue')
export class WebhookQueueItem {
@PrimaryColumn({ type: 'int' })
documentId!: number;
@Column({ type: 'varchar', length: 100, nullable: true })
action!: string | null;
@Index()
@CreateDateColumn()
createdAt!: Date;
}
@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Legt die persistente Webhook-Warteschlange an (`webhook_queue`).
* `documentId` ist Primärschlüssel → jede Dokument-ID kommt nur einmal vor.
*/
export class CreateWebhookQueue1782700000000 implements MigrationInterface {
name = 'CreateWebhookQueue1782700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS \`webhook_queue\` (
\`documentId\` int NOT NULL,
\`action\` varchar(100) NULL,
\`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (\`documentId\`),
INDEX \`IDX_webhook_queue_createdAt\` (\`createdAt\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE \`webhook_queue\``);
}
}
@@ -20,6 +20,14 @@ import { Attachment } from '../database/entities/attachment.entity';
import { Content } from '../database/entities/content.entity'; import { Content } from '../database/entities/content.entity';
import { isERechnung } from './zugferd.util'; import { isERechnung } from './zugferd.util';
function sanitizeFilename(name: string): string {
return name
.replace(/[/\\]/g, '-')
.replace(/\.\./g, '.')
.replace(/\x00/g, '')
.trim();
}
@Injectable() @Injectable()
export class EmailDownloadService { export class EmailDownloadService {
private readonly logger = new Logger(EmailDownloadService.name); private readonly logger = new Logger(EmailDownloadService.name);
@@ -291,7 +299,7 @@ export class EmailDownloadService {
} }
const attachment = new Attachment(); const attachment = new Attachment();
attachment.FileName = filename.slice(0, 255); attachment.FileName = sanitizeFilename(filename).slice(0, 255);
attachment.ContentType = contentType.slice(0, 100); attachment.ContentType = contentType.slice(0, 100);
attachment.IsEmbedded = isEmbedded; attachment.IsEmbedded = isEmbedded;
attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null; attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null;
@@ -10,6 +10,7 @@ import { Content } from '../database/entities/content.entity';
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity'; import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
import { Task } from '../database/entities/task.entity'; import { Task } from '../database/entities/task.entity';
import { PaperlessService } from '../paperless/paperless.service'; import { PaperlessService } from '../paperless/paperless.service';
import { deriveTaskMetadata } from '../paperless/task-metadata.util';
import * as QRCode from 'qrcode'; import * as QRCode from 'qrcode';
import { EmailPageCacheService } from './email-page-cache.service'; import { EmailPageCacheService } from './email-page-cache.service';
import { ImapFolderService } from './imap-folder.service'; import { ImapFolderService } from './imap-folder.service';
@@ -19,6 +20,14 @@ import * as os from 'os';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
function sanitizeFilename(name: string): string {
return name
.replace(/[/\\]/g, '-')
.replace(/\.\./g, '.')
.replace(/\x00/g, '')
.trim();
}
@Injectable() @Injectable()
export class EmailImportService { export class EmailImportService {
private readonly logger = new Logger(EmailImportService.name); private readonly logger = new Logger(EmailImportService.name);
@@ -491,7 +500,7 @@ export class EmailImportService {
if (!content) continue; if (!content) continue;
const originalPdfBytes = content.Content1; const originalPdfBytes = content.Content1;
const baseFilename = attachmentEntity.FileName.replace(/\.pdf$/i, ''); const baseFilename = sanitizeFilename(attachmentEntity.FileName.replace(/\.pdf$/i, ''));
const paperlessIds: any = {}; const paperlessIds: any = {};
const uploadPromises = []; const uploadPromises = [];
@@ -557,13 +566,29 @@ export class EmailImportService {
// Upload all generated PDFs // Upload all generated PDFs
for (const uploadItem of uploadPromises) { for (const uploadItem of uploadPromises) {
// Einmal definieren, zweimal verwenden: Upload-Metadaten und Task
// tragen garantiert dieselben Werte
const taskFields = {
InterneBelegnummer: att.belegnummer || '',
Eingangsdatum: att.barcode?.datum
? new Date(att.barcode.datum)
: createdDate,
Belegdatum: createdDate,
DocumentType: att.type === 'MAIN' ? null : 5, // 5 = Anlage
BetriebID: null, // Owner-Entfernung erledigt der Task-Processor
};
// Metadaten direkt beim Upload mitgeben; der Task-Processor patcht
// später idempotent nach (Sicherheitsnetz)
const derived = deriveTaskMetadata(taskFields);
const options: any = { const options: any = {
filename: uploadItem.filename, filename: uploadItem.filename,
title: att.belegnummer title: att.belegnummer
? `Beleg ${att.belegnummer}` ? `Beleg ${att.belegnummer}`
: uploadItem.filename, : uploadItem.filename,
created: createdDate, created: createdDate,
owner: null, documentType: derived.documentType,
archiveSerialNumber: derived.archiveSerialNumber,
customFields: derived.customFields,
}; };
if (att.paperlessCorrespondentId) if (att.paperlessCorrespondentId)
options.correspondent = att.paperlessCorrespondentId; options.correspondent = att.paperlessCorrespondentId;
@@ -577,15 +602,9 @@ export class EmailImportService {
// Create background task for enrichment (same logic as Inbox) // Create background task for enrichment (same logic as Inbox)
const backgroundTask = this.taskRepo.create({ const backgroundTask = this.taskRepo.create({
TaskId: paperlessTaskId, TaskId: paperlessTaskId,
InterneBelegnummer: att.belegnummer || '', ...taskFields,
Eingangsdatum: att.barcode?.datum
? new Date(att.barcode.datum)
: createdDate,
Belegdatum: createdDate,
BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null, BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null,
BetriebID: null, // Owner
Fertig: 0, Fertig: 0,
DocumentType: att.type === 'MAIN' ? null : 5, // 5 = Anlage
SourceAttachmentID: att.attachmentId, SourceAttachmentID: att.attachmentId,
SourceAttachmentRange: uploadItem.rangeKey, SourceAttachmentRange: uploadItem.rangeKey,
}); });
@@ -648,10 +667,16 @@ export class EmailImportService {
this.logger.log( this.logger.log(
`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`, `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) { if (emailEntity) {
this.imapFolderService.moveToImportiert(emailEntity.MessageId).catch(err => this.imapFolderService
this.logger.error('IMAP-Verschieben fehlgeschlagen: ' + err.message), .moveToImportiert(emailEntity.MessageId)
.catch((err) =>
this.logger.error(
'IMAP-Verschieben fehlgeschlagen: ' + err.message,
),
); );
} }
} }
@@ -204,7 +204,9 @@ export class EmailController {
this.logger.log( this.logger.log(
`Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`, `Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`,
); );
this.imapFolderService.cleanupImportedEmails().catch(err => this.imapFolderService
.cleanupImportedEmails()
.catch((err) =>
this.logger.error('IMAP-Cleanup fehlgeschlagen: ' + err.message), this.logger.error('IMAP-Cleanup fehlgeschlagen: ' + err.message),
); );
@@ -214,8 +216,13 @@ export class EmailController {
where: [{ Status: 1 }, { Status: 3 }], where: [{ Status: 1 }, { Status: 3 }],
select: ['MessageId'], select: ['MessageId'],
}); });
const messageIds = processedEmails.map((e) => e.MessageId).filter(Boolean); const messageIds = processedEmails
movedToImportiert = await this.imapFolderService.moveProcessedInboxToImportiert(messageIds); .map((e) => e.MessageId)
.filter(Boolean);
movedToImportiert =
await this.imapFolderService.moveProcessedInboxToImportiert(
messageIds,
);
} }
return { updatedCount, idsUpdated, movedToImportiert }; return { updatedCount, idsUpdated, movedToImportiert };
@@ -25,8 +25,14 @@ export class ImapFolderService {
@Cron('0 3 * * *', { timeZone: 'Europe/Berlin' }) @Cron('0 3 * * *', { timeZone: 'Europe/Berlin' })
async cleanupImportedEmails(): Promise<void> { async cleanupImportedEmails(): Promise<void> {
if (!this.configService.get<string>('IMAP_HOST')) return; if (!this.configService.get<string>('IMAP_HOST')) return;
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert'); const importedFolder = this.configService.get<string>(
const trashFolder = this.configService.get<string>('IMAP_TRASH_FOLDER', 'Trash'); 'IMAP_IMPORTED_FOLDER',
'importiert',
);
const trashFolder = this.configService.get<string>(
'IMAP_TRASH_FOLDER',
'Trash',
);
const client = this.createClient(); const client = this.createClient();
try { try {
await client.connect(); await client.connect();
@@ -39,10 +45,14 @@ export class ImapFolderService {
const oldUids = await client.search({ before: cutoff }, { uid: true }); const oldUids = await client.search({ before: cutoff }, { uid: true });
if (Array.isArray(oldUids) && oldUids.length > 0) { if (Array.isArray(oldUids) && oldUids.length > 0) {
await client.messageMove(oldUids, trashFolder, { uid: true }); 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) { } 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 // Papierkorb leeren
@@ -51,10 +61,14 @@ export class ImapFolderService {
const trashUids = await client.search({ all: true }, { uid: true }); const trashUids = await client.search({ all: true }, { uid: true });
if (Array.isArray(trashUids) && trashUids.length > 0) { if (Array.isArray(trashUids) && trashUids.length > 0) {
await client.messageDelete(trashUids, { uid: true }); 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) { } 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) { } catch (err: any) {
this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`); this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`);
@@ -64,9 +78,13 @@ export class ImapFolderService {
} }
async moveProcessedInboxToImportiert(messageIds: string[]): Promise<number> { async moveProcessedInboxToImportiert(messageIds: string[]): Promise<number> {
if (!this.configService.get<string>('IMAP_HOST') || messageIds.length === 0) return 0; if (!this.configService.get<string>('IMAP_HOST') || messageIds.length === 0)
return 0;
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert'); const importedFolder = this.configService.get<string>(
'IMAP_IMPORTED_FOLDER',
'importiert',
);
const client = this.createClient(); const client = this.createClient();
let movedCount = 0; let movedCount = 0;
@@ -74,7 +92,7 @@ export class ImapFolderService {
await client.connect(); await client.connect();
const mailboxes = await client.list(); const mailboxes = await client.list();
if (!mailboxes.some(m => m.path === importedFolder)) { if (!mailboxes.some((m) => m.path === importedFolder)) {
await client.mailboxCreate(importedFolder); await client.mailboxCreate(importedFolder);
this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`); this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`);
} }
@@ -86,7 +104,10 @@ export class ImapFolderService {
const idSet = new Set(messageIds.map(normalize)); const idSet = new Set(messageIds.map(normalize));
const uidsToMove: number[] = []; 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; const msgId = msg.envelope?.messageId;
if (msgId && idSet.has(normalize(msgId))) { if (msgId && idSet.has(normalize(msgId))) {
uidsToMove.push(msg.uid); uidsToMove.push(msg.uid);
@@ -96,10 +117,14 @@ export class ImapFolderService {
if (uidsToMove.length > 0) { if (uidsToMove.length > 0) {
await client.messageMove(uidsToMove, importedFolder, { uid: true }); await client.messageMove(uidsToMove, importedFolder, { uid: true });
movedCount = uidsToMove.length; 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) { } catch (err: any) {
this.logger.error(`moveProcessedInboxToImportiert fehlgeschlagen: ${err.message}`); this.logger.error(
`moveProcessedInboxToImportiert fehlgeschlagen: ${err.message}`,
);
} finally { } finally {
await client.logout().catch(() => {}); await client.logout().catch(() => {});
} }
@@ -110,24 +135,34 @@ export class ImapFolderService {
async moveToImportiert(messageId: string): Promise<void> { async moveToImportiert(messageId: string): Promise<void> {
if (!this.configService.get<string>('IMAP_HOST')) return; if (!this.configService.get<string>('IMAP_HOST')) return;
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert'); const importedFolder = this.configService.get<string>(
'IMAP_IMPORTED_FOLDER',
'importiert',
);
const client = this.createClient(); const client = this.createClient();
try { try {
await client.connect(); await client.connect();
const mailboxes = await client.list(); const mailboxes = await client.list();
if (!mailboxes.some(m => m.path === importedFolder)) { if (!mailboxes.some((m) => m.path === importedFolder)) {
await client.mailboxCreate(importedFolder); await client.mailboxCreate(importedFolder);
this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`); this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`);
} }
await client.mailboxOpen('INBOX'); 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) { if (Array.isArray(uids) && uids.length > 0) {
await client.messageMove(uids, importedFolder, { uid: true }); await client.messageMove(uids, importedFolder, { uid: true });
this.logger.log(`E-Mail ${messageId} → "${importedFolder}" verschoben.`); this.logger.log(
`E-Mail ${messageId} → "${importedFolder}" verschoben.`,
);
} else { } 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) { } catch (err: any) {
this.logger.error(`IMAP moveToImportiert fehlgeschlagen: ${err.message}`); this.logger.error(`IMAP moveToImportiert fehlgeschlagen: ${err.message}`);
@@ -1,5 +1,4 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -8,6 +7,8 @@ import { DocumentType } from '../database/entities/document-type.entity';
import { PaperlessService } from './paperless.service'; import { PaperlessService } from './paperless.service';
import { PostprocessingService } from '../postprocessing/postprocessing.service'; import { PostprocessingService } from '../postprocessing/postprocessing.service';
const PAPERLESSMANAGER_TAG_ID = 16; // Tag "paperlessmanager"
@Injectable() @Injectable()
export class PaperlessProcessorService { export class PaperlessProcessorService {
private readonly logger = new Logger(PaperlessProcessorService.name); private readonly logger = new Logger(PaperlessProcessorService.name);
@@ -22,11 +23,13 @@ export class PaperlessProcessorService {
private readonly docFieldRepo: Repository<DocumentField>, private readonly docFieldRepo: Repository<DocumentField>,
) {} ) {}
@Cron(process.env.PAPERLESS_PROCESSOR_CRON || '0 * * * * *') // Manueller Batch-Lauf ("alle paperlessmanager-Dokumente neu durchlaufen").
// Die laufende Verarbeitung erfolgt ereignisgesteuert über den Webhook
// (processDocumentById), daher ist hier kein @Cron-Trigger mehr gesetzt.
async processDocuments() { async processDocuments() {
try { try {
const response = await this.paperlessService.getDocuments({ const response = await this.paperlessService.getDocuments({
tags__id__all: 16, tags__id__all: PAPERLESSMANAGER_TAG_ID,
page_size: 9999, page_size: 9999,
}); });
const documents: any[] = Array.isArray(response) const documents: any[] = Array.isArray(response)
@@ -38,17 +41,12 @@ export class PaperlessProcessorService {
const validFieldIds = new Set(customFields.map((f: any) => f.id)); const validFieldIds = new Set(customFields.map((f: any) => f.id));
this.logger.log( this.logger.log(
`Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: 16).`, `Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: ${PAPERLESSMANAGER_TAG_ID}).`,
); );
for (const doc of documents) { for (const doc of documents) {
try { try {
const updatedDoc = await this.processSingleDocument( await this.processAndEvaluate(doc, validFieldIds);
doc,
validFieldIds,
);
// Postprocessing nach dem Speichern evaluieren
await this.postprocessingService.evaluate(updatedDoc || doc);
} catch (innerErr: any) { } catch (innerErr: any) {
this.logger.error( this.logger.error(
`Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`, `Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`,
@@ -65,6 +63,38 @@ export class PaperlessProcessorService {
} }
} }
/**
* Verarbeitet ein einzelnes Dokument anhand seiner ID die ereignisgesteuerte
* Variante des früheren Cron-Jobs (vom Paperless-Webhook aufgerufen). Es wird
* jedes gemeldete Dokument verarbeitet, unabhängig vom Tag "paperlessmanager".
*/
async processDocumentById(
documentId: number,
): Promise<{ processed: boolean; reason?: string }> {
const doc = await this.paperlessService.getDocument(documentId);
const customFields = await this.paperlessService.getCustomFields();
const validFieldIds = new Set<number>(customFields.map((f: any) => f.id));
await this.processAndEvaluate(doc, validFieldIds);
this.logger.log(`Dokument ${documentId} per Webhook verarbeitet.`);
return { processed: true };
}
/**
* Reichert ein Dokument an (Tags, Pflichtfelder, Titel) und evaluiert
* anschließend das Postprocessing. Gemeinsame Logik von Batch-Lauf und Webhook.
*/
private async processAndEvaluate(
doc: any,
validFieldIds: Set<number>,
): Promise<any> {
const updatedDoc = await this.processSingleDocument(doc, validFieldIds);
// Postprocessing nach dem Speichern evaluieren
await this.postprocessingService.evaluate(updatedDoc || doc);
return updatedDoc;
}
private async processSingleDocument( private async processSingleDocument(
doc: any, doc: any,
validFieldIds: Set<number>, validFieldIds: Set<number>,
@@ -151,7 +181,7 @@ export class PaperlessProcessorService {
doc.correspondent !== null && doc.correspondent !== undefined; doc.correspondent !== null && doc.correspondent !== undefined;
break; break;
case 2: case 2:
isFilled = !!doc.created || !!doc.created_date; isFilled = !!doc.created;
break; break;
case 3: case 3:
isFilled = isFilled =
@@ -0,0 +1,201 @@
import { PaperlessTaskProcessorService } from './paperless-task-processor.service';
import { Task } from '../database/entities/task.entity';
/**
* Charakterisierungstest für processSuccessfulTask: pinnt den PATCH-Payload
* fest, damit der Refactor auf deriveTaskMetadata() verhaltensidentisch bleibt.
* custom_fields wird ordnungsunabhängig verglichen (Paperless behandelt das
* Array als Menge).
*/
function createTask(overrides: Partial<Task> = {}): Task {
return {
TaskId: 'task-uuid-1',
InterneBelegnummer: '',
DocumentType: null,
Eingangsdatum: null,
Fertig: 0,
Tags: null,
BetriebID: null,
Lieferant: null,
externeBelegnummer: null,
EinkaufID: null,
Belegdatum: null,
PaperlessDocumentID: null,
TaskReferenceID: null,
BarcodeJson: null,
DuplikatZU: null,
CustomFieldsJson: null,
Asn: null,
SourceAttachmentID: null,
SourceAttachmentRange: null,
...overrides,
} as Task;
}
function sortByField(fields: Array<{ field: number }>) {
return [...fields].sort((a, b) => a.field - b.field);
}
/** Erwartete Form des PATCH-Payloads an updateDocument. */
interface PatchPayload {
custom_fields: Array<{ field: number; value: unknown }>;
archive_serial_number?: number;
document_type?: number;
created?: string;
owner?: number | null;
tags?: number[];
title?: string;
}
describe('PaperlessTaskProcessorService.processSuccessfulTask', () => {
let paperlessService: {
getDocument: jest.Mock;
updateDocument: jest.Mock;
addNote: jest.Mock;
getDocumentMetadata: jest.Mock;
getTask: jest.Mock;
};
let taskRepo: { save: jest.Mock };
let documentRepo: { findOne: jest.Mock; create: jest.Mock; save: jest.Mock };
let attachmentRepo: { findOne: jest.Mock; save: jest.Mock };
let service: PaperlessTaskProcessorService;
/** Zugriff auf die private Methode, typsicher für den Test gekapselt. */
function runProcessSuccessfulTask(
task: Task,
apiTask: { related_document: number },
parentTask: Task | null,
): Promise<void> {
return (
service as unknown as {
processSuccessfulTask: (
t: Task,
a: { related_document: number },
p: Task | null,
) => Promise<void>;
}
).processSuccessfulTask(task, apiTask, parentTask);
}
function lastPatchPayload(): PatchPayload {
const calls = paperlessService.updateDocument.mock.calls as Array<
[number, PatchPayload]
>;
return calls[calls.length - 1][1];
}
beforeEach(() => {
paperlessService = {
getDocument: jest.fn(),
updateDocument: jest.fn().mockResolvedValue(undefined),
addNote: jest.fn().mockResolvedValue(undefined),
getDocumentMetadata: jest.fn().mockResolvedValue({
original_checksum: 'abc',
original_filename: 'test.pdf',
}),
getTask: jest.fn(),
};
taskRepo = { save: jest.fn().mockResolvedValue(undefined) };
documentRepo = {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((v: object) => v),
save: jest.fn().mockResolvedValue(undefined),
};
attachmentRepo = {
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn().mockResolvedValue(undefined),
};
service = new PaperlessTaskProcessorService(
taskRepo as any,
documentRepo as any,
attachmentRepo as any,
paperlessService as any,
);
});
it('baut den PATCH-Payload mit ASN, CF3/CF7/CF9, Typ, created, owner und Tag-Merge', async () => {
paperlessService.getDocument.mockResolvedValue({
id: 101,
title: 'Testdokument',
custom_fields: [{ field: 7, value: 'alt' }],
tags: [99],
});
const task = createTask({
InterneBelegnummer: '2026-000123',
externeBelegnummer: 'RE-9',
Eingangsdatum: new Date('2026-07-14T00:00:00Z'),
Belegdatum: new Date('2026-07-01T00:00:00Z'),
DocumentType: 2,
BetriebID: 3,
Tags: '1,2',
CustomFieldsJson: JSON.stringify({ '4': 'x' }),
});
await runProcessSuccessfulTask(task, { related_document: 101 }, null);
expect(paperlessService.updateDocument).toHaveBeenCalledTimes(1);
expect(paperlessService.updateDocument).toHaveBeenCalledWith(
101,
expect.anything(),
);
const payload = lastPatchPayload();
expect(payload.archive_serial_number).toBe(2026000123);
expect(payload.document_type).toBe(2);
expect(payload.created).toBe('2026-07-01T00:00:00.000Z');
expect(payload.owner).toBe(3);
expect(payload.tags).toEqual([99, 1, 2]);
expect(sortByField(payload.custom_fields)).toEqual([
{ field: 3, value: 'RE-9' },
{ field: 4, value: 'x' },
{ field: 7, value: '2026-000123' },
{ field: 9, value: '2026-07-14' },
]);
expect(task.Fertig).toBe(1);
expect(task.PaperlessDocumentID).toBe(101);
});
it('entfernt den Owner (null) bei leerer BetriebID', async () => {
paperlessService.getDocument.mockResolvedValue({
id: 102,
title: 'Ohne Betrieb',
custom_fields: [],
tags: [],
});
const task = createTask({ InterneBelegnummer: '2026-000124' });
await runProcessSuccessfulTask(task, { related_document: 102 }, null);
expect(lastPatchPayload().owner).toBeNull();
});
it('überschreibt bei Anlagen document_type/title und verknüpft CF8 zum Elterndokument', async () => {
paperlessService.getDocument
.mockResolvedValueOnce({
id: 103,
title: 'Anlage-Roh',
custom_fields: [],
tags: [],
})
.mockResolvedValueOnce({ id: 55, title: 'Eltern' });
paperlessService.getTask.mockResolvedValue([{ related_document: 55 }]);
const task = createTask({
DocumentType: 2,
TaskReferenceID: 'parent-uuid',
});
const parentTask = createTask({
TaskId: 'parent-uuid',
InterneBelegnummer: '2026-000100',
Fertig: 1,
});
await runProcessSuccessfulTask(task, { related_document: 103 }, parentTask);
const payload = lastPatchPayload();
expect(payload.document_type).toBe(5);
expect(payload.title).toBe('Anlage zu 2026-000100');
expect(sortByField(payload.custom_fields)).toEqual([
{ field: 8, value: [55] },
]);
});
});
@@ -6,6 +6,7 @@ import { Task } from '../database/entities/task.entity';
import { Document } from '../database/entities/document.entity'; import { Document } from '../database/entities/document.entity';
import { Attachment } from '../database/entities/attachment.entity'; import { Attachment } from '../database/entities/attachment.entity';
import { PaperlessService } from './paperless.service'; import { PaperlessService } from './paperless.service';
import { deriveTaskMetadata } from './task-metadata.util';
@Injectable() @Injectable()
export class PaperlessTaskProcessorService { export class PaperlessTaskProcessorService {
@@ -74,7 +75,12 @@ export class PaperlessTaskProcessorService {
await this.processSuccessfulTask(t, apiResponseTask, parentTask); await this.processSuccessfulTask(t, apiResponseTask, parentTask);
} }
} else if (apiResponseTask.status === 'FAILURE') { } else if (apiResponseTask.status === 'FAILURE') {
this.logger.warn(`Task ${t.TaskId} failed in Paperless`); // Seit Metadaten (inkl. ASN) direkt beim Upload gesetzt werden, kann
// z.B. eine ASN-Kollision bereits den Consume scheitern lassen
// vor dem Löschen die Diagnose-Infos festhalten
this.logger.error(
`Task ${t.TaskId} in Paperless fehlgeschlagen (Beleg: ${t.InterneBelegnummer || '-'}, Attachment: ${t.SourceAttachmentID ?? '-'}). Paperless-Meldung: ${apiResponseTask.result ?? 'keine'}`,
);
toDelete.push(t); toDelete.push(t);
} }
} else { } else {
@@ -200,104 +206,34 @@ export class PaperlessTaskProcessorService {
: [], : [],
}; };
// CustomFieldsJson als Basis zuerst anwenden dedizierte Felder weiter unten überschreiben diese // Gemeinsame Ableitung identische Regeln wie beim direkten Upload (task-metadata.util)
if (t.CustomFieldsJson) { const derived = deriveTaskMetadata(t);
try {
const extra = JSON.parse(t.CustomFieldsJson) as Record< for (const [k, v] of Object.entries(derived.customFields)) {
string,
string
>;
for (const [k, v] of Object.entries(extra)) {
const fieldId = parseInt(k, 10); const fieldId = parseInt(k, 10);
if (!Number.isFinite(fieldId)) continue;
const idx = updateData.custom_fields.findIndex( const idx = updateData.custom_fields.findIndex(
(f: any) => f.field === fieldId, (f: any) => f.field === fieldId,
); );
if (idx !== -1) updateData.custom_fields[idx].value = v; if (idx !== -1) updateData.custom_fields[idx].value = v;
else updateData.custom_fields.push({ field: fieldId, value: v }); else updateData.custom_fields.push({ field: fieldId, value: v });
} }
} catch {
/* JSON-Parse-Fehler ignorieren */
}
}
if (t.Asn) { if (derived.archiveSerialNumber !== undefined) {
const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10);
if (!isNaN(asnNum)) {
this.logger.log( this.logger.log(
`[Postprocessing] Task ${t.TaskId} - Setze ASN (explizit): ${asnNum}`, `[Postprocessing] Task ${t.TaskId} - Setze ASN: ${derived.archiveSerialNumber}`,
); );
updateData.archive_serial_number = asnNum; updateData.archive_serial_number = derived.archiveSerialNumber;
} } else if (t.Asn || t.InterneBelegnummer) {
}
if (t.InterneBelegnummer) {
this.logger.log(
`[Postprocessing] Task ${t.TaskId} - Setze InterneBelegnummer: ${t.InterneBelegnummer}`,
);
if (!t.Asn) {
const asnFromBelegnummer = parseInt(
t.InterneBelegnummer.replace(/-/g, ''),
10,
);
if (!isNaN(asnFromBelegnummer)) {
updateData.archive_serial_number = asnFromBelegnummer;
} else {
this.logger.warn( this.logger.warn(
`[Postprocessing] Task ${t.TaskId} - ASN aus InterneBelegnummer konnte nicht geparst werden: ${t.InterneBelegnummer}`, `[Postprocessing] Task ${t.TaskId} - ASN konnte nicht abgeleitet werden (Asn: ${t.Asn ?? '-'}, InterneBelegnummer: ${t.InterneBelegnummer || '-'})`,
); );
} }
}
const existingField7 = updateData.custom_fields.find(
(f: any) => f.field === 7,
);
if (existingField7) {
existingField7.value = t.InterneBelegnummer;
} else {
updateData.custom_fields.push({
field: 7,
value: t.InterneBelegnummer,
});
}
}
if (t.externeBelegnummer) { if (derived.documentType !== undefined) {
this.logger.log( this.logger.log(
`[Postprocessing] Task ${t.TaskId} - Setze externeBelegnummer: ${t.externeBelegnummer}`, `[Postprocessing] Task ${t.TaskId} - Setze DocumentType: ${derived.documentType}`,
); );
const existingField3 = updateData.custom_fields.find( updateData.document_type = derived.documentType;
(f: any) => f.field === 3,
);
if (existingField3) {
existingField3.value = t.externeBelegnummer;
} else {
updateData.custom_fields.push({
field: 3,
value: t.externeBelegnummer,
});
}
}
if (t.Eingangsdatum) {
const dateValue = new Date(t.Eingangsdatum).toISOString().split('T')[0];
this.logger.log(
`[Postprocessing] Task ${t.TaskId} - Setze Eingangsdatum: ${dateValue}`,
);
const existingField9 = updateData.custom_fields.find(
(f: any) => f.field === 9,
);
if (existingField9) {
existingField9.value = dateValue;
} else {
updateData.custom_fields.push({ field: 9, value: dateValue });
}
}
if (t.DocumentType) {
this.logger.log(
`[Postprocessing] Task ${t.TaskId} - Setze DocumentType: ${t.DocumentType}`,
);
updateData.document_type = t.DocumentType;
} }
// Parent Task / Attachment logic // Parent Task / Attachment logic
@@ -350,36 +286,33 @@ export class PaperlessTaskProcessorService {
} }
} }
if (t.Belegdatum) { if (derived.created) {
this.logger.log( this.logger.log(
`[Postprocessing] Task ${t.TaskId} - Setze Belegdatum: ${t.Belegdatum.toISOString()}`, `[Postprocessing] Task ${t.TaskId} - Setze Belegdatum: ${derived.created}`,
); );
updateData.created = t.Belegdatum.toISOString(); updateData.created = derived.created;
} }
if (t.BetriebID) { if (derived.owner !== undefined) {
this.logger.log( this.logger.log(
`[Postprocessing] Task ${t.TaskId} - Setze Owner: ${t.BetriebID}`, `[Postprocessing] Task ${t.TaskId} - Setze Owner: ${derived.owner}`,
); );
updateData.owner = t.BetriebID;
} else { } else {
this.logger.log( this.logger.log(
`[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`, `[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`,
); );
updateData.owner = null;
} }
updateData.owner = derived.owner ?? null;
// Tags // Tags: Upload-/Consume-Tags des Dokuments bleiben erhalten (Merge statt Ersetzen)
if (t.Tags) { if (derived.tags) {
this.logger.log( this.logger.log(
`[Postprocessing] Task ${t.TaskId} - Setze Tags: ${t.Tags}`, `[Postprocessing] Task ${t.TaskId} - Setze Tags: ${t.Tags}`,
); );
const tagIds = t.Tags.split(',')
.map((id) => parseInt(id.trim(), 10))
.filter((id) => !isNaN(id));
const currentTags = document.tags || []; const currentTags = document.tags || [];
const newTags = Array.from(new Set([...currentTags, ...tagIds])); updateData.tags = Array.from(
updateData.tags = newTags; new Set([...currentTags, ...derived.tags]),
);
} }
// Agrarmonitor Link (Skip API call for now, but save the link if needed) // Agrarmonitor Link (Skip API call for now, but save the link if needed)
@@ -23,6 +23,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { PaperlessService } from './paperless.service'; import { PaperlessService } from './paperless.service';
import { deriveTaskMetadata } from './task-metadata.util';
import { ApiKeyGuard } from '../auth/api-key.guard'; import { ApiKeyGuard } from '../auth/api-key.guard';
import { UploadExternalDto } from './dto/upload-external.dto'; import { UploadExternalDto } from './dto/upload-external.dto';
import { Task } from '../database/entities/task.entity'; import { Task } from '../database/entities/task.entity';
@@ -155,7 +156,7 @@ export class PaperlessController {
asn: doc.archive_serial_number, asn: doc.archive_serial_number,
documentType: doc.document_type, documentType: doc.document_type,
correspondent: doc.correspondent, correspondent: doc.correspondent,
created: doc.created_date, created: doc.created,
added: doc.added, added: doc.added,
tags: doc.tags, tags: doc.tags,
customFields: doc.custom_fields, customFields: doc.custom_fields,
@@ -179,7 +180,7 @@ export class PaperlessController {
asn: doc.archive_serial_number, asn: doc.archive_serial_number,
documentType: doc.document_type, documentType: doc.document_type,
correspondent: doc.correspondent, correspondent: doc.correspondent,
created: doc.created_date, created: doc.created,
added: doc.added, added: doc.added,
tags: doc.tags, tags: doc.tags,
customFields: doc.custom_fields, customFields: doc.custom_fields,
@@ -338,7 +339,7 @@ export class PaperlessController {
docDate.getHours() * 60 * 60 * 1000, docDate.getHours() * 60 * 60 * 1000,
); );
} }
oldDocument.created_date = docDate.toISOString().split('T')[0]; oldDocument.created = docDate.toISOString();
} }
const cfDefinitions = await this.paperlessService.getCustomFields(); const cfDefinitions = await this.paperlessService.getCustomFields();
@@ -390,7 +391,7 @@ export class PaperlessController {
for (const req of reqs) { for (const req of reqs) {
let isFieldValid = false; let isFieldValid = false;
if (req.Type === 1) isFieldValid = oldDocument.correspondent !== null; 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) if (req.Type === 3)
isFieldValid = oldDocument.archive_serial_number !== null; isFieldValid = oldDocument.archive_serial_number !== null;
if (req.Type === 4) if (req.Type === 4)
@@ -446,10 +447,8 @@ export class PaperlessController {
); );
} }
} }
titleTemplate = titleTemplate.replace( const createdDatePart = String(oldDocument.created ?? '').split('T')[0];
'{{DATE}}', titleTemplate = titleTemplate.replace('{{DATE}}', createdDatePart);
oldDocument.created_date,
);
oldDocument.title = titleTemplate; oldDocument.title = titleTemplate;
} }
} else { } else {
@@ -471,6 +470,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); await this.paperlessService.updateDocument(documentId, oldDocument);
return { success: true }; return { success: true };
} }
@@ -513,11 +515,28 @@ export class PaperlessController {
// 0. Check if ASN already exists // 0. Check if ASN already exists
await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer); await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer);
// 1. Forward to Paperless // 1. Forward to Paperless Metadaten direkt beim Upload mitgeben;
// der Task-Processor patcht später idempotent nach (Sicherheitsnetz)
const derived = deriveTaskMetadata({
InterneBelegnummer: dto.interneBelegnummer,
externeBelegnummer: dto.externeBelegnummer ?? null,
Eingangsdatum: dto.Eingangsdatum ? new Date(dto.Eingangsdatum) : null,
Belegdatum: dto.belegdatum ? new Date(dto.belegdatum) : null,
// Anlagen (parentId gesetzt) bekommen Typ 5 sofort; Titel/CF8 folgen im Processor
DocumentType: dto.parentId ? 5 : (dto.dokumentType ?? null),
Tags: dto.tag ? String(dto.tag) : null,
BetriebID: dto.betriebId ?? null,
});
const paperlessTaskId = await this.paperlessService.uploadDocument( const paperlessTaskId = await this.paperlessService.uploadDocument(
file.path, file.path,
{ {
title: `Beleg ${dto.interneBelegnummer}`, title: `Beleg ${dto.interneBelegnummer}`,
created: derived.created,
documentType: derived.documentType,
owner: derived.owner,
tags: derived.tags,
archiveSerialNumber: derived.archiveSerialNumber,
customFields: derived.customFields,
}, },
); );
@@ -32,6 +32,6 @@ import { AuthModule } from '../auth/auth.module';
PaperlessProcessorService, PaperlessProcessorService,
PaperlessTaskProcessorService, PaperlessTaskProcessorService,
], ],
exports: [PaperlessService], exports: [PaperlessService, PaperlessProcessorService],
}) })
export class PaperlessModule {} export class PaperlessModule {}
@@ -250,6 +250,14 @@ export class PaperlessService {
return response.data; return response.data;
} }
async getNotes(
id: number,
): Promise<Array<{ id: number; note: string; created: string }>> {
const response = await this.client.get(`/documents/${id}/notes/`);
const data = response.data;
return Array.isArray(data) ? data : (data?.results ?? []);
}
async checksumExists(checksum: string): Promise<boolean> { async checksumExists(checksum: string): Promise<boolean> {
const response = await this.client.get('/documents/', { const response = await this.client.get('/documents/', {
params: { checksum__iexact: checksum }, params: { checksum__iexact: checksum },
@@ -0,0 +1,139 @@
import { deriveTaskMetadata } from './task-metadata.util';
describe('deriveTaskMetadata', () => {
describe('archiveSerialNumber', () => {
it('parst ASN aus Asn und entfernt alle Nicht-Ziffern', () => {
const result = deriveTaskMetadata({ Asn: 'ASN-123' });
expect(result.archiveSerialNumber).toBe(123);
});
it('leitet ASN aus InterneBelegnummer ab (nur Bindestriche entfernen)', () => {
const result = deriveTaskMetadata({ InterneBelegnummer: '2026-000123' });
expect(result.archiveSerialNumber).toBe(2026000123);
});
it('bevorzugt Asn vor InterneBelegnummer', () => {
const result = deriveTaskMetadata({
Asn: '42',
InterneBelegnummer: '2026-000123',
});
expect(result.archiveSerialNumber).toBe(42);
});
it('nutzt keinen Belegnummer-Fallback, wenn Asn gesetzt aber unparsebar ist', () => {
// Entspricht exakt der Processor-Logik: Fallback nur bei leerem Asn
const result = deriveTaskMetadata({
Asn: 'abc',
InterneBelegnummer: '2026-000123',
});
expect(result.archiveSerialNumber).toBeUndefined();
});
it('lässt ASN weg, wenn InterneBelegnummer nicht parsebar ist', () => {
const result = deriveTaskMetadata({ InterneBelegnummer: 'Beleg-X' });
expect(result.archiveSerialNumber).toBeUndefined();
});
});
describe('customFields (CF7/CF3/CF9)', () => {
it('setzt CF7 aus InterneBelegnummer', () => {
const result = deriveTaskMetadata({ InterneBelegnummer: '2026-000123' });
expect(result.customFields['7']).toBe('2026-000123');
});
it('setzt CF3 aus externeBelegnummer', () => {
const result = deriveTaskMetadata({ externeBelegnummer: 'RE-4711' });
expect(result.customFields['3']).toBe('RE-4711');
});
it('setzt CF9 aus Eingangsdatum als YYYY-MM-DD', () => {
const result = deriveTaskMetadata({
Eingangsdatum: new Date('2026-07-14T10:30:00Z'),
});
expect(result.customFields['9']).toBe('2026-07-14');
});
it('übernimmt CustomFieldsJson als Basis mit normalisierten numerischen Keys', () => {
const result = deriveTaskMetadata({
CustomFieldsJson: JSON.stringify({ '4': 'wert4', abc: 'ignoriert' }),
});
expect(result.customFields).toEqual({ '4': 'wert4' });
});
it('überschreibt CustomFieldsJson-Basis mit dedizierten Feldern CF7/CF3/CF9', () => {
const result = deriveTaskMetadata({
InterneBelegnummer: '2026-000123',
externeBelegnummer: 'RE-4711',
Eingangsdatum: new Date('2026-07-14T00:00:00Z'),
CustomFieldsJson: JSON.stringify({
'7': 'alt7',
'3': 'alt3',
'9': 'alt9',
}),
});
expect(result.customFields['7']).toBe('2026-000123');
expect(result.customFields['3']).toBe('RE-4711');
expect(result.customFields['9']).toBe('2026-07-14');
});
it('ignoriert ungültiges CustomFieldsJson still', () => {
const result = deriveTaskMetadata({
CustomFieldsJson: '{kein json',
InterneBelegnummer: '2026-000123',
});
expect(result.customFields).toEqual({ '7': '2026-000123' });
});
it('liefert leeres customFields-Objekt ohne Eingaben', () => {
const result = deriveTaskMetadata({});
expect(result.customFields).toEqual({});
});
});
describe('documentType / created / owner / tags', () => {
it('übernimmt DocumentType nur wenn truthy', () => {
expect(deriveTaskMetadata({ DocumentType: 5 }).documentType).toBe(5);
expect(
deriveTaskMetadata({ DocumentType: 0 }).documentType,
).toBeUndefined();
expect(
deriveTaskMetadata({ DocumentType: null }).documentType,
).toBeUndefined();
});
it('leitet created als ISO-String aus Belegdatum ab', () => {
const result = deriveTaskMetadata({
Belegdatum: new Date('2026-07-01T00:00:00Z'),
});
expect(result.created).toBe('2026-07-01T00:00:00.000Z');
});
it('setzt owner nur bei truthy BetriebID', () => {
expect(deriveTaskMetadata({ BetriebID: 3 }).owner).toBe(3);
expect(deriveTaskMetadata({ BetriebID: null }).owner).toBeUndefined();
expect(deriveTaskMetadata({}).owner).toBeUndefined();
});
it('parst Tags-CSV und filtert Ungültiges', () => {
const result = deriveTaskMetadata({ Tags: '1, 2, x' });
expect(result.tags).toEqual([1, 2]);
});
it('lässt tags bei leerem Tags-Feld weg', () => {
expect(deriveTaskMetadata({ Tags: null }).tags).toBeUndefined();
expect(deriveTaskMetadata({}).tags).toBeUndefined();
});
});
describe('leere Eingaben', () => {
it('lässt bei leeren Strings alle Felder weg', () => {
const result = deriveTaskMetadata({
InterneBelegnummer: '',
Asn: '',
externeBelegnummer: '',
Tags: '',
});
expect(result).toEqual({ customFields: {} });
});
});
});
@@ -0,0 +1,102 @@
/**
* Leitet Paperless-Metadaten aus Task-Feldern ab.
*
* Wird sowohl von den Upload-Pfaden (external-upload, E-Mail-Import) als auch
* vom PaperlessTaskProcessorService genutzt, damit beim Upload und beim
* nachgelagerten PATCH garantiert dieselben Ableitungsregeln gelten.
*/
/** Teilmenge der Task-Entity-Felder, aus denen Metadaten abgeleitet werden. */
export interface TaskMetadataInput {
InterneBelegnummer?: string | null;
Asn?: string | null;
externeBelegnummer?: string | null;
Eingangsdatum?: Date | string | null;
Belegdatum?: Date | string | null;
DocumentType?: number | null;
/** CSV wie Task.Tags, z.B. "1,2,3" */
Tags?: string | null;
BetriebID?: number | null;
CustomFieldsJson?: string | null;
}
export interface DerivedTaskMetadata {
archiveSerialNumber?: number;
/** Basis aus CustomFieldsJson; CF7/CF3/CF9 überschreiben die Basis. */
customFields: Record<string, string>;
documentType?: number;
/** ISO-String aus Belegdatum */
created?: string;
/** Nur gesetzt, wenn BetriebID truthy — Owner-Entfernung bleibt Sache des Processors. */
owner?: number;
tags?: number[];
}
export function deriveTaskMetadata(t: TaskMetadataInput): DerivedTaskMetadata {
const result: DerivedTaskMetadata = { customFields: {} };
// Basis aus CustomFieldsJson dedizierte Felder unten überschreiben diese
if (t.CustomFieldsJson) {
try {
const extra = JSON.parse(t.CustomFieldsJson) as Record<string, string>;
for (const [k, v] of Object.entries(extra)) {
const fieldId = parseInt(k, 10);
if (!Number.isFinite(fieldId)) continue;
result.customFields[String(fieldId)] = v;
}
} catch {
/* JSON-Parse-Fehler ignorieren */
}
}
if (t.Asn) {
const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10);
if (!isNaN(asnNum)) {
result.archiveSerialNumber = asnNum;
}
}
if (t.InterneBelegnummer) {
// ASN-Fallback nur bei leerem Asn; bewusst nur Bindestriche entfernen
if (!t.Asn) {
const asnFromBelegnummer = parseInt(
t.InterneBelegnummer.replace(/-/g, ''),
10,
);
if (!isNaN(asnFromBelegnummer)) {
result.archiveSerialNumber = asnFromBelegnummer;
}
}
result.customFields['7'] = t.InterneBelegnummer;
}
if (t.externeBelegnummer) {
result.customFields['3'] = t.externeBelegnummer;
}
if (t.Eingangsdatum) {
result.customFields['9'] = new Date(t.Eingangsdatum)
.toISOString()
.split('T')[0];
}
if (t.DocumentType) {
result.documentType = t.DocumentType;
}
if (t.Belegdatum) {
result.created = new Date(t.Belegdatum).toISOString();
}
if (t.BetriebID) {
result.owner = t.BetriebID;
}
if (t.Tags) {
result.tags = t.Tags.split(',')
.map((id) => parseInt(id.trim(), 10))
.filter((id) => !isNaN(id));
}
return result;
}
@@ -0,0 +1,184 @@
// PaperlessProcessorService zieht über die Postprocessing-Kette das ESM-Paket
// "webdav" nach, das Jest nicht transformiert. Für diesen Unit-Test ersetzen wir
// das Modul durch eine Dummy-Klasse der Service erhält seine Abhängigkeiten
// ohnehin als Mocks injiziert.
jest.mock('../paperless/paperless-processor.service', () => ({
PaperlessProcessorService: class {},
}));
import { WebhookQueueService } from './webhook-queue.service';
/**
* Baut ein Mock-Repository, das die `webhook_queue`-Tabelle durch ein einfaches
* FIFO-Array simuliert: `INSERT IGNORE` (Dedup), FIFO-`find` und `delete`.
*/
function createQueueRepoMock() {
const store: { documentId: number; createdAt: Date }[] = [];
return {
store,
createQueryBuilder: jest.fn(() => ({
insert: () => ({
into: () => ({
values: (v: { documentId: number }) => ({
orIgnore: () => ({
execute: () => {
const added = !store.some((s) => s.documentId === v.documentId);
if (added) {
// Standardmäßig "alt genug" (60s), damit die Mindest-Wartezeit
// die Verhaltenstests nicht blockiert.
store.push({
documentId: v.documentId,
createdAt: new Date(Date.now() - 60_000),
});
}
return Promise.resolve({
raw: { affectedRows: added ? 1 : 0 },
});
},
}),
}),
}),
}),
})),
find: jest.fn(() => {
// Simuliert den DB-seitigen Alters-Filter (createdAt <= NOW() - 5000 ms).
const eligible = [...store]
.filter((s) => Date.now() - s.createdAt.getTime() >= 5000)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
return Promise.resolve(
eligible.length
? [
{
documentId: eligible[0].documentId,
action: null,
createdAt: eligible[0].createdAt,
},
]
: [],
);
}),
delete: jest.fn((criteria: { documentId: number }) => {
const idx = store.findIndex((s) => s.documentId === criteria.documentId);
if (idx >= 0) store.splice(idx, 1);
return Promise.resolve({ affected: 1 });
}),
count: jest.fn(() => Promise.resolve(store.length)),
};
}
describe('WebhookQueueService', () => {
let processor: { processDocumentById: jest.Mock };
let settingRepo: { findOneBy: jest.Mock; create: jest.Mock; save: jest.Mock };
let queueRepo: ReturnType<typeof createQueueRepoMock>;
let service: WebhookQueueService;
beforeEach(() => {
processor = {
processDocumentById: jest.fn().mockResolvedValue({ processed: true }),
};
settingRepo = {
findOneBy: jest.fn().mockResolvedValue(null),
create: jest.fn((x: unknown) => x),
save: jest.fn().mockResolvedValue(undefined),
};
queueRepo = createQueueRepoMock();
service = new WebhookQueueService(
processor as never,
settingRepo as never,
queueRepo as never,
);
});
it('reiht jede ID nur einmal ein (Dedup)', async () => {
await service.enqueue(5);
await service.enqueue(5);
expect(queueRepo.store.map((s) => s.documentId)).toEqual([5]);
expect(await service.count()).toBe(1);
});
it('verarbeitet alle eingereihten IDs und leert die Warteschlange', async () => {
await service.enqueue(1);
await service.enqueue(2);
await service.processQueue();
expect(processor.processDocumentById).toHaveBeenCalledWith(1);
expect(processor.processDocumentById).toHaveBeenCalledWith(2);
expect(processor.processDocumentById).toHaveBeenCalledTimes(2);
expect(queueRepo.store).toHaveLength(0);
});
it('entfernt die ID vor Verarbeitungsbeginn aus der Tabelle', async () => {
let containedWhileProcessing = true;
processor.processDocumentById.mockImplementation((id: number) => {
containedWhileProcessing = queueRepo.store.some(
(s) => s.documentId === id,
);
return Promise.resolve({ processed: true });
});
await service.enqueue(42);
await service.processQueue();
// Beim Verarbeitungsstart war die ID bereits aus der Tabelle entfernt.
expect(containedWhileProcessing).toBe(false);
});
it('arbeitet eine während der Verarbeitung erneut eingereihte ID erneut ab', async () => {
let firstRun = true;
processor.processDocumentById.mockImplementation(async (id: number) => {
// Beim ersten Lauf feuert der Webhook erneut, während verarbeitet wird.
if (firstRun && id === 7) {
firstRun = false;
await service.enqueue(7);
}
return { processed: true };
});
await service.enqueue(7);
await service.processQueue();
expect(processor.processDocumentById).toHaveBeenCalledTimes(2);
expect(queueRepo.store).toHaveLength(0);
});
it('startet keinen zweiten Durchlauf parallel (isProcessing-Guard)', async () => {
let resolveFirst: (() => void) | undefined;
let signalStarted!: () => void;
const started = new Promise<void>((res) => {
signalStarted = res;
});
processor.processDocumentById.mockImplementation(
() =>
new Promise<{ processed: boolean }>((res) => {
resolveFirst = () => res({ processed: true });
signalStarted(); // erste Verarbeitung läuft und blockiert hier
}),
);
await service.enqueue(1);
const firstRun = service.processQueue(); // blockiert in processDocumentById
await started; // warten, bis der erste Lauf tatsächlich verarbeitet
await service.processQueue(); // muss sofort zurückkehren (isProcessing)
expect(processor.processDocumentById).toHaveBeenCalledTimes(1);
resolveFirst?.();
await firstRun;
});
it('verarbeitet einen Eintrag erst nach der Mindest-Wartezeit', async () => {
// Frisch eingereihter Eintrag (Alter ~0) darf noch nicht verarbeitet werden.
queueRepo.store.push({ documentId: 99, createdAt: new Date() });
await service.processQueue();
expect(processor.processDocumentById).not.toHaveBeenCalled();
expect(queueRepo.store).toHaveLength(1);
// Nach Überschreiten der Mindest-Wartezeit (5000 ms) wird verarbeitet.
queueRepo.store[0].createdAt = new Date(Date.now() - 6000);
await service.processQueue();
expect(processor.processDocumentById).toHaveBeenCalledWith(99);
expect(queueRepo.store).toHaveLength(0);
});
});
@@ -0,0 +1,197 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Raw, Repository } from 'typeorm';
import { PaperlessProcessorService } from '../paperless/paperless-processor.service';
import { Setting } from '../database/entities/setting.entity';
import { WebhookQueueItem } from '../database/entities/webhook-queue-item.entity';
// Tag (Schlüssel) des Settings-Eintrags, der den letzten Webhook-Aufruf festhält.
const LAST_WEBHOOK_CALL_TAG = 'last_webhook_call';
// Prüfintervall der Warteschlange (sehr kurz). Über ENV überschreibbar.
const QUEUE_INTERVAL_MS = Number(process.env.WEBHOOK_QUEUE_INTERVAL_MS) || 1000;
// Mindest-Verweildauer zwischen Einreihen und Verarbeitung. Ein Eintrag wird
// frühestens verarbeitet, wenn er so lange in der Warteschlange lag. Über ENV
// überschreibbar (Default 5000 ms).
const MIN_QUEUE_AGE_MS = Number(process.env.WEBHOOK_QUEUE_MIN_AGE_MS) || 5000;
interface WebhookStatusInfo {
documentId: number | null;
action?: string;
status: 'queued' | 'processed' | 'error' | 'bad-request';
reason?: string;
message?: string;
}
/**
* Entkoppelt den Webhook-Empfang von der Verarbeitung: Eingehende Dokument-IDs
* werden in eine **persistente**, deduplizierte Warteschlange (`webhook_queue`)
* gelegt und von einem separaten Intervall-Prozess **sequenziell** (ohne
* Überschneidung) abgearbeitet. So führt mehrfaches schnelles Speichern
* desselben Dokuments nicht zu parallelen Läufen.
*
* Verhalten:
* - Jede ID kommt nur **einmal** in der Warteschlange vor (Dedup via Primär-
* schlüssel `documentId` / `INSERT IGNORE`).
* - Beim Verarbeitungsstart wird die ID **sofort** aus der Tabelle entfernt; ein
* erneutes Feuern während der Verarbeitung reiht sie wieder ein (ein weiterer
* Lauf folgt danach).
* - Es läuft immer nur eine Verarbeitung gleichzeitig (`isProcessing`-Guard).
* - Zwischen Einreihen und Verarbeitung liegen mindestens `MIN_QUEUE_AGE_MS`
* (Default 5000 ms); jüngere Einträge warten bis zum nächsten Tick.
*
* Da die Warteschlange in der Datenbank liegt, überstehen ausstehende IDs einen
* Neustart und werden nach dem Boot weiterverarbeitet.
*/
@Injectable()
export class WebhookQueueService {
private readonly logger = new Logger(WebhookQueueService.name);
private isProcessing = false;
constructor(
private readonly paperlessProcessor: PaperlessProcessorService,
@InjectRepository(Setting)
private readonly settingRepo: Repository<Setting>,
@InjectRepository(WebhookQueueItem)
private readonly queueRepo: Repository<WebhookQueueItem>,
) {}
/** Anzahl der aktuell wartenden Dokument-IDs. */
count(): Promise<number> {
return this.queueRepo.count();
}
/**
* Reiht eine Dokument-ID zur Verarbeitung ein. Ist die ID bereits in der
* Warteschlange, wird sie nicht erneut hinzugefügt (Dedup über den Primär-
* schlüssel; `INSERT IGNORE` ist atomar und race-sicher).
*/
async enqueue(documentId: number, action?: string): Promise<void> {
const result = await this.queueRepo
.createQueryBuilder()
.insert()
.into(WebhookQueueItem)
.values({ documentId, action: action ?? null })
.orIgnore()
.execute();
const added =
((result.raw as { affectedRows?: number })?.affectedRows ?? 0) > 0;
if (added) {
this.logger.log(`Dokument ${documentId} eingereiht.`);
} else {
this.logger.log(
`Dokument ${documentId} ist bereits in der Warteschlange nicht erneut hinzugefügt.`,
);
}
await this.recordStatus({ documentId, action, status: 'queued' });
}
/**
* Prüft in kurzen Abständen die Warteschlange und arbeitet vorhandene IDs
* nacheinander ab. Ein bereits laufender Durchlauf wird nicht doppelt
* gestartet (kein paralleles Verarbeiten).
*/
@Interval(QUEUE_INTERVAL_MS)
async processQueue(): Promise<void> {
if (this.isProcessing) return;
this.isProcessing = true;
try {
// Solange (alte genug) Einträge vorhanden sind, sequenziell abarbeiten.
for (;;) {
// Ältesten Eintrag (FIFO) holen, der die Mindest-Wartezeit erfüllt.
// Der Altersvergleich läuft bewusst in der DB (NOW() vs. createdAt) und
// ist damit unabhängig von Zeitzone/Uhr des Node-Prozesses ein Node/DB-
// Zeitversatz würde sonst den Vergleich verfälschen (Einträge nie "alt
// genug"). Nur ein numerisches Konstantenliteral wird interpoliert.
const [next] = await this.queueRepo.find({
where: {
createdAt: Raw(
(alias) =>
`${alias} <= (NOW(6) - INTERVAL ${MIN_QUEUE_AGE_MS * 1000} MICROSECOND)`,
),
},
order: { createdAt: 'ASC' },
take: 1,
});
if (!next) break; // Warteschlange leer oder noch nichts alt genug
// SOFORT (vor Verarbeitungsstart) aus der Tabelle entfernen.
await this.queueRepo.delete({ documentId: next.documentId });
await this.handleDocument(next.documentId);
}
} finally {
this.isProcessing = false;
}
}
private async handleDocument(documentId: number): Promise<void> {
try {
await this.paperlessProcessor.processDocumentById(documentId);
this.logger.log(`Dokument ${documentId} aus Warteschlange verarbeitet.`);
await this.recordStatus({ documentId, status: 'processed' });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
`Fehler bei der Verarbeitung von Dokument ${documentId}: ${message}`,
);
await this.recordStatus({ documentId, status: 'error', message });
}
}
/**
* Liefert den letzten festgehaltenen Webhook-Status sowie die aktuelle
* Warteschlangen-Größe.
*/
async getStatus(): Promise<{ lastCall: unknown; queueSize: number }> {
const setting = await this.settingRepo.findOneBy({
Tag: LAST_WEBHOOK_CALL_TAG,
});
let lastCall: unknown = null;
if (setting?.Wert) {
try {
lastCall = JSON.parse(setting.Wert);
} catch {
lastCall = { raw: setting.Wert };
}
}
return { lastCall, queueSize: await this.queueRepo.count() };
}
/**
* Hält den letzten Webhook-Vorgang in der Settings-Tabelle fest
* (Tag "last_webhook_call"). Fehler beim Speichern werden nur geloggt.
*/
async recordStatus(info: WebhookStatusInfo): Promise<void> {
try {
const value = JSON.stringify({
at: new Date().toISOString(),
documentId: info.documentId,
action: info.action ?? null,
status: info.status,
...(info.reason ? { reason: info.reason } : {}),
...(info.message ? { message: info.message.slice(0, 100) } : {}),
});
let setting = await this.settingRepo.findOneBy({
Tag: LAST_WEBHOOK_CALL_TAG,
});
if (!setting) {
setting = this.settingRepo.create({
Typ: 0,
Tag: LAST_WEBHOOK_CALL_TAG,
Wert: value,
});
} else {
setting.Wert = value;
}
await this.settingRepo.save(setting);
} catch (err) {
this.logger.error(
'Konnte Webhook-Status nicht in den Settings speichern',
err instanceof Error ? err.stack : String(err),
);
}
}
}
@@ -1,36 +1,85 @@
import { import {
Controller, Controller,
Post, Post,
Get,
Body, Body,
Logger, Logger,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
UseGuards,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Public } from '../auth/public.decorator'; import { ApiKeyGuard } from '../auth/api-key.guard';
import { WebhookQueueService } from './webhook-queue.service';
export interface PaperlessWebhookPayload { export interface PaperlessWebhookPayload {
document_id: number; doc_url?: string;
action: string; document_id?: number; // optional, für manuelles Testen
action?: string;
[key: string]: any; [key: string]: any;
} }
@Controller('webhook') @Controller('api/webhook')
export class WebhookController { export class WebhookController {
private readonly logger = new Logger(WebhookController.name); private readonly logger = new Logger(WebhookController.name);
@Public() constructor(private readonly webhookQueue: WebhookQueueService) {}
@UseGuards(ApiKeyGuard)
@Post('paperless') @Post('paperless')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async handlePaperlessWebhook( async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload) {
@Body() payload: PaperlessWebhookPayload, const documentId = this.extractDocumentId(payload);
): Promise<{ status: string }> { if (!documentId) {
this.logger.log( this.logger.warn(
`Webhook empfangen: action=${payload.action}, document=${payload.document_id}`, `Webhook ohne ermittelbare Dokument-ID: ${JSON.stringify(payload)}`,
); );
await this.webhookQueue.recordStatus({
documentId: null,
action: payload.action,
status: 'bad-request',
});
throw new BadRequestException(
'Keine Dokument-ID aus doc_url/document_id ermittelbar',
);
}
// TODO: Business-Logik für verschiedene Webhook-Events this.logger.log(
// - document_updated → Felder prüfen, Postprocessing auslösen `Webhook: action=${payload.action}, document=${documentId} → eingereiht`,
// - document_consumed → GoBD-Archivierung prüfen );
// Sofort einreihen und antworten; die eigentliche Verarbeitung übernimmt
// der separate Queue-Prozess (sequenziell, dedupliziert).
await this.webhookQueue.enqueue(documentId, payload.action);
return { status: 'queued', documentId };
}
return { status: 'received' }; /**
* Liefert Informationen zum letzten Webhook-Vorgang (Zeitpunkt, Dokument,
* Ergebnis) und die aktuelle Warteschlangen-Größe. Über die globalen Guards
* per JWT oder API-Key zugänglich.
*/
@Get('status')
async getWebhookStatus(): Promise<{ lastCall: unknown; queueSize: number }> {
return this.webhookQueue.getStatus();
}
/**
* Ermittelt die Dokument-ID aus dem Webhook-Payload. Bevorzugt wird das
* optionale Feld `document_id` (für manuelles Testen), andernfalls wird die
* ID aus dem Paperless-Platzhalter `{{doc_url}}` extrahiert
* (z.B. ".../documents/123/").
*/
private extractDocumentId(payload: PaperlessWebhookPayload): number | null {
if (
payload?.document_id != null &&
!Number.isNaN(Number(payload.document_id))
) {
return Number(payload.document_id);
}
if (typeof payload?.doc_url === 'string') {
const m = payload.doc_url.match(/\/documents\/(\d+)/);
if (m) return Number(m[1]);
}
return null;
} }
} }
@@ -1,7 +1,19 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WebhookController } from './webhook.controller'; import { WebhookController } from './webhook.controller';
import { WebhookQueueService } from './webhook-queue.service';
import { PaperlessModule } from '../paperless/paperless.module';
import { AuthModule } from '../auth/auth.module';
import { Setting } from '../database/entities/setting.entity';
import { WebhookQueueItem } from '../database/entities/webhook-queue-item.entity';
@Module({ @Module({
imports: [
TypeOrmModule.forFeature([Setting, WebhookQueueItem]),
PaperlessModule,
AuthModule,
],
controllers: [WebhookController], controllers: [WebhookController],
providers: [WebhookQueueService],
}) })
export class WebhookModule {} export class WebhookModule {}
@@ -0,0 +1,33 @@
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();
}
}
@@ -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 {}
@@ -0,0 +1,138 @@
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<DocumentType>,
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<string, any> = {
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.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 !== '');
}
}
+9
View File
@@ -7,6 +7,7 @@ import { saveReturnUrl } from './auth/sessionRedirect';
import { ThemeProvider, useTheme } from './theme/ThemeContext'; import { ThemeProvider, useTheme } from './theme/ThemeContext';
import AuthCallback from './auth/AuthCallback'; import AuthCallback from './auth/AuthCallback';
import AppLayout from './layouts/AppLayout'; import AppLayout from './layouts/AppLayout';
import AppErrorBoundary from './components/AppErrorBoundary';
import { Spin, Result, Button } from 'antd'; import { Spin, Result, Button } from 'antd';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@@ -21,6 +22,7 @@ const UserSettingsPage = lazy(() => import('./pages/UserSettingsPage'));
const LoginPage = lazy(() => import('./pages/LoginPage')); const LoginPage = lazy(() => import('./pages/LoginPage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage')); const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const FreigabePage = lazy(() => import('./pages/FreigabePage')); const FreigabePage = lazy(() => import('./pages/FreigabePage'));
const ZahlungPage = lazy(() => import('./pages/ZahlungPage'));
import { Permission } from './auth/permissions'; import { Permission } from './auth/permissions';
function UnauthorizedPage() { function UnauthorizedPage() {
@@ -75,6 +77,10 @@ function ThemedApp() {
token: { token: {
colorPrimary: '#1677ff', colorPrimary: '#1677ff',
borderRadius: 6, borderRadius: 6,
// Gleiche Schriftfamilie wie der Body (index.css), damit AntD-Eingaben
// z.B. das Select-Suchfeld nicht im AntD-Default-Stack abweichen.
fontFamily:
"'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
...(isDark ...(isDark
? {} ? {}
: { : {
@@ -112,6 +118,7 @@ function ThemedApp() {
<AuthProvider> <AuthProvider>
<AntdApp> <AntdApp>
<BrowserRouter> <BrowserRouter>
<AppErrorBoundary>
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}><Spin size="large" /></div>}> <Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}><Spin size="large" /></div>}>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
@@ -133,10 +140,12 @@ function ThemedApp() {
<Route path="/mailpostfach/:id" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailDetailPage /></PermissionRoute>} /> <Route path="/mailpostfach/:id" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailDetailPage /></PermissionRoute>} />
<Route path="/settings" element={<PermissionRoute permission={Permission.MANAGE_SETTINGS}><SettingsPage /></PermissionRoute>} /> <Route path="/settings" element={<PermissionRoute permission={Permission.MANAGE_SETTINGS}><SettingsPage /></PermissionRoute>} />
<Route path="/freigabe" element={<PermissionRoute permission={Permission.VIEW_FREIGABE}><FreigabePage /></PermissionRoute>} /> <Route path="/freigabe" element={<PermissionRoute permission={Permission.VIEW_FREIGABE}><FreigabePage /></PermissionRoute>} />
<Route path="/zahlung" element={<PermissionRoute permission={Permission.VIEW_ZAHLUNG}><ZahlungPage /></PermissionRoute>} />
<Route path="/user-settings" element={<UserSettingsPage />} /> <Route path="/user-settings" element={<UserSettingsPage />} />
</Route> </Route>
</Routes> </Routes>
</Suspense> </Suspense>
</AppErrorBoundary>
</BrowserRouter> </BrowserRouter>
</AntdApp> </AntdApp>
</AuthProvider> </AuthProvider>
+18 -3
View File
@@ -1,8 +1,12 @@
import axios from 'axios'; import axios, { type InternalAxiosRequestConfig } from 'axios';
import { getAccessToken } from '../auth/oidc'; import { getAccessToken, renewToken } from '../auth/oidc';
import { triggerLoginRedirect } from '../auth/sessionRedirect'; import { triggerLoginRedirect } from '../auth/sessionRedirect';
import { getEnv } from '../utils/env'; import { getEnv } from '../utils/env';
// Markiert eine bereits einmal wiederholte Anfrage, um Endlosschleifen zu
// vermeiden.
type RetryConfig = InternalAxiosRequestConfig & { _retried?: boolean };
const api = axios.create({ const api = axios.create({
baseURL: getEnv('VITE_API_URL') || '', baseURL: getEnv('VITE_API_URL') || '',
timeout: 30000, timeout: 30000,
@@ -19,7 +23,18 @@ api.interceptors.request.use(async (config) => {
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
if (error.response?.status === 401) { const original = error.config as RetryConfig | undefined;
if (error.response?.status === 401 && original && !original._retried) {
original._retried = true;
// Erst still erneuern und die Anfrage einmal wiederholen, bevor wir den
// Nutzer zu einer kompletten Neuanmeldung zwingen.
const user = await renewToken();
if (user?.access_token) {
original.headers.Authorization = `Bearer ${user.access_token}`;
return api(original);
}
// Erneuerung fehlgeschlagen (Refresh-Token ungültig/abgelaufen) →
// echte Neuanmeldung nötig.
await triggerLoginRedirect(); await triggerLoginRedirect();
} }
return Promise.reject(error); return Promise.reject(error);
-1
View File
@@ -4,7 +4,6 @@ export interface FreigabeDocument {
id: number; id: number;
title: string; title: string;
created: string; created: string;
created_date: string;
correspondent: number | null; correspondent: number | null;
document_type: number | null; document_type: number | null;
archive_serial_number: number | null; archive_serial_number: number | null;
+2
View File
@@ -208,6 +208,8 @@ export interface AgrarmonitorPollingConfig {
tagHochgeladen: string; tagHochgeladen: string;
linkField: string; linkField: string;
tagManuell: string; tagManuell: string;
importWartezeitMinuten: string;
notizMarker: string;
} }
export interface AgrarmonitorPollingResult { export interface AgrarmonitorPollingResult {
+20
View File
@@ -0,0 +1,20 @@
import api from './client';
export interface WebhookLastCall {
at: string; // ISO-Zeitstempel
documentId: number | null;
action: string | null;
status: 'queued' | 'processed' | 'error' | 'bad-request' | string;
reason?: string;
message?: string;
}
export interface WebhookStatus {
lastCall: WebhookLastCall | null;
queueSize?: number;
}
export const webhookApi = {
getStatus: () =>
api.get<WebhookStatus>('/api/webhook/status').then((r) => r.data),
};
+39
View File
@@ -0,0 +1,39 @@
import api from './client';
export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle';
export interface ZahlungDocument {
id: number;
title: string;
created: 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<ZahlungResult>('/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<ZahlungOption[]>('/api/zahlung/options').then((r) => r.data),
};
+28 -1
View File
@@ -34,9 +34,36 @@ export async function getUser(): Promise<User | null> {
return userManager.getUser(); return userManager.getUser();
} }
// Laufender Silent-Renew-Vorgang mehrere gleichzeitige Aufrufe teilen sich
// denselben Refresh (Dedup), damit nicht parallele signinSilent-Aufrufe mit
// einem rotierenden Refresh-Token kollidieren (invalid_grant).
let renewPromise: Promise<User | null> | null = null;
/**
* Erneuert das Token still über den Refresh-Token (signinSilent). Schlägt die
* Erneuerung fehl, wird null geliefert (statt zu werfen). Gleichzeitige Aufrufe
* werden dedupliziert.
*/
export function renewToken(): Promise<User | null> {
if (!renewPromise) {
renewPromise = userManager.signinSilent().catch((err: unknown) => {
console.error('OIDC: signinSilent fehlgeschlagen', err);
return null;
});
void renewPromise.finally(() => {
renewPromise = null;
});
}
return renewPromise;
}
export async function getAccessToken(): Promise<string | null> { export async function getAccessToken(): Promise<string | null> {
const user = await getUser(); const user = await getUser();
return user?.access_token ?? null; if (!user) return null; // nicht eingeloggt
if (!user.expired) return user.access_token ?? null;
// Token ist abgelaufen → vor dem Senden still erneuern.
const renewed = await renewToken();
return renewed?.access_token ?? null;
} }
export { userManager }; export { userManager };
@@ -6,6 +6,7 @@ export const Permission = {
VIEW_SCANNER: 'VIEW_SCANNER', VIEW_SCANNER: 'VIEW_SCANNER',
MANAGE_SETTINGS: 'MANAGE_SETTINGS', MANAGE_SETTINGS: 'MANAGE_SETTINGS',
VIEW_FREIGABE: 'VIEW_FREIGABE', VIEW_FREIGABE: 'VIEW_FREIGABE',
VIEW_ZAHLUNG: 'VIEW_ZAHLUNG',
} as const; } as const;
export type Permission = typeof Permission[keyof typeof Permission]; 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.VIEW_SCANNER);
permissions.add(Permission.MANAGE_SETTINGS); permissions.add(Permission.MANAGE_SETTINGS);
permissions.add(Permission.VIEW_FREIGABE); permissions.add(Permission.VIEW_FREIGABE);
permissions.add(Permission.VIEW_ZAHLUNG);
return Array.from(permissions); return Array.from(permissions);
} }
@@ -44,6 +46,9 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per
if (groups.includes('PM_Freigabe')) { if (groups.includes('PM_Freigabe')) {
permissions.add(Permission.VIEW_FREIGABE); permissions.add(Permission.VIEW_FREIGABE);
} }
if (groups.includes('PM_Zahlung')) {
permissions.add(Permission.VIEW_ZAHLUNG);
}
return Array.from(permissions); return Array.from(permissions);
} }
@@ -0,0 +1,62 @@
import { Component } from 'react';
import type { ReactNode } from 'react';
import { Button, Result } from 'antd';
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
// Erkennt fehlgeschlagene Lazy-Chunk-Imports:
// Safari: "Importing a module script failed."
// Chrome: "Failed to fetch dynamically imported module: …"
// Firefox: "error loading dynamically imported module"
function istChunkLadefehler(error: Error): boolean {
return /module script failed|dynamically imported module/i.test(error.message);
}
/**
* Fängt Renderfehler ab, die sonst den kompletten React-Baum aushängen und
* nur eine weiße Seite hinterlassen — insbesondere fehlgeschlagene
* React.lazy-Chunk-Imports nach einem Deployment oder bei beschädigtem
* Browser-Cache (Assets werden mit "immutable" gecacht und nie revalidiert).
*/
export default class AppErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error) {
console.error('AppErrorBoundary:', error);
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
return (
<Result
status="error"
title="Die Seite konnte nicht geladen werden"
subTitle={
istChunkLadefehler(error)
? 'Ein Teil der Anwendung konnte nicht nachgeladen werden — vermutlich wurde die ' +
'Anwendung aktualisiert oder eine zwischengespeicherte Datei ist beschädigt. ' +
'Bitte neu laden. Hilft das nicht, den Browser-Cache leeren ' +
'(Safari: Verlauf → Websitedaten löschen).'
: error.message
}
extra={
<Button type="primary" onClick={() => window.location.reload()}>
Seite neu laden
</Button>
}
/>
);
}
}
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider, Tag } from 'antd'; 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 dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang'; import { posteingangApi } from '../api/posteingang';
import type { DocumentRequirement, PosteingangDocument, Kontonummer } 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 { getEnv } from '../utils/env';
import { AuthIframe, openAuthUrl } from '../utils/auth-resource'; import { AuthIframe, openAuthUrl } from '../utils/auth-resource';
import DocumentSearchModal from './DocumentSearchModal'; import DocumentSearchModal from './DocumentSearchModal';
import { useIsMobile } from '../hooks/useIsMobile';
const { Option } = Select; const { Option } = Select;
@@ -26,6 +27,7 @@ interface Props {
export default function DocumentEditModal({ documentId, document, open, onClose, onSave, isPosteingang = true, hasNextDocument = true }: Props) { export default function DocumentEditModal({ documentId, document, open, onClose, onSave, isPosteingang = true, hasNextDocument = true }: Props) {
const [form] = Form.useForm(); const [form] = Form.useForm();
const isMobile = useIsMobile();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -292,28 +294,51 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
} }
}; };
const handleSubmit = async (isNext: boolean = false) => { const runSave = async (values: any, isNext: boolean) => {
try { // Kontonummer-Logik (unverändert): neue Kontonummer ggf. bestätigen lassen.
const values = await form.validateFields();
// Kontonummer Logic
const kontonummerReq = requirements.find(r => r.customFieldIndex === 5); const kontonummerReq = requirements.find(r => r.customFieldIndex === 5);
if (kontonummerReq && values[`cf_5`] && values.correspondent) { if (kontonummerReq && values['cf_5'] && values.correspondent) {
const kNummer = values[`cf_5`]; const kNummer = values['cf_5'];
const knData = await posteingangApi.getKontonummern(values.correspondent); const knData = await posteingangApi.getKontonummern(values.correspondent);
const exists = knData.some(k => k.Nummer === kNummer); const exists = knData.some(k => k.Nummer === kNummer);
if (!exists) { if (!exists) {
// Prompt user to save kontonummer // Prompt user to save kontonummer
setKontonummerMissing({ correspondentId: values.correspondent, nummer: kNummer }); setKontonummerMissing({ correspondentId: values.correspondent, nummer: kNummer });
return; // Stop saving, wait for confirmation return; // Stop saving, wait for confirmation
} }
} }
await handleSaveDocument(values, isNext); await handleSaveDocument(values, isNext);
};
const handleSubmit = async (isNext: boolean = false) => {
try {
const values = await form.validateFields();
await runSave(values, isNext);
} catch (e) { } catch (e) {
// Validation failed const errorFields =
(e as { errorFields?: { name: unknown; errors: string[] }[] })?.errorFields ?? [];
// Kein Validierungsfehler, oder Posteingang → bisheriges Verhalten (stiller Abbruch).
if (errorFields.length === 0 || isPosteingang) {
console.error("Form validation failed:", e); console.error("Form validation failed:", e);
return;
}
// "Manuell bearbeiten": Speichern trotz fehlender Pflichtfelder mit Hinweis anbieten.
const missing = Array.from(new Set(errorFields.flatMap((f) => f.errors)));
Modal.confirm({
title: 'Pflichtfelder unvollständig',
okText: 'Trotzdem speichern',
cancelText: 'Abbrechen',
content: (
<div>
<p>Nicht alle Pflichtfelder sind ausgefüllt:</p>
<ul>{missing.map((m, i) => <li key={i}>{m}</li>)}</ul>
<p>Möchtest du trotzdem speichern?</p>
</div>
),
onOk: () => runSave(form.getFieldsValue(), isNext),
});
} }
}; };
@@ -349,8 +374,8 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
title={`Dokument bearbeiten (${document?.title || ''})`} title={`Dokument bearbeiten (${document?.title || ''})`}
open={open && !kontonummerMissing} open={open && !kontonummerMissing}
onCancel={() => onClose(false)} onCancel={() => onClose(false)}
width={1400} width={{ xs: '100vw', md: 900, xl: 1400 }}
style={{ top: 20 }} style={isMobile ? {} : { top: 20 }}
footer={ footer={
hasNextDocument ? [ hasNextDocument ? [
<Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>, <Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>,
@@ -363,8 +388,12 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
} }
> >
<Spin spinning={loading}> <Spin spinning={loading}>
<Row gutter={16} style={{ height: '75vh', overflow: 'hidden' }}> <Row gutter={[16, 16]} style={isMobile ? {} : { height: '75vh', overflow: 'hidden' }}>
<Col span={10} style={{ overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}> <Col
xs={24}
lg={10}
style={isMobile ? {} : { overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}
>
<Form form={form} layout="vertical" disabled={saving}> <Form form={form} layout="vertical" disabled={saving}>
<Form.Item name="mandant" label="Mandant" rules={[{ required: true, message: 'Wähle einen Mandanten' }]}> <Form.Item name="mandant" label="Mandant" rules={[{ required: true, message: 'Wähle einen Mandanten' }]}>
@@ -556,10 +585,20 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
})} })}
</Form> </Form>
</Col> </Col>
<Col span={14} style={{ height: '100%' }}> <Col xs={24} lg={14} style={isMobile ? {} : { height: '100%' }}>
{isMobile && (
<Button
icon={<ExportOutlined />}
block
style={{ marginBottom: 8 }}
onClick={() => openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}`)}
>
PDF in neuem Tab öffnen
</Button>
)}
<AuthIframe <AuthIframe
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`} src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: isMobile ? '50vh' : '100%' }}
title="PDF Preview" title="PDF Preview"
/> />
</Col> </Col>
@@ -57,7 +57,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
open={open} open={open}
onCancel={onCancel} onCancel={onCancel}
footer={null} footer={null}
width={800} width={{ xs: '100vw', md: 800 }}
style={{ top: 50 }} style={{ top: 50 }}
> >
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
@@ -100,7 +100,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
description={ description={
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>ID: {doc.id} | ASN: {doc.archive_serial_number || 'Keine'}</Text> <Text type="secondary" style={{ fontSize: 12 }}>ID: {doc.id} | ASN: {doc.archive_serial_number || 'Keine'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>Erstellt: {dayjs(doc.created_date).format('DD.MM.YYYY')}</Text> <Text type="secondary" style={{ fontSize: 12 }}>Erstellt: {dayjs(doc.created).format('DD.MM.YYYY')}</Text>
</Space> </Space>
} }
/> />
@@ -538,8 +538,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
{toProcess.map(item => ( {toProcess.map(item => (
<div key={item.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}> <div key={item.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}>
<Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{item.fileName}</Text> <Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{item.fileName}</Text>
<Row gutter={24}> <Row gutter={[24, 16]}>
<Col span={8}> <Col xs={24} lg={8}>
{/* Eingangsdatum */} {/* Eingangsdatum */}
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Text style={{ display: 'block', marginBottom: 4 }}>Eingangsdatum:</Text> <Text style={{ display: 'block', marginBottom: 4 }}>Eingangsdatum:</Text>
@@ -626,7 +626,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
</Text> </Text>
</div> </div>
</Col> </Col>
<Col span={16}> <Col xs={24} lg={16}>
<BarcodePositioner <BarcodePositioner
attachmentId={item.attachmentId} attachmentId={item.attachmentId}
startPage={item.pages?.start} startPage={item.pages?.start}
@@ -666,9 +666,9 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
return ( return (
<div key={main.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}> <div key={main.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}>
<Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{main.fileName}</Text> <Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{main.fileName}</Text>
<Row gutter={24} align="middle"> <Row gutter={[24, 12]} align="middle">
<Col span={showPrint ? 20 : 24}> <Col xs={24} md={showPrint ? 20 : 24}>
<Space size={24}> <Space size={24} wrap>
<Text type="secondary"> <Text type="secondary">
Eingangsdatum: <Text strong>{datum?.format('DD.MM.YYYY') ?? '—'}</Text> Eingangsdatum: <Text strong>{datum?.format('DD.MM.YYYY') ?? '—'}</Text>
</Text> </Text>
@@ -690,7 +690,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
)} )}
</Col> </Col>
{showPrint && ( {showPrint && (
<Col span={4} style={{ textAlign: 'right' }}> <Col xs={24} md={4} style={{ textAlign: 'right' }}>
<Button icon={<PrinterOutlined />} onClick={() => printDocument(main.virtualId, main.attachmentId)}> <Button icon={<PrinterOutlined />} onClick={() => printDocument(main.virtualId, main.attachmentId)}>
Drucken Drucken
</Button> </Button>
@@ -735,7 +735,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
title="Paperless Import-Wizard" title="Paperless Import-Wizard"
open={visible} open={visible}
onCancel={onClose} onCancel={onClose}
width={1000} width={{ xs: '100vw', md: 900, lg: 1000 }}
footer={ footer={
importSuccess ? ( importSuccess ? (
<Button type="primary" onClick={onSuccess ?? onClose}>Schließen</Button> <Button type="primary" onClick={onSuccess ?? onClose}>Schließen</Button>
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react';
import { List, Card } from 'antd';
import type { TablePaginationConfig } from 'antd';
interface MobileCardListProps<T> {
dataSource: T[];
rowKey: keyof T | ((record: T) => React.Key);
loading?: boolean;
/** Gleiche Pagination-Config wie bei <Table> — wird durchgereicht (mobil kompakt). */
pagination?: TablePaginationConfig | false;
/** Seitenspezifischer Karteninhalt für einen Datensatz. */
renderCard: (record: T) => ReactNode;
onCardClick?: (record: T) => void;
emptyText?: string;
}
/**
* Mobile Karten-Ansicht als Ersatz für breite Tabellen:
* eine Karte pro Datensatz, Pagination wie bei der Tabelle.
*/
export default function MobileCardList<T>({
dataSource,
rowKey,
loading,
pagination,
renderCard,
onCardClick,
emptyText = 'Keine Einträge vorhanden',
}: MobileCardListProps<T>) {
const getKey = (record: T): React.Key =>
typeof rowKey === 'function' ? rowKey(record) : (record[rowKey] as React.Key);
return (
<List
dataSource={dataSource}
loading={loading}
rowKey={getKey}
locale={{ emptyText }}
pagination={
pagination
? {
...pagination,
position: undefined,
simple: true,
showSizeChanger: false,
}
: false
}
renderItem={(record) => (
<List.Item style={{ padding: 0, marginBottom: 8, borderBlockEnd: 'none' }}>
<Card
size="small"
style={{ width: '100%' }}
styles={{ body: { padding: 12 } }}
hoverable={!!onCardClick}
onClick={onCardClick ? () => onCardClick(record) : undefined}
>
{renderCard(record)}
</Card>
</List.Item>
)}
/>
);
}
@@ -0,0 +1,11 @@
import { Grid } from 'antd';
/**
* Zentraler Breakpoint-Hook: mobil = Viewport unter `md` (768px).
* Beim allerersten Render liefert useBreakpoint() noch keine Werte —
* dann Desktop annehmen, um ein Layout-Flackern zu vermeiden.
*/
export function useIsMobile(): boolean {
const screens = Grid.useBreakpoint();
return screens.md === undefined ? false : !screens.md;
}
+26
View File
@@ -139,3 +139,29 @@ body {
.ant-picker-input > input { .ant-picker-input > input {
font-size: 14px !important; 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;
}
}
+213 -131
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; 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 { import {
InboxOutlined, InboxOutlined,
FileTextOutlined, FileTextOutlined,
@@ -14,9 +14,12 @@ import {
AppstoreOutlined, AppstoreOutlined,
GlobalOutlined, GlobalOutlined,
CheckCircleOutlined, CheckCircleOutlined,
EuroOutlined,
MenuOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { useTheme } from '../theme/ThemeContext'; import { useTheme } from '../theme/ThemeContext';
import { useIsMobile } from '../hooks/useIsMobile';
import { Permission } from '../auth/permissions'; import { Permission } from '../auth/permissions';
import { statsApi, type StatsCounts } from '../api/stats'; import { statsApi, type StatsCounts } from '../api/stats';
@@ -40,19 +43,25 @@ const allMenuItems: MenuItemDef[] = [
{ key: '/mailpostfach', icon: <MailOutlined />, label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' }, { key: '/mailpostfach', icon: <MailOutlined />, label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' },
{ key: 'agrarmonitor', icon: <GlobalOutlined />, label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' }, { key: 'agrarmonitor', icon: <GlobalOutlined />, label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' },
{ key: '/freigabe', icon: <CheckCircleOutlined />, label: 'Freigabe', permission: Permission.VIEW_FREIGABE }, { key: '/freigabe', icon: <CheckCircleOutlined />, label: 'Freigabe', permission: Permission.VIEW_FREIGABE },
{ key: '/zahlung', icon: <EuroOutlined />, label: 'Zahlung', permission: Permission.VIEW_ZAHLUNG },
{ key: '/settings', icon: <SettingOutlined />, label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS }, { key: '/settings', icon: <SettingOutlined />, label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS },
]; ];
export default function AppLayout() { export default function AppLayout() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, logout, hasPermission, isAuthenticated } = useAuth(); const { user, logout, hasPermission, isAuthenticated } = useAuth();
const { token: themeToken } = theme.useToken(); const { token: themeToken } = theme.useToken();
const { isDark, toggleTheme } = useTheme(); const { isDark, toggleTheme } = useTheme();
const isMobile = useIsMobile();
const [counts, setCounts] = useState<StatsCounts | null>(null); const [counts, setCounts] = useState<StatsCounts | null>(null);
// Im Drawer (mobil) ist die Navigation nie eingeklappt
const effectiveCollapsed = isMobile ? false : collapsed;
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
@@ -78,7 +87,7 @@ export default function AppLayout() {
label: ( label: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', paddingRight: 8 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', paddingRight: 8 }}>
<span>{item.label}</span> <span>{item.label}</span>
{item.countKey && counts && counts[item.countKey] > 0 && !collapsed && ( {item.countKey && counts && counts[item.countKey] > 0 && !effectiveCollapsed && (
<Badge <Badge
count={counts[item.countKey]} count={counts[item.countKey]}
overflowCount={99} overflowCount={99}
@@ -105,9 +114,194 @@ export default function AppLayout() {
const logoColor = isDark ? '#fff' : '#1a1a2e'; const logoColor = isDark ? '#fff' : '#1a1a2e';
const subtleColor = isDark ? '#ffffffa6' : '#4a4a6a'; const subtleColor = isDark ? '#ffffffa6' : '#4a4a6a';
const dividerColor = isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea';
// Sidebar-Inhalt (Logo, Menü, Bottom-Sektion) — identisch für Sider (Desktop)
// und Drawer (mobil). `onNavigate` schließt auf Mobil den Drawer.
const renderSidebarContent = (isCollapsed: boolean, onNavigate?: () => void) => (
<>
{/* Logo / Collapse-Toggle */}
<button
onClick={() => (onNavigate ? onNavigate() : setCollapsed(!collapsed))}
style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
userSelect: 'none',
border: 'none',
borderBottom: `1px solid ${dividerColor}`,
background: 'transparent',
width: '100%',
padding: 0,
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Text strong style={{ color: logoColor, fontSize: isCollapsed ? 14 : 18, transition: 'font-size 0.2s' }}>
{isCollapsed ? 'PM' : 'Paperless'}
</Text>
</button>
{/* Navigation Menu */}
<Menu
theme={isDark ? 'dark' : 'light'}
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => {
const item = allMenuItems.find((i) => i.key === key);
if (item?.externalUrl) {
window.open(item.externalUrl, '_blank', 'noopener,noreferrer');
} else {
navigate(key);
}
onNavigate?.();
}}
style={isDark ? { flex: 1 } : { background: 'transparent', flex: 1 }}
/>
{/* Bottom Section: User + Theme Toggle */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
borderTop: `1px solid ${dividerColor}`,
padding: isCollapsed ? '12px 0' : '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
transition: 'padding 0.2s',
}}
>
{/* Theme Toggle */}
<Tooltip title={isDark ? 'Light Mode' : 'Dark Mode'} placement="right">
<button
onClick={toggleTheme}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: isCollapsed ? '8px 0' : '8px 12px',
borderRadius: 6,
cursor: 'pointer',
color: subtleColor,
justifyContent: isCollapsed ? 'center' : 'flex-start',
border: 'none',
background: 'transparent',
width: '100%',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
{isDark ? <SunOutlined style={{ fontSize: 16 }} /> : <MoonOutlined style={{ fontSize: 16 }} />}
{!isCollapsed && <Text style={{ color: subtleColor, fontSize: 13 }}>{isDark ? 'Light Mode' : 'Dark Mode'}</Text>}
</button>
</Tooltip>
{/* User Menu */}
<Dropdown
menu={{
items: [
{
key: 'user-settings',
icon: <SettingOutlined />,
label: 'Benutzereinstellungen',
onClick: () => {
navigate('/user-settings');
onNavigate?.();
},
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Abmelden',
onClick: () => logout(),
},
],
}}
placement="topRight"
trigger={['click']}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: isCollapsed ? '8px 0' : '8px 12px',
borderRadius: 6,
cursor: 'pointer',
justifyContent: isCollapsed ? 'center' : 'flex-start',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Avatar size="small" icon={<UserOutlined />} />
{!isCollapsed && (
<Text ellipsis style={{ color: subtleColor, fontSize: 13, maxWidth: 120 }}>
{user?.profile?.name || 'Benutzer'}
</Text>
)}
</div>
</Dropdown>
</div>
</>
);
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
{isMobile ? (
<>
{/* Mobile Top-Bar mit Hamburger */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 48,
zIndex: 100,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 8px',
background: themeToken.colorBgContainer,
borderBottom: `1px solid ${dividerColor}`,
}}
>
<Button
type="text"
icon={<MenuOutlined />}
aria-label="Menü öffnen"
onClick={() => setDrawerOpen(true)}
/>
<Text strong style={{ color: logoColor, fontSize: 18 }}>Paperless</Text>
</div>
<Drawer
placement="left"
width={260}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
closable={false}
styles={{
body: {
padding: 0,
position: 'relative',
background: isDark ? '#001529' : '#f0f2f7',
},
}}
>
{renderSidebarContent(false, () => setDrawerOpen(false))}
</Drawer>
</>
) : (
<Sider <Sider
width={240} width={240}
trigger={null} trigger={null}
@@ -126,137 +320,25 @@ export default function AppLayout() {
...siderStyle, ...siderStyle,
}} }}
> >
{/* Logo / Collapse-Toggle */} {renderSidebarContent(collapsed)}
<button
onClick={() => setCollapsed(!collapsed)}
style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
userSelect: 'none',
border: 'none',
borderBottom: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
background: 'transparent',
width: '100%',
padding: 0,
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Text strong style={{ color: logoColor, fontSize: collapsed ? 14 : 18, transition: 'font-size 0.2s' }}>
{collapsed ? 'PM' : 'Paperless'}
</Text>
</button>
{/* Navigation Menu */}
<Menu
theme={isDark ? 'dark' : 'light'}
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => {
const item = allMenuItems.find((i) => i.key === key);
if (item?.externalUrl) {
window.open(item.externalUrl, '_blank', 'noopener,noreferrer');
} else {
navigate(key);
}
}}
style={isDark ? { flex: 1 } : { background: 'transparent', flex: 1 }}
/>
{/* Bottom Section: User + Theme Toggle */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
borderTop: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
padding: collapsed ? '12px 0' : '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
transition: 'padding 0.2s',
}}
>
{/* Theme Toggle */}
<Tooltip title={isDark ? 'Light Mode' : 'Dark Mode'} placement="right">
<button
onClick={toggleTheme}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '8px 0' : '8px 12px',
borderRadius: 6,
cursor: 'pointer',
color: subtleColor,
justifyContent: collapsed ? 'center' : 'flex-start',
border: 'none',
background: 'transparent',
width: '100%',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
{isDark ? <SunOutlined style={{ fontSize: 16 }} /> : <MoonOutlined style={{ fontSize: 16 }} />}
{!collapsed && <Text style={{ color: subtleColor, fontSize: 13 }}>{isDark ? 'Light Mode' : 'Dark Mode'}</Text>}
</button>
</Tooltip>
{/* User Menu */}
<Dropdown
menu={{
items: [
{
key: 'user-settings',
icon: <SettingOutlined />,
label: 'Benutzereinstellungen',
onClick: () => navigate('/user-settings'),
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Abmelden',
onClick: () => logout(),
},
],
}}
placement="topRight"
trigger={['click']}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '8px 0' : '8px 12px',
borderRadius: 6,
cursor: 'pointer',
justifyContent: collapsed ? 'center' : 'flex-start',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Avatar size="small" icon={<UserOutlined />} />
{!collapsed && (
<Text ellipsis style={{ color: subtleColor, fontSize: 13, maxWidth: 120 }}>
{user?.profile?.name || 'Benutzer'}
</Text>
)}
</div>
</Dropdown>
</div>
</Sider> </Sider>
)}
<Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}> <Layout
<Content style={{ margin: 24, padding: 24, background: themeToken.colorBgContainer, borderRadius: 8 }}> style={{
marginLeft: isMobile ? 0 : collapsed ? 80 : 240,
marginTop: isMobile ? 48 : 0,
transition: 'margin-left 0.2s',
}}
>
<Content
style={{
margin: isMobile ? 8 : 24,
padding: isMobile ? 12 : 24,
background: themeToken.colorBgContainer,
borderRadius: 8,
}}
>
<Outlet /> <Outlet />
</Content> </Content>
</Layout> </Layout>
+16
View File
@@ -3,6 +3,22 @@ import { createRoot } from 'react-dom/client';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
// Nach einem Deployment existieren die alten, inhalts-gehashten Chunks nicht
// mehr; zudem bleibt ein einmal fehlerhaft zwischengespeicherter Chunk wegen
// "Cache-Control: immutable" dauerhaft defekt. Vite meldet fehlgeschlagene
// dynamische Imports als 'vite:preloadError' — dann automatisch neu laden,
// damit der Browser die aktuelle index.html samt gültiger Chunk-Namen holt.
// Höchstens ein Versuch pro Minute, sonst droht eine Reload-Schleife; danach
// zeigt der AppErrorBoundary eine sichtbare Fehlermeldung.
window.addEventListener('vite:preloadError', (event) => {
const KEY = 'vite-preload-error-reload';
const letzterVersuch = Number(sessionStorage.getItem(KEY) ?? 0);
if (Date.now() - letzterVersuch < 60_000) return;
sessionStorage.setItem(KEY, String(Date.now()));
event.preventDefault();
window.location.reload();
});
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
+65 -14
View File
@@ -7,11 +7,14 @@ import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { freigabeApi, type FreigabeDocument, type FreigabeOption } from '../api/freigabe'; import { freigabeApi, type FreigabeDocument, type FreigabeOption } from '../api/freigabe';
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless'; 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 FREIGABE_FIELD_ID = 15;
export default function FreigabePage() { export default function FreigabePage() {
const isMobile = useIsMobile();
const [data, setData] = useState<FreigabeDocument[]>([]); const [data, setData] = useState<FreigabeDocument[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -119,7 +122,7 @@ export default function FreigabePage() {
}, },
{ {
title: 'Erstellt', title: 'Erstellt',
dataIndex: 'created_date', dataIndex: 'created',
key: 'created', key: 'created',
width: 110, width: 110,
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—', render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
@@ -153,10 +156,37 @@ export default function FreigabePage() {
}, },
]; ];
const paginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['25', '50', '100'],
onChange: (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
},
showTotal: (t: number) => `${t} Belege`,
};
return ( return (
<> <>
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Freigabe</Title> <Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Freigabe</Title>
{isMobile ? (
<Select
style={{ width: '100%', marginBottom: 16 }}
value={nurNichtFreigegeben}
onChange={(v) => {
setPage(1);
setNurNichtFreigegeben(v);
}}
options={[
{ value: true, label: 'Nicht freigegeben' },
{ value: false, label: 'Alle' },
]}
/>
) : (
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
<Radio.Group <Radio.Group
value={nurNichtFreigegeben} value={nurNichtFreigegeben}
@@ -171,26 +201,47 @@ export default function FreigabePage() {
<Radio.Button value={false}>Alle</Radio.Button> <Radio.Button value={false}>Alle</Radio.Button>
</Radio.Group> </Radio.Group>
</Space> </Space>
)}
{isMobile ? (
<MobileCardList<FreigabeDocument>
dataSource={data}
rowKey="id"
loading={loading}
pagination={paginationConfig}
emptyText="Keine Belege vorhanden"
renderCard={(doc) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<Text strong>{doc.title || '—'}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>Dokumenttyp: {getDocTypeName(doc.document_type)}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>Absender: {getCorrespondentName(doc.correspondent)}</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
Erstellt: {doc.created ? dayjs(doc.created).format('DD.MM.YYYY') : '—'}
</Text>
{getFreigabeValue(doc)}
</div>
<Button
icon={<CheckCircleOutlined />}
type="primary"
block
onClick={() => openModal(doc)}
>
Freigabe setzen
</Button>
</div>
)}
/>
) : (
<Table<FreigabeDocument> <Table<FreigabeDocument>
dataSource={data} dataSource={data}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
size="small" size="small"
pagination={{ pagination={paginationConfig}
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['25', '50', '100'],
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
showTotal: (t) => `${t} Belege`,
}}
/> />
)}
<Modal <Modal
title="Freigabe setzen" title="Freigabe setzen"
@@ -25,6 +25,7 @@ import {
import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox'; import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox';
import { paperlessApi } from '../api/paperless'; import { paperlessApi } from '../api/paperless';
import { userSettingsApi, type SenderOption } from '../api/userSettings'; import { userSettingsApi, type SenderOption } from '../api/userSettings';
import { useIsMobile } from '../hooks/useIsMobile';
const ZOOM_MIN = 0.5; const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3; const ZOOM_MAX = 3;
@@ -113,6 +114,7 @@ function CompareModal({
onCreateNewVersion, onCreateNewVersion,
onSkip, onSkip,
}: CompareModalProps) { }: CompareModalProps) {
const compareIsMobile = useIsMobile();
const [paperlessUrl, setPaperlessUrl] = useState<string | null>(null); const [paperlessUrl, setPaperlessUrl] = useState<string | null>(null);
const [inboxUrl, setInboxUrl] = useState<string | null>(null); const [inboxUrl, setInboxUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -165,8 +167,8 @@ function CompareModal({
</Button>, </Button>,
]} ]}
> >
<div style={{ display: 'flex', gap: 12, height: '75vh' }}> <div style={{ display: 'flex', flexDirection: compareIsMobile ? 'column' : 'row', gap: 12, height: '75vh' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}> <Typography.Text strong style={{ marginBottom: 4 }}>
Original (Paperless) Original (Paperless)
</Typography.Text> </Typography.Text>
@@ -180,7 +182,7 @@ function CompareModal({
)} )}
</div> </div>
</div> </div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}> <Typography.Text strong style={{ marginBottom: 4 }}>
Aktueller Abschnitt (Inbox) Aktueller Abschnitt (Inbox)
</Typography.Text> </Typography.Text>
@@ -858,6 +860,7 @@ function SendEmailDialog({ open, fileId, fileName, documents, thumbUrls, onClose
export default function InboxDetailPage() { export default function InboxDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobile();
const [file, setFile] = useState<InboxFile | null>(null); const [file, setFile] = useState<InboxFile | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [thumbUrls, setThumbUrls] = useState<Map<number, string>>(new Map()); const [thumbUrls, setThumbUrls] = useState<Map<number, string>>(new Map());
@@ -1211,13 +1214,13 @@ export default function InboxDetailPage() {
})(); })();
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}> <div style={{ display: 'flex', flexDirection: 'column', height: isMobile ? 'calc(100dvh - 180px)' : 'calc(100vh - 120px)' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12, gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', rowGap: 8, marginBottom: 12, gap: 12 }}>
<Space> <Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/inbox')}> <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/inbox')}>
Zurück Zurück
</Button> </Button>
<Title level={3} style={{ margin: 0 }}> <Title level={isMobile ? 5 : 3} style={{ margin: 0, maxWidth: isMobile ? 180 : undefined }} ellipsis={{ tooltip: file.name }}>
{file.name} {file.name}
</Title> </Title>
<SourceTag source={file.source} /> <SourceTag source={file.source} />
@@ -1399,12 +1402,13 @@ export default function InboxDetailPage() {
> >
<div <div
style={{ style={{
width: 140, width: isMobile ? 84 : 140,
overflowY: 'auto', overflowY: 'auto',
padding: 6, padding: 6,
background: '#fafafa', background: '#fafafa',
border: '1px solid #f0f0f0', border: '1px solid #f0f0f0',
borderRadius: 8, borderRadius: 8,
flexShrink: 0,
}} }}
> >
{sidebarPages.map((n, idx) => { {sidebarPages.map((n, idx) => {
@@ -1428,7 +1432,7 @@ export default function InboxDetailPage() {
<div <div
style={{ style={{
width: '100%', width: '100%',
height: 170, height: isMobile ? 100 : 170,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -1439,7 +1443,7 @@ export default function InboxDetailPage() {
<img <img
src={url} src={url}
alt={`Seite ${docPage}`} alt={`Seite ${docPage}`}
style={thumbImageStyle(rotationFor(n), 130)} style={thumbImageStyle(rotationFor(n), isMobile ? 72 : 130)}
/> />
) : ( ) : (
<Spin size="small" /> <Spin size="small" />
+105 -64
View File
@@ -37,6 +37,8 @@ import { inboxApi, type InboxBarcode, type InboxFile } from '../api/inbox';
import { barcodeTemplatesApi, type BarcodeTemplate } from '../api/barcode-templates'; import { barcodeTemplatesApi, type BarcodeTemplate } from '../api/barcode-templates';
import { labelPrintAgentApi } from '../api/labelPrintAgent'; import { labelPrintAgentApi } from '../api/labelPrintAgent';
import { userSettingsApi } from '../api/userSettings'; import { userSettingsApi } from '../api/userSettings';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
const { Title } = Typography; const { Title } = Typography;
@@ -51,6 +53,18 @@ function formatDate(iso: string): string {
}); });
} }
function renderSourceTag(src: InboxFile['source']): ReactNode {
return src === 'user' ? (
<Tag icon={<UserOutlined />} color="purple">
Persönlich
</Tag>
) : (
<Tag icon={<FolderOpenOutlined />} color="blue">
Gemeinsam
</Tag>
);
}
function renderBarcodes(barcodes: InboxBarcode[]): ReactNode { function renderBarcodes(barcodes: InboxBarcode[]): ReactNode {
if (!barcodes || barcodes.length === 0) { if (!barcodes || barcodes.length === 0) {
return <Typography.Text type="secondary"></Typography.Text>; return <Typography.Text type="secondary"></Typography.Text>;
@@ -137,6 +151,7 @@ function buildInitialFieldValues(template: BarcodeTemplate | null): Record<strin
export default function InboxPage() { export default function InboxPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobile();
const [files, setFiles] = useState<InboxFile[]>([]); const [files, setFiles] = useState<InboxFile[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -261,68 +276,11 @@ export default function InboxPage() {
search ? f.name.toLowerCase().includes(search.toLowerCase()) : true, search ? f.name.toLowerCase().includes(search.toLowerCase()) : true,
); );
const columns: ColumnsType<InboxFile> = [ // Karten-Ansicht sortiert wie die Tabelle (neueste zuerst)
{ const sortedForMobile = [...filtered].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
title: 'Dateiname',
dataIndex: 'name', const renderActions = (record: InboxFile, direction: 'column' | 'row' = 'column') => (
key: 'name', <div style={{ display: 'flex', flexDirection: direction, alignItems: 'flex-start', flexWrap: 'wrap' }}>
sorter: (a, b) => a.name.localeCompare(b.name),
render: (name: string, record) => (
<DocumentPreviewPopover record={record}>
<Typography.Text>{name}</Typography.Text>
</DocumentPreviewPopover>
),
},
{
title: 'Quelle',
dataIndex: 'source',
key: 'source',
width: 160,
filters: [
{ text: 'Gemeinsam', value: 'all' },
{ text: 'Persönlich', value: 'user' },
],
onFilter: (value, record) => record.source === value,
render: (src: InboxFile['source']) =>
src === 'user' ? (
<Tag icon={<UserOutlined />} color="purple">
Persönlich
</Tag>
) : (
<Tag icon={<FolderOpenOutlined />} color="blue">
Gemeinsam
</Tag>
),
},
{
title: 'QR-Code / Vorlage',
key: 'barcodes',
width: 260,
render: (_, record) => renderBarcodes(record.barcodes),
},
{
title: 'Seiten',
dataIndex: 'pageCount',
key: 'pageCount',
width: 100,
sorter: (a, b) => a.pageCount - b.pageCount,
render: (n: number) => (n > 0 ? n : <Typography.Text type="secondary"></Typography.Text>),
},
{
title: 'Empfangen',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
defaultSortOrder: 'descend',
sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
render: (iso: string) => formatDate(iso),
},
{
title: 'Aktionen',
key: 'actions',
width: 140,
render: (_, record) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<Tooltip title="Vorschau öffnen"> <Tooltip title="Vorschau öffnen">
<Button <Button
type="link" type="link"
@@ -360,8 +318,61 @@ export default function InboxPage() {
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
);
const columns: ColumnsType<InboxFile> = [
{
title: 'Dateiname',
dataIndex: 'name',
key: 'name',
sorter: (a, b) => a.name.localeCompare(b.name),
render: (name: string, record) => (
<DocumentPreviewPopover record={record}>
<Typography.Text>{name}</Typography.Text>
</DocumentPreviewPopover>
), ),
}, },
{
title: 'Quelle',
dataIndex: 'source',
key: 'source',
width: 160,
filters: [
{ text: 'Gemeinsam', value: 'all' },
{ text: 'Persönlich', value: 'user' },
],
onFilter: (value, record) => record.source === value,
render: (src: InboxFile['source']) => renderSourceTag(src),
},
{
title: 'QR-Code / Vorlage',
key: 'barcodes',
width: 260,
render: (_, record) => renderBarcodes(record.barcodes),
},
{
title: 'Seiten',
dataIndex: 'pageCount',
key: 'pageCount',
width: 100,
sorter: (a, b) => a.pageCount - b.pageCount,
render: (n: number) => (n > 0 ? n : <Typography.Text type="secondary"></Typography.Text>),
},
{
title: 'Empfangen',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
defaultSortOrder: 'descend',
sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
render: (iso: string) => formatDate(iso),
},
{
title: 'Aktionen',
key: 'actions',
width: 140,
render: (_, record) => renderActions(record),
},
]; ];
return ( return (
@@ -371,6 +382,8 @@ export default function InboxPage() {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
marginBottom: 16, marginBottom: 16,
}} }}
> >
@@ -383,11 +396,11 @@ export default function InboxPage() {
Scan-Ordner. Scan-Ordner.
</Typography.Text> </Typography.Text>
</div> </div>
<Space> <Space wrap>
<Input <Input
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
placeholder="Suchen …" placeholder="Suchen …"
style={{ width: 260 }} style={{ width: isMobile ? '100%' : 260 }}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
allowClear allowClear
@@ -478,6 +491,33 @@ export default function InboxPage() {
</Form> </Form>
</Modal> </Modal>
{isMobile ? (
<MobileCardList<InboxFile>
dataSource={sortedForMobile}
rowKey="id"
loading={loading}
pagination={{
pageSize: 25,
showTotal: (t) => `${t} Dateien`,
}}
emptyText="Keine Dateien vorhanden"
renderCard={(record) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<DocumentPreviewPopover record={record}>
<Typography.Text strong>{record.name}</Typography.Text>
</DocumentPreviewPopover>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{renderSourceTag(record.source)}
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{record.pageCount > 0 ? `${record.pageCount} Seiten` : '—'} · {formatDate(record.createdAt)}
</Typography.Text>
</div>
{renderBarcodes(record.barcodes)}
{renderActions(record, 'row')}
</div>
)}
/>
) : (
<Card> <Card>
<Table<InboxFile> <Table<InboxFile>
rowKey="id" rowKey="id"
@@ -492,6 +532,7 @@ export default function InboxPage() {
locale={{ emptyText: 'Keine Dateien vorhanden' }} locale={{ emptyText: 'Keine Dateien vorhanden' }}
/> />
</Card> </Card>
)}
</div> </div>
); );
} }
@@ -10,12 +10,14 @@ import { emailsApi, type EmailItem, type EmailAttachment } from '../api/emails';
import { emailImportApi } from '../api/email-import'; import { emailImportApi } from '../api/email-import';
import { getEnv } from '../utils/env'; import { getEnv } from '../utils/env';
import MailImportWizard from '../components/MailImportWizard'; import MailImportWizard from '../components/MailImportWizard';
import { useIsMobile } from '../hooks/useIsMobile';
const { Title, Text } = Typography; const { Title, Text } = Typography;
export default function MailDetailPage() { export default function MailDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobile();
const [email, setEmail] = useState<EmailItem | null>(null); const [email, setEmail] = useState<EmailItem | null>(null);
const [attachments, setAttachments] = useState<EmailAttachment[]>([]); const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
const [selected, setSelected] = useState<EmailAttachment | null>(null); const [selected, setSelected] = useState<EmailAttachment | null>(null);
@@ -136,16 +138,16 @@ export default function MailDetailPage() {
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
<Space> <Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/mailpostfach')}> <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/mailpostfach')}>
Zurück Zurück
</Button> </Button>
<Title level={3} style={{ margin: 0 }}>{email.Subject}</Title> <Title level={isMobile ? 5 : 3} style={{ margin: 0 }}>{email.Subject}</Title>
{hasErechnung && <Tag color="green">eRechnung</Tag>} {hasErechnung && <Tag color="green">eRechnung</Tag>}
</Space> </Space>
<Space> <Space wrap>
<Popconfirm <Popconfirm
title="E-Mail ignorieren" title="E-Mail ignorieren"
description="Möchten Sie diese E-Mail wirklich als ignoriert markieren?" description="Möchten Sie diese E-Mail wirklich als ignoriert markieren?"
@@ -183,12 +185,26 @@ export default function MailDetailPage() {
</Space> </Space>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 3fr', gap: 16, height: 'calc(100vh - 140px)' }}> <div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '2fr 3fr',
gap: 16,
height: isMobile ? 'auto' : 'calc(100vh - 140px)',
}}
>
{/* Linke Seite: E-Mail-Inhalt */} {/* Linke Seite: E-Mail-Inhalt */}
<Card <Card
title="E-Mail" title="E-Mail"
size="small" size="small"
styles={{ body: { overflow: 'auto', height: 'calc(100vh - 200px)', display: 'flex', flexDirection: 'column' } }} styles={{
body: {
overflow: 'auto',
height: isMobile ? 'auto' : 'calc(100vh - 200px)',
display: 'flex',
flexDirection: 'column',
},
}}
> >
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<div><Text type="secondary">Von:</Text> <Text>{email.SenderAddress}</Text></div> <div><Text type="secondary">Von:</Text> <Text>{email.SenderAddress}</Text></div>
@@ -208,7 +224,17 @@ export default function MailDetailPage() {
</Card> </Card>
{/* Rechte Seite: Anhänge + Vorschau */} {/* Rechte Seite: Anhänge + Vorschau */}
<Card size="small" styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: 'calc(100vh - 200px)' } }}> <Card
size="small"
styles={{
body: {
padding: 0,
display: 'flex',
flexDirection: 'column',
height: isMobile ? 'auto' : 'calc(100vh - 200px)',
},
}}
>
<div style={{ flex: '0 0 auto', borderBottom: `1px solid ${token.colorBorder}`, maxHeight: 240, overflow: 'auto' }}> <div style={{ flex: '0 0 auto', borderBottom: `1px solid ${token.colorBorder}`, maxHeight: 240, overflow: 'auto' }}>
<Table<EmailAttachment> <Table<EmailAttachment>
columns={columns} columns={columns}
@@ -224,7 +250,7 @@ export default function MailDetailPage() {
locale={{ emptyText: <Empty description="Keine Anhänge" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }} locale={{ emptyText: <Empty description="Keine Anhänge" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
/> />
</div> </div>
<div style={{ flex: 1, minHeight: 0, background: token.colorBgLayout }}> <div style={isMobile ? { height: '60vh', background: token.colorBgLayout } : { flex: 1, minHeight: 0, background: token.colorBgLayout }}>
{previewLoading ? ( {previewLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin /> <Spin />
@@ -7,11 +7,22 @@ import dayjs from 'dayjs';
import { emailsApi, type EmailItem } from '../api/emails'; import { emailsApi, type EmailItem } from '../api/emails';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { Permission } from '../auth/permissions'; import { Permission } from '../auth/permissions';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
const { Title } = Typography; const { Title, Text } = Typography;
function renderStatusTag(s: number) {
if (s === 0) return <Tag color="blue">Neu</Tag>;
if (s === 1) return <Tag color="green">Verarbeitet</Tag>;
if (s === 2) return <Tag color="red">Fehler</Tag>;
if (s === 3) return <Tag color="default">Ignoriert</Tag>;
return <Tag>{s}</Tag>;
}
export default function MailpostfachPage() { export default function MailpostfachPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobile();
const [emails, setEmails] = useState<EmailItem[]>([]); const [emails, setEmails] = useState<EmailItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
@@ -84,13 +95,7 @@ export default function MailpostfachPage() {
dataIndex: 'Status', dataIndex: 'Status',
key: 'Status', key: 'Status',
width: 110, width: 110,
render: (s: number) => { render: renderStatusTag,
if (s === 0) return <Tag color="blue">Neu</Tag>;
if (s === 1) return <Tag color="green">Verarbeitet</Tag>;
if (s === 2) return <Tag color="red">Fehler</Tag>;
if (s === 3) return <Tag color="default">Ignoriert</Tag>;
return <Tag>{s}</Tag>;
},
filters: [ filters: [
{ text: 'Neu', value: 0 }, { text: 'Neu', value: 0 },
{ text: 'Verarbeitet', value: 1 }, { text: 'Verarbeitet', value: 1 },
@@ -116,9 +121,9 @@ export default function MailpostfachPage() {
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>Mailpostfach</Title> <Title level={3} style={{ margin: 0 }}>Mailpostfach</Title>
<Space> <Space wrap>
<Button <Button
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
onClick={async () => { onClick={async () => {
@@ -198,13 +203,13 @@ export default function MailpostfachPage() {
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }} style={{ width: isMobile ? '100%' : 300 }}
allowClear allowClear
/> />
<Select <Select
value={statusFilter} value={statusFilter}
onChange={setStatusFilter} onChange={setStatusFilter}
style={{ width: 200 }} style={{ width: isMobile ? '100%' : 200 }}
options={[ options={[
{ value: 'all', label: 'Alle Status' }, { value: 'all', label: 'Alle Status' },
{ value: 0, label: 'Neu' }, { value: 0, label: 'Neu' },
@@ -214,6 +219,35 @@ export default function MailpostfachPage() {
]} ]}
/> />
</div> </div>
{isMobile ? (
<MobileCardList<EmailItem>
dataSource={[...filteredEmails].sort(
(a, b) => new Date(b.Date).getTime() - new Date(a.Date).getTime(),
)}
rowKey="Id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t} E-Mails` }}
onCardClick={(record) => navigate(`/mailpostfach/${record.Id}`)}
renderCard={(record) => {
const hasErechnung = record.Attachments?.some((a) => a.Erechnung);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
<Text strong>{record.Subject || '—'}</Text>
{hasErechnung && <Tag color="green">eRechnung</Tag>}
</div>
<Text type="secondary" style={{ fontSize: 13 }}>{record.SenderAddress}</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
{record.Date ? dayjs(record.Date).format('DD.MM.YYYY HH:mm') : '-'}
</Text>
{renderStatusTag(record.Status)}
</div>
</div>
);
}}
/>
) : (
<Table<EmailItem> <Table<EmailItem>
columns={columns} columns={columns}
dataSource={filteredEmails} dataSource={filteredEmails}
@@ -226,6 +260,7 @@ export default function MailpostfachPage() {
style: { cursor: 'pointer' }, style: { cursor: 'pointer' },
})} })}
/> />
)}
</Card> </Card>
</div> </div>
); );
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Table, Popover, Button, Space, message, Tooltip, Typography, Tag } from 'antd'; import { Table, Popover, Button, Space, message, Tooltip, Typography, Tag } from 'antd';
const { Title } = Typography; const { Title, Text } = Typography;
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang'; import { posteingangApi } from '../api/posteingang';
@@ -11,8 +11,11 @@ import type { PaperlessTag } from '../api/paperless';
import DocumentEditModal from '../components/DocumentEditModal'; import DocumentEditModal from '../components/DocumentEditModal';
import { getEnv } from '../utils/env'; import { getEnv } from '../utils/env';
import { AuthImage } from '../utils/auth-resource'; import { AuthImage } from '../utils/auth-resource';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
export default function ManuellBearbeitenPage() { export default function ManuellBearbeitenPage() {
const isMobile = useIsMobile();
const [data, setData] = useState<PosteingangDocument[]>([]); const [data, setData] = useState<PosteingangDocument[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
@@ -77,6 +80,26 @@ export default function ManuellBearbeitenPage() {
/> />
); );
const getContentTags = (record: PosteingangDocument) =>
(record.tags || [])
.filter(id => !steuertagIds.includes(id))
.map(id => allTags.find(t => t.id === id))
.filter((t): t is PaperlessTag => !!t);
const renderContentTags = (record: PosteingangDocument) => {
const contentTags = getContentTags(record);
if (contentTags.length === 0) return null;
return (
<Space size={[4, 4]} wrap style={{ marginTop: 4 }}>
{contentTags.map(t => (
<Tag key={t.id} color={t.color} style={{ color: t.text_color, margin: 0 }}>
{t.name}
</Tag>
))}
</Space>
);
};
const columns = [ const columns = [
{ {
title: 'Vorschau', title: 'Vorschau',
@@ -97,26 +120,12 @@ export default function ManuellBearbeitenPage() {
dataIndex: 'title', dataIndex: 'title',
key: 'title', key: 'title',
width: '35%', width: '35%',
render: (_: any, record: PosteingangDocument) => { render: (_: any, record: PosteingangDocument) => (
const contentTags = (record.tags || [])
.filter(id => !steuertagIds.includes(id))
.map(id => allTags.find(t => t.id === id))
.filter((t): t is PaperlessTag => !!t);
return (
<div> <div>
<div>{record.title}</div> <div>{record.title}</div>
{contentTags.length > 0 && ( {renderContentTags(record)}
<Space size={[4, 4]} wrap style={{ marginTop: 4 }}>
{contentTags.map(t => (
<Tag key={t.id} color={t.color} style={{ color: t.text_color, margin: 0 }}>
{t.name}
</Tag>
))}
</Space>
)}
</div> </div>
); ),
},
}, },
{ {
title: 'Eingangsdatum', title: 'Eingangsdatum',
@@ -153,6 +162,38 @@ export default function ManuellBearbeitenPage() {
</Tooltip> </Tooltip>
</Space> </Space>
</div> </div>
{isMobile ? (
<MobileCardList<PosteingangDocument>
dataSource={data}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t} Dokumente` }}
renderCard={(record) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 12 }}>
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
width={72}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<Text strong>{record.title || '—'}</Text>
{renderContentTags(record)}
<Text type="secondary" style={{ fontSize: 13 }}>
Eingangsdatum: {record.created ? dayjs(record.created).format('DD.MM.YYYY') : '-'}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Importiert am: {dayjs(record.added).format('DD.MM.YYYY HH:mm')}
</Text>
</div>
</div>
<Button type="primary" block onClick={() => handleEdit(record)}>
Bearbeiten
</Button>
</div>
)}
/>
) : (
<Table <Table
columns={columns} columns={columns}
dataSource={data} dataSource={data}
@@ -161,6 +202,7 @@ export default function ManuellBearbeitenPage() {
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
locale={{ emptyText: 'Keine Einträge vorhanden' }} locale={{ emptyText: 'Keine Einträge vorhanden' }}
/> />
)}
<DocumentEditModal <DocumentEditModal
documentId={selectedDoc?.id || null} documentId={selectedDoc?.id || null}
@@ -3,14 +3,17 @@ import { Table, Popover, Button, Space, message, Tooltip, Typography } from 'ant
import { AuthImage } from '../utils/auth-resource'; import { AuthImage } from '../utils/auth-resource';
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
const { Title } = Typography; const { Title, Text } = Typography;
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang'; import { posteingangApi } from '../api/posteingang';
import type { PosteingangDocument } from '../api/posteingang'; import type { PosteingangDocument } from '../api/posteingang';
import DocumentEditModal from '../components/DocumentEditModal'; import DocumentEditModal from '../components/DocumentEditModal';
import { getEnv } from '../utils/env'; import { getEnv } from '../utils/env';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
export default function PosteingangPage() { export default function PosteingangPage() {
const isMobile = useIsMobile();
const [data, setData] = useState<PosteingangDocument[]>([]); const [data, setData] = useState<PosteingangDocument[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = 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 = [ const columns = [
{ {
title: 'Vorschau', title: 'Vorschau',
@@ -88,10 +96,7 @@ export default function PosteingangPage() {
{ {
title: 'Eingangsdatum', title: 'Eingangsdatum',
key: 'eingangsdatum', key: 'eingangsdatum',
render: (_: any, record: PosteingangDocument) => { render: (_: any, record: PosteingangDocument) => getEingangsdatum(record),
const cf = record.customFields?.find((f) => f.field === 9);
return cf?.value ? dayjs(cf.value).format('DD.MM.YYYY') : '-';
}
}, },
{ {
title: 'Importiert am', title: 'Importiert am',
@@ -121,6 +126,37 @@ export default function PosteingangPage() {
</Tooltip> </Tooltip>
</Space> </Space>
</div> </div>
{isMobile ? (
<MobileCardList<PosteingangDocument>
dataSource={data}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t} Dokumente` }}
renderCard={(record) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 12 }}>
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
width={72}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<Text strong>{record.title || '—'}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Eingangsdatum: {getEingangsdatum(record)}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Importiert am: {dayjs(record.added).format('DD.MM.YYYY HH:mm')}
</Text>
</div>
</div>
<Button type="primary" block onClick={() => handleEdit(record)}>
Bearbeiten
</Button>
</div>
)}
/>
) : (
<Table <Table
columns={columns} columns={columns}
dataSource={data} dataSource={data}
@@ -129,6 +165,7 @@ export default function PosteingangPage() {
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
locale={{ emptyText: 'Keine Einträge vorhanden' }} locale={{ emptyText: 'Keine Einträge vorhanden' }}
/> />
)}
<DocumentEditModal <DocumentEditModal
documentId={selectedDoc?.id || null} documentId={selectedDoc?.id || null}
+124 -7
View File
@@ -2,14 +2,14 @@ import { useEffect, useState, useCallback } from 'react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
Tabs, Typography, Table, Button, Modal, Form, Input, Select, Tabs, Typography, Table, Button, Modal, Form, Input, Select,
Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge, Row, Col, Radio, Alert, Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge, Row, Col, Radio, Alert, Descriptions,
} from 'antd'; } from 'antd';
import { import {
UserOutlined, FileTextOutlined, ThunderboltOutlined, UserOutlined, FileTextOutlined, ThunderboltOutlined,
PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined, PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined,
HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined, HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined,
QrcodeOutlined, UnorderedListOutlined, PrinterOutlined, GlobalOutlined, QrcodeOutlined, UnorderedListOutlined, PrinterOutlined, GlobalOutlined,
TagsOutlined, TagsOutlined, ApiOutlined, ReloadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd'; import type { FormInstance } from 'antd';
@@ -26,6 +26,7 @@ import {
} from '../api/settings'; } from '../api/settings';
import { clientsApi, type Client } from '../api/inbox'; import { clientsApi, type Client } from '../api/inbox';
import { apiKeysApi, type ApiKey } from '../api/api-keys'; import { apiKeysApi, type ApiKey } from '../api/api-keys';
import { webhookApi, type WebhookStatus } from '../api/webhook';
import { import {
barcodeTemplatesApi, barcodeTemplatesApi,
type BarcodeTemplate, type BarcodeTemplate,
@@ -320,7 +321,7 @@ function UserClientsTab() {
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)} style={{ marginBottom: 16 }}> <Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)} style={{ marginBottom: 16 }}>
Zuordnung hinzufügen Zuordnung hinzufügen
</Button> </Button>
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} /> <Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
<Divider /> <Divider />
<Typography.Title level={5} style={{ marginBottom: 8 }}>Betriebe Agrarmonitor-Zuordnung</Typography.Title> <Typography.Title level={5} style={{ marginBottom: 8 }}>Betriebe Agrarmonitor-Zuordnung</Typography.Title>
@@ -334,6 +335,7 @@ function UserClientsTab() {
rowKey="Id" rowKey="Id"
size="small" size="small"
pagination={false} pagination={false}
scroll={{ x: 'max-content' }}
/> />
<Modal title="Neue Zuordnung" open={modalOpen} onOk={handleAdd} onCancel={() => setModalOpen(false)}> <Modal title="Neue Zuordnung" open={modalOpen} onOk={handleAdd} onCancel={() => setModalOpen(false)}>
@@ -605,6 +607,7 @@ function DocTypesTab() {
rowKey="Id" rowKey="Id"
size="small" size="small"
pagination={false} pagination={false}
scroll={{ x: 'max-content' }}
/> />
<Modal <Modal
title="Dokumenttyp bearbeiten" title="Dokumenttyp bearbeiten"
@@ -1038,7 +1041,7 @@ function PostprocessingTab() {
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}> <Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
Regel hinzufügen Regel hinzufügen
</Button> </Button>
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} /> <Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
<Modal <Modal
title={isNew ? 'Neue Postprocessing-Regel' : 'Regel bearbeiten'} title={isNew ? 'Neue Postprocessing-Regel' : 'Regel bearbeiten'}
@@ -1180,7 +1183,7 @@ function ExportTargetsTab() {
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}> <Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
Export-Ziel hinzufügen Export-Ziel hinzufügen
</Button> </Button>
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} /> <Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
<Modal title={isNew ? 'Neues Export-Ziel' : 'Export-Ziel bearbeiten'} open={!!editing} onOk={handleSave} onCancel={() => setEditing(null)}> <Modal title={isNew ? 'Neues Export-Ziel' : 'Export-Ziel bearbeiten'} open={!!editing} onOk={handleSave} onCancel={() => setEditing(null)}>
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
@@ -1246,6 +1249,7 @@ function PostprocessingLogsTab() {
loading={loading} loading={loading}
rowKey="Id" rowKey="Id"
size="small" size="small"
scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: page, current: page,
total, total,
@@ -1351,7 +1355,7 @@ function ApiKeysTab() {
</Button> </Button>
</div> </div>
<Table dataSource={data} columns={columns} loading={loading} rowKey="id" size="small" pagination={false} /> <Table dataSource={data} columns={columns} loading={loading} rowKey="id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
<Modal <Modal
title="Neuen API-Key erstellen" title="Neuen API-Key erstellen"
@@ -1577,6 +1581,7 @@ function CorrespondentsTab() {
loading={loading} loading={loading}
rowKey="id" rowKey="id"
size="small" size="small"
scroll={{ x: 'max-content' }}
pagination={{ pagination={{
current: currentPage, current: currentPage,
pageSize: pageSize, pageSize: pageSize,
@@ -1910,7 +1915,7 @@ function InboxActionsForTemplateEditor({ templateId }: { templateId: number }) {
<h4 style={{ margin: 0 }}>Weiterverarbeitungs-Aktionen</h4> <h4 style={{ margin: 0 }}>Weiterverarbeitungs-Aktionen</h4>
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={openNew}>Aktion hinzufügen</Button> <Button type="dashed" size="small" icon={<PlusOutlined />} onClick={openNew}>Aktion hinzufügen</Button>
</Space> </Space>
<Table<InboxAction> rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" /> <Table<InboxAction> rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" scroll={{ x: 'max-content' }} />
<Modal <Modal
title={isNew ? 'Neue Aktion' : 'Aktion bearbeiten'} title={isNew ? 'Neue Aktion' : 'Aktion bearbeiten'}
@@ -2167,6 +2172,7 @@ function BarcodeTemplatesTab() {
dataSource={data} dataSource={data}
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ x: 'max-content' }}
/> />
<Modal <Modal
@@ -2599,6 +2605,20 @@ function AgrarmonitorTab() {
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item
name="importWartezeitMinuten"
label="Wartezeit bis „zurück&ldquo; (Minuten)"
tooltip="Wie lange nach dem Versand gewartet wird, bevor ein Beleg als „Von Agrarmonitor zurück&ldquo; markiert wird (Import kann bis zu 10 Min dauern)."
>
<Input placeholder="10" style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="notizMarker"
label="Notiz-Marker (Text der AM-Sende-Notiz)"
tooltip="Text, an dem die Sende-Notiz erkannt wird (Teil-Übereinstimmung, Groß-/Kleinschreibung egal). Deren Zeitstempel bestimmt die Wartezeit."
>
<Input placeholder="Agrarmonitor" style={{ width: 280 }} />
</Form.Item>
<Button type="primary" loading={pollingSaving} onClick={handleSavePollingConfig}> <Button type="primary" loading={pollingSaving} onClick={handleSavePollingConfig}>
Speichern Speichern
</Button> </Button>
@@ -2748,6 +2768,98 @@ function SteuertagsTab() {
// Settings Page // Settings Page
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════
// Webhook-Status Tab
// ═══════════════════════════════════════════════════════════════════
function WebhookStatusTab() {
const [status, setStatus] = useState<WebhookStatus | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await webhookApi.getStatus();
setStatus(data);
} catch (err) {
console.error('Error loading webhook status:', err);
message.error('Webhook-Status konnte nicht geladen werden');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const renderStatusBadge = (s: string) => {
switch (s) {
case 'queued': return <Badge status="processing" text="In Warteschlange" />;
case 'processed': return <Badge status="success" text="Verarbeitet" />;
case 'error': return <Badge status="error" text="Fehler" />;
case 'bad-request': return <Badge status="error" text="Ungültige Anfrage" />;
default: return <Badge status="default" text={s} />;
}
};
const lastCall = status?.lastCall ?? null;
const queueSize = status?.queueSize ?? 0;
return (
<>
<div style={{ marginBottom: 16 }}>
<Title level={4}>Webhook-Status</Title>
<Typography.Paragraph type="secondary">
Paperless-NGX ruft nach dem Bearbeiten eines Dokuments den Webhook{' '}
<Typography.Text code>/api/webhook/paperless</Typography.Text> auf
(per API-Key authentifiziert). Die ID wird in eine Warteschlange
gelegt und von einem separaten Prozess nacheinander verarbeitet.
Hier siehst du den zuletzt festgehaltenen Vorgang. Eine vollständige
Historie steht in den Backend-Logs.
</Typography.Paragraph>
<Space>
<Button icon={<ReloadOutlined />} loading={loading} onClick={load}>
Aktualisieren
</Button>
<Tag color={queueSize > 0 ? 'processing' : 'default'}>
Warteschlange: {queueSize}
</Tag>
</Space>
</div>
<Card size="small" title="Letzter Webhook-Aufruf" loading={loading}>
{lastCall ? (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="Zeitpunkt">
{dayjs(lastCall.at).format('DD.MM.YYYY HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label="Dokument-ID">
{lastCall.documentId ?? '—'}
</Descriptions.Item>
<Descriptions.Item label="Aktion">
{lastCall.action ? <Tag>{lastCall.action}</Tag> : '—'}
</Descriptions.Item>
<Descriptions.Item label="Ergebnis">
{renderStatusBadge(lastCall.status)}
{lastCall.reason ? (
<Typography.Text type="secondary"> ({lastCall.reason})</Typography.Text>
) : null}
</Descriptions.Item>
{lastCall.message ? (
<Descriptions.Item label="Meldung">
<Typography.Text type="danger">{lastCall.message}</Typography.Text>
</Descriptions.Item>
) : null}
</Descriptions>
) : (
<Typography.Text type="secondary">
Noch kein Webhook-Aufruf erfolgt.
</Typography.Text>
)}
</Card>
</>
);
}
export default function SettingsPage() { export default function SettingsPage() {
return ( return (
<div> <div>
@@ -2805,6 +2917,11 @@ export default function SettingsPage() {
label: <span><KeyOutlined /> API-Keys</span>, label: <span><KeyOutlined /> API-Keys</span>,
children: <ApiKeysTab />, children: <ApiKeysTab />,
}, },
{
key: 'webhook',
label: <span><ApiOutlined /> Webhook</span>,
children: <WebhookStatusTab />,
},
{ {
key: 'agrarmonitor', key: 'agrarmonitor',
label: <span><GlobalOutlined /> Agrarmonitor</span>, label: <span><GlobalOutlined /> Agrarmonitor</span>,
+47 -1
View File
@@ -1,9 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Table, Button, Space, Tag, Tooltip, Popconfirm, message, ConfigProvider } from 'antd'; import { Table, Button, Space, Tag, Tooltip, Popconfirm, message, ConfigProvider, Typography } from 'antd';
import { ReloadOutlined, DeleteOutlined } from '@ant-design/icons'; import { ReloadOutlined, DeleteOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { tasksApi } from '../api/tasks'; import { tasksApi } from '../api/tasks';
import type { Task } from '../api/tasks'; import type { Task } from '../api/tasks';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
const { Text } = Typography;
function statusTag(fertig: number | null) { function statusTag(fertig: number | null) {
if (fertig === 1) return <Tag color="success">Fertig</Tag>; if (fertig === 1) return <Tag color="success">Fertig</Tag>;
@@ -12,6 +16,7 @@ function statusTag(fertig: number | null) {
} }
export default function TaskLogPage() { export default function TaskLogPage() {
const isMobile = useIsMobile();
const [data, setData] = useState<Task[]>([]); const [data, setData] = useState<Task[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -155,6 +160,46 @@ export default function TaskLogPage() {
</Popconfirm> </Popconfirm>
</Space> </Space>
</div> </div>
{isMobile ? (
<MobileCardList<Task>
dataSource={data}
rowKey="TaskId"
loading={loading}
pagination={{ pageSize: 20 }}
renderCard={(record) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<Text strong>{record.InterneBelegnummer || '—'}</Text>
{statusTag(record.Fertig)}
</div>
<Text type="secondary" style={{ fontFamily: 'monospace', fontSize: 12 }}>
{record.TaskId.slice(0, 8)}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>Lieferant: {record.Lieferant || '-'}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Belegdatum: {record.Belegdatum ? dayjs(record.Belegdatum).format('DD.MM.YYYY') : '-'}
{' · '}
Eingang: {record.Eingangsdatum ? dayjs(record.Eingangsdatum).format('DD.MM.YYYY') : '-'}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Paperless-Dok.-ID: {record.PaperlessDocumentID ?? '-'}
</Text>
<Popconfirm
title="Task löschen"
description={`Task ${record.TaskId.slice(0, 8)}… dauerhaft entfernen?`}
onConfirm={() => handleDeleteOne(record.TaskId)}
okText="Löschen"
cancelText="Abbrechen"
okButtonProps={{ danger: true }}
>
<Button danger size="small" icon={<DeleteOutlined />} block>
Löschen
</Button>
</Popconfirm>
</div>
)}
/>
) : (
<Table <Table
columns={columns} columns={columns}
dataSource={data} dataSource={data}
@@ -163,6 +208,7 @@ export default function TaskLogPage() {
pagination={{ pageSize: 20 }} pagination={{ pageSize: 20 }}
locale={{ emptyText: 'Keine Einträge vorhanden' }} locale={{ emptyText: 'Keine Einträge vorhanden' }}
/> />
)}
</div> </div>
</ConfigProvider> </ConfigProvider>
); );
@@ -0,0 +1,308 @@
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';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
const { Title, Text } = Typography;
const FREIGABE_FIELD_ID = 15;
const ZAHLUNG_FIELD_ID = 16;
const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben';
const FILTER_OPTIONS: { value: ZahlungFilter; label: string }[] = [
{ value: 'ausstehend', label: 'Freigegeben, noch nicht bezahlt' },
{ value: 'freigegeben', label: 'Alle freigegebenen' },
{ value: 'alle', label: 'Alle' },
];
export default function ZahlungPage() {
const isMobile = useIsMobile();
const [data, setData] = useState<ZahlungDocument[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [filter, setFilter] = useState<ZahlungFilter>('ausstehend');
const [docTypes, setDocTypes] = useState<PaperlessDocType[]>([]);
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
const [zahlungOptions, setZahlungOptions] = useState<ZahlungOption[]>([]);
const [selectedDoc, setSelectedDoc] = useState<ZahlungDocument | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<string | null>(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 <Tag color="default">Nicht gesetzt</Tag>;
if (val === FREIGABE_WERT_FREIGEGEBEN) return <Tag color="success">Freigegeben</Tag>;
return <Tag color="warning">{val}</Tag>;
};
const renderZahlungTag = (doc: ZahlungDocument) => {
const val = getCfValue(doc, ZAHLUNG_FIELD_ID);
if (!val) return <Tag color="default">Nicht gesetzt</Tag>;
const opt = zahlungOptions.find((o) => o.id === val);
return <Tag color="blue">{opt?.label ?? val}</Tag>;
};
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<ZahlungDocument> = [
{
title: 'Dokumenttyp',
dataIndex: 'document_type',
key: 'doctype',
render: getDocTypeName,
},
{
title: 'Titel',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: 'Erstellt',
dataIndex: 'created',
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 (
<Tooltip title={!freigegeben ? 'Beleg muss zuerst freigegeben werden' : undefined}>
<Button
icon={<EuroOutlined />}
size="small"
type="primary"
disabled={!freigegeben}
onClick={() => openModal(doc)}
>
Zahlung verbuchen
</Button>
</Tooltip>
);
},
},
];
const paginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['25', '50', '100'],
onChange: (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
},
showTotal: (t: number) => `${t} Belege`,
};
return (
<>
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Zahlung</Title>
{isMobile ? (
<Select
style={{ width: '100%', marginBottom: 16 }}
value={filter}
onChange={(v) => {
setPage(1);
setFilter(v);
}}
options={FILTER_OPTIONS}
/>
) : (
<Space style={{ marginBottom: 16 }}>
<Radio.Group
value={filter}
onChange={(e) => {
setPage(1);
setFilter(e.target.value);
}}
optionType="button"
buttonStyle="solid"
>
{FILTER_OPTIONS.map((o) => (
<Radio.Button key={o.value} value={o.value}>{o.label}</Radio.Button>
))}
</Radio.Group>
</Space>
)}
{isMobile ? (
<MobileCardList<ZahlungDocument>
dataSource={data}
rowKey="id"
loading={loading}
pagination={paginationConfig}
emptyText="Keine Belege vorhanden"
renderCard={(doc) => {
const freigegeben = istFreigegeben(doc);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<Text strong>{doc.title || '—'}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>Dokumenttyp: {getDocTypeName(doc.document_type)}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>Absender: {getCorrespondentName(doc.correspondent)}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Erstellt: {doc.created ? dayjs(doc.created).format('DD.MM.YYYY') : '—'}
</Text>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{renderFreigabeTag(doc)}
{renderZahlungTag(doc)}
</div>
<Button
icon={<EuroOutlined />}
type="primary"
block
disabled={!freigegeben}
onClick={() => openModal(doc)}
>
Zahlung verbuchen
</Button>
{!freigegeben && (
<Text type="secondary" style={{ fontSize: 12 }}>
Beleg muss zuerst freigegeben werden
</Text>
)}
</div>
);
}}
/>
) : (
<Table<ZahlungDocument>
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
size="small"
pagination={paginationConfig}
/>
)}
<Modal
title="Zahlung verbuchen"
open={modalOpen}
onOk={handleZahlung}
onCancel={() => { setModalOpen(false); setSelectedDoc(null); }}
okText="Speichern"
cancelText="Abbrechen"
confirmLoading={saving}
>
<p style={{ marginBottom: 12 }}>
<strong>{selectedDoc?.title}</strong>
</p>
<Select
style={{ width: '100%' }}
placeholder="Zahlungsstatus wählen"
allowClear
value={selectedValue ?? undefined}
onChange={(v) => setSelectedValue(v ?? null)}
options={zahlungOptions.map((o) => ({ value: o.id, label: o.label }))}
/>
</Modal>
</>
);
}