From 37ffc6c13b0407dd1d89926e02b7b5c16f63e162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Mon, 25 May 2026 21:54:09 +0200 Subject: [PATCH 01/22] feat: implement Freigabesystem for payment approval workflow Adds a dedicated approval view for PM_Freigabe users to release documents for payment by setting Paperless custom field 15 to a predefined value. - Backend: VIEW_FREIGABE permission mapped to PM_Freigabe OIDC group - Backend: FreigabeErforderlich flag on DocumentType entity (auto-migrated) - Backend: FreigabeModule with endpoints to list documents, fetch field options dynamically from Paperless, and set the approval custom field - Frontend: /freigabe route with filter (default: nicht freigegeben), paginated table, and modal to select approval value - Frontend: Settings checkbox to mark document types as requiring approval - Frontend: Freigabe menu item visible only to PM_Freigabe/PM_Admin users Co-Authored-By: Claude Sonnet 4.6 --- paperless-backend/src/app.module.ts | 2 + .../src/auth/permissions.enum.ts | 3 + .../database/entities/document-type.entity.ts | 3 + .../src/freigabe/freigabe.controller.ts | 36 +++ .../src/freigabe/freigabe.module.ts | 16 ++ .../src/freigabe/freigabe.service.ts | 106 +++++++++ paperless-frontend/src/App.tsx | 2 + paperless-frontend/src/api/freigabe.ts | 40 ++++ paperless-frontend/src/api/settings.ts | 1 + paperless-frontend/src/auth/permissions.ts | 5 + paperless-frontend/src/layouts/AppLayout.tsx | 2 + paperless-frontend/src/pages/FreigabePage.tsx | 213 ++++++++++++++++++ paperless-frontend/src/pages/SettingsPage.tsx | 9 + 13 files changed, 438 insertions(+) create mode 100644 paperless-backend/src/freigabe/freigabe.controller.ts create mode 100644 paperless-backend/src/freigabe/freigabe.module.ts create mode 100644 paperless-backend/src/freigabe/freigabe.service.ts create mode 100644 paperless-frontend/src/api/freigabe.ts create mode 100644 paperless-frontend/src/pages/FreigabePage.tsx diff --git a/paperless-backend/src/app.module.ts b/paperless-backend/src/app.module.ts index f557e8a..1af36eb 100644 --- a/paperless-backend/src/app.module.ts +++ b/paperless-backend/src/app.module.ts @@ -19,6 +19,7 @@ import { InboxPostprocessorModule } from './inbox-postprocessor/inbox-postproces import { UserSettingsModule } from './user-settings/user-settings.module'; import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module'; import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module'; +import { FreigabeModule } from './freigabe/freigabe.module'; import * as path from 'path'; @Module({ @@ -49,6 +50,7 @@ import * as path from 'path'; UserSettingsModule, LabelPrintAgentModule, AgrarmonitorModule, + FreigabeModule, ], }) export class AppModule {} diff --git a/paperless-backend/src/auth/permissions.enum.ts b/paperless-backend/src/auth/permissions.enum.ts index 7dfc892..7104ff7 100644 --- a/paperless-backend/src/auth/permissions.enum.ts +++ b/paperless-backend/src/auth/permissions.enum.ts @@ -5,6 +5,7 @@ export const Permission = { VIEW_INBOX: 'VIEW_INBOX', VIEW_SCANNER: 'VIEW_SCANNER', MANAGE_SETTINGS: 'MANAGE_SETTINGS', + VIEW_FREIGABE: 'VIEW_FREIGABE', } as const; export type Permission = typeof Permission[keyof typeof Permission]; @@ -23,6 +24,7 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per permissions.add(Permission.VIEW_INBOX); permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.MANAGE_SETTINGS); + permissions.add(Permission.VIEW_FREIGABE); return Array.from(permissions); } @@ -30,6 +32,7 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per if (groups.includes('PM_Maileingang')) permissions.add(Permission.VIEW_MAIL); if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX); if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER); + if (groups.includes('PM_Freigabe')) permissions.add(Permission.VIEW_FREIGABE); return Array.from(permissions); } diff --git a/paperless-backend/src/database/entities/document-type.entity.ts b/paperless-backend/src/database/entities/document-type.entity.ts index e5bf5e7..53551d1 100644 --- a/paperless-backend/src/database/entities/document-type.entity.ts +++ b/paperless-backend/src/database/entities/document-type.entity.ts @@ -16,4 +16,7 @@ export class DocumentType { @Column({ type: 'int', nullable: true }) TagReady!: number | null; + + @Column({ type: 'tinyint', width: 1, nullable: true, default: null }) + FreigabeErforderlich!: boolean | null; } diff --git a/paperless-backend/src/freigabe/freigabe.controller.ts b/paperless-backend/src/freigabe/freigabe.controller.ts new file mode 100644 index 0000000..e64a964 --- /dev/null +++ b/paperless-backend/src/freigabe/freigabe.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Put, Param, Body, Query } from '@nestjs/common'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; +import { FreigabeService } from './freigabe.service'; + +@Controller('api/freigabe') +@RequirePermissions(Permission.VIEW_FREIGABE) +export class FreigabeController { + constructor(private readonly freigabeService: FreigabeService) {} + + @Get('documents') + getDocuments( + @Query('page') page = '1', + @Query('pageSize') pageSize = '25', + @Query('nurNichtFreigegeben') nurNichtFreigegeben = 'true', + ) { + return this.freigabeService.getFreigabeDocuments( + parseInt(page, 10), + Math.min(parseInt(pageSize, 10), 100), + nurNichtFreigegeben !== 'false', + ); + } + + @Put('documents/:id/freigabe') + setFreigabe( + @Param('id') id: string, + @Body('value') value: string | null, + ) { + return this.freigabeService.setFreigabe(parseInt(id, 10), value ?? null); + } + + @Get('options') + getOptions() { + return this.freigabeService.getFreigabeOptions(); + } +} diff --git a/paperless-backend/src/freigabe/freigabe.module.ts b/paperless-backend/src/freigabe/freigabe.module.ts new file mode 100644 index 0000000..aa60c45 --- /dev/null +++ b/paperless-backend/src/freigabe/freigabe.module.ts @@ -0,0 +1,16 @@ +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 { FreigabeController } from './freigabe.controller'; +import { FreigabeService } from './freigabe.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([DocumentType]), + PaperlessModule, + ], + controllers: [FreigabeController], + providers: [FreigabeService], +}) +export class FreigabeModule {} diff --git a/paperless-backend/src/freigabe/freigabe.service.ts b/paperless-backend/src/freigabe/freigabe.service.ts new file mode 100644 index 0000000..043324b --- /dev/null +++ b/paperless-backend/src/freigabe/freigabe.service.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } 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; + +@Injectable() +export class FreigabeService { + private readonly logger = new Logger(FreigabeService.name); + + constructor( + @InjectRepository(DocumentType) + private readonly documentTypeRepo: Repository, + private readonly paperlessService: PaperlessService, + ) {} + + async getFreigabeDocuments(page: number, pageSize: number, nurNichtFreigegeben: boolean) { + const docTypes = await this.documentTypeRepo.find({ + where: { FreigabeErforderlich: true as any }, + }); + + if (docTypes.length === 0) { + return { count: 0, results: [] }; + } + + const docTypeIds = docTypes.map((dt) => dt.DocumentTypeId).join(','); + + const params: Record = { + page, + page_size: pageSize, + document_type__id__in: docTypeIds, + ordering: '-created', + truncate_content: true, + }; + + if (nurNichtFreigegeben) { + // Filter für Belege, bei denen Custom Field 15 nicht gesetzt ist + params[`custom_fields__field_id`] = FREIGABE_FIELD_ID; + params[`custom_fields__value__isnull`] = true; + } + + try { + const result = await this.paperlessService.getDocuments(params); + return result; + } catch (err: any) { + // Fallback: Paperless unterstützt den custom_fields-Filter möglicherweise nicht + // In diesem Fall alle Belege laden und client-seitig filtern + this.logger.warn('custom_fields Filter nicht unterstützt, lade alle Belege und filtere lokal'); + const fallbackParams: Record = { + page: 1, + page_size: 9999, + document_type__id__in: docTypeIds, + ordering: '-created', + truncate_content: true, + }; + const allDocs = await this.paperlessService.getDocuments(fallbackParams); + const results: any[] = allDocs.results ?? []; + + if (nurNichtFreigegeben) { + const filtered = results.filter((doc: any) => { + const cf = (doc.custom_fields ?? []).find((f: any) => f.field === FREIGABE_FIELD_ID); + return !cf || cf.value === null || cf.value === '' || cf.value === undefined; + }); + const start = (page - 1) * pageSize; + return { + count: filtered.length, + results: filtered.slice(start, start + pageSize), + }; + } + + const start = (page - 1) * pageSize; + return { + count: results.length, + results: results.slice(start, start + pageSize), + }; + } + } + + async setFreigabe(documentId: number, value: string | null) { + const doc = await this.paperlessService.getDocument(documentId); + const customFields: any[] = [...(doc.custom_fields ?? [])]; + + const existing = customFields.find((f: any) => f.field === FREIGABE_FIELD_ID); + if (existing) { + existing.value = value; + } else if (value !== null && value !== '') { + customFields.push({ field: FREIGABE_FIELD_ID, value }); + } + + await this.paperlessService.updateDocument(documentId, { custom_fields: customFields }); + return { success: true }; + } + + async getFreigabeOptions(): Promise<{ id: string; label: string }[]> { + const fields = await this.paperlessService.getCustomFields(); + const field = (fields as any[]).find((f: any) => f.id === FREIGABE_FIELD_ID); + if (!field) return []; + + const options: string[] = field.extra_data?.select_options ?? []; + return options + .filter((o) => o !== null && o !== undefined && o !== '') + .map((o) => ({ id: o, label: o })); + } +} diff --git a/paperless-frontend/src/App.tsx b/paperless-frontend/src/App.tsx index b3320eb..45cd76b 100644 --- a/paperless-frontend/src/App.tsx +++ b/paperless-frontend/src/App.tsx @@ -20,6 +20,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage')); const UserSettingsPage = lazy(() => import('./pages/UserSettingsPage')); const LoginPage = lazy(() => import('./pages/LoginPage')); const DashboardPage = lazy(() => import('./pages/DashboardPage')); +const FreigabePage = lazy(() => import('./pages/FreigabePage')); import { Permission } from './auth/permissions'; function UnauthorizedPage() { @@ -131,6 +132,7 @@ function ThemedApp() { } /> } /> } /> + } /> } /> diff --git a/paperless-frontend/src/api/freigabe.ts b/paperless-frontend/src/api/freigabe.ts new file mode 100644 index 0000000..1f4163e --- /dev/null +++ b/paperless-frontend/src/api/freigabe.ts @@ -0,0 +1,40 @@ +import api from './client'; + +export interface FreigabeDocument { + id: number; + title: string; + created: string; + created_date: string; + correspondent: number | null; + document_type: number | null; + archive_serial_number: number | null; + tags: number[]; + custom_fields: { field: number; value: any }[]; +} + +export interface FreigabeOption { + id: string; + label: string; +} + +export interface FreigabeResult { + count: number; + results: FreigabeDocument[]; +} + +export const freigabeApi = { + getDocuments: (page = 1, pageSize = 25, nurNichtFreigegeben = true) => + api + .get('/api/freigabe/documents', { + params: { page, pageSize, nurNichtFreigegeben }, + }) + .then((r) => r.data), + + setFreigabe: (docId: number, value: string | null) => + api + .put<{ success: boolean }>(`/api/freigabe/documents/${docId}/freigabe`, { value }) + .then((r) => r.data), + + getOptions: () => + api.get('/api/freigabe/options').then((r) => r.data), +}; diff --git a/paperless-frontend/src/api/settings.ts b/paperless-frontend/src/api/settings.ts index e09665b..5081769 100644 --- a/paperless-frontend/src/api/settings.ts +++ b/paperless-frontend/src/api/settings.ts @@ -6,6 +6,7 @@ export interface SettingDocType { TitelTemplate: string; TagNotReady: number | null; TagReady: number | null; + FreigabeErforderlich?: boolean | null; } export interface SettingDocField { diff --git a/paperless-frontend/src/auth/permissions.ts b/paperless-frontend/src/auth/permissions.ts index bb99ed4..ed90aa4 100644 --- a/paperless-frontend/src/auth/permissions.ts +++ b/paperless-frontend/src/auth/permissions.ts @@ -5,6 +5,7 @@ export const Permission = { VIEW_INBOX: 'VIEW_INBOX', VIEW_SCANNER: 'VIEW_SCANNER', MANAGE_SETTINGS: 'MANAGE_SETTINGS', + VIEW_FREIGABE: 'VIEW_FREIGABE', } as const; export type Permission = typeof Permission[keyof typeof Permission]; @@ -24,6 +25,7 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per permissions.add(Permission.VIEW_INBOX); permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.MANAGE_SETTINGS); + permissions.add(Permission.VIEW_FREIGABE); return Array.from(permissions); } @@ -39,6 +41,9 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per if (groups.includes('PM_Scanner')) { permissions.add(Permission.VIEW_SCANNER); } + if (groups.includes('PM_Freigabe')) { + permissions.add(Permission.VIEW_FREIGABE); + } return Array.from(permissions); } diff --git a/paperless-frontend/src/layouts/AppLayout.tsx b/paperless-frontend/src/layouts/AppLayout.tsx index 478e8bf..2cfc2c7 100644 --- a/paperless-frontend/src/layouts/AppLayout.tsx +++ b/paperless-frontend/src/layouts/AppLayout.tsx @@ -13,6 +13,7 @@ import { MoonOutlined, AppstoreOutlined, GlobalOutlined, + CheckCircleOutlined, } from '@ant-design/icons'; import { useAuth } from '../auth/AuthContext'; import { useTheme } from '../theme/ThemeContext'; @@ -38,6 +39,7 @@ const allMenuItems: MenuItemDef[] = [ { key: '/manuell', icon: , label: 'Manuell bearbeiten', permission: Permission.PROCESS_MANUALLY, countKey: 'manuell' }, { key: '/mailpostfach', icon: , label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' }, { key: 'agrarmonitor', icon: , label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' }, + { key: '/freigabe', icon: , label: 'Freigabe', permission: Permission.VIEW_FREIGABE }, { key: '/settings', icon: , label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS }, ]; diff --git a/paperless-frontend/src/pages/FreigabePage.tsx b/paperless-frontend/src/pages/FreigabePage.tsx new file mode 100644 index 0000000..4502ca3 --- /dev/null +++ b/paperless-frontend/src/pages/FreigabePage.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState, useCallback } from 'react'; +import { + Table, Typography, Tag, Button, Modal, Select, message, Space, Radio, +} from 'antd'; +import { CheckCircleOutlined } from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { freigabeApi, type FreigabeDocument, type FreigabeOption } from '../api/freigabe'; +import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless'; + +const { Title } = Typography; +const FREIGABE_FIELD_ID = 15; + +export default function FreigabePage() { + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [nurNichtFreigegeben, setNurNichtFreigegeben] = useState(true); + + const [docTypes, setDocTypes] = useState([]); + const [correspondents, setCorrespondents] = useState([]); + const [freigabeOptions, setFreigabeOptions] = useState([]); + + const [selectedDoc, setSelectedDoc] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [selectedValue, setSelectedValue] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + Promise.all([ + paperlessApi.getDocumentTypes(), + paperlessApi.getCorrespondents(), + freigabeApi.getOptions(), + ]).then(([dts, corrs, opts]) => { + setDocTypes(dts); + setCorrespondents(corrs); + setFreigabeOptions(opts); + }); + }, []); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const result = await freigabeApi.getDocuments(page, pageSize, nurNichtFreigegeben); + setData(result.results ?? []); + setTotal(result.count ?? 0); + } catch { + message.error('Fehler beim Laden der Belege'); + } finally { + setLoading(false); + } + }, [page, pageSize, nurNichtFreigegeben]); + + 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 getFreigabeValue = (doc: FreigabeDocument) => { + const cf = doc.custom_fields?.find((f) => f.field === FREIGABE_FIELD_ID); + if (!cf || cf.value === null || cf.value === '' || cf.value === undefined) { + return Nicht gesetzt; + } + const opt = freigabeOptions.find((o) => String(o.id) === String(cf.value)); + return {opt?.label ?? String(cf.value)}; + }; + + const openModal = (doc: FreigabeDocument) => { + const cf = doc.custom_fields?.find((f) => f.field === FREIGABE_FIELD_ID); + setSelectedDoc(doc); + setSelectedValue(cf?.value ?? null); + setModalOpen(true); + }; + + const handleFreigabe = async () => { + if (!selectedDoc) return; + setSaving(true); + try { + await freigabeApi.setFreigabe(selectedDoc.id, selectedValue); + message.success('Freigabe gesetzt'); + setModalOpen(false); + setSelectedDoc(null); + fetchData(); + } catch { + message.error('Fehler beim Speichern der Freigabe'); + } finally { + setSaving(false); + } + }; + + const columns: ColumnsType = [ + { + title: 'Dokumenttyp', + dataIndex: 'document_type', + key: 'doctype', + render: getDocTypeName, + }, + { + title: 'Titel', + dataIndex: 'title', + key: 'title', + ellipsis: true, + }, + { + title: 'Erstellt', + dataIndex: 'created_date', + key: 'created', + width: 110, + render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—', + }, + { + title: 'Absender', + dataIndex: 'correspondent', + key: 'correspondent', + render: getCorrespondentName, + }, + { + title: 'Freigabe', + key: 'freigabe', + width: 140, + render: (_, doc) => getFreigabeValue(doc), + }, + { + title: '', + key: 'action', + width: 130, + render: (_, doc) => ( + + ), + }, + ]; + + return ( + <> + Freigabe + + + { + setPage(1); + setNurNichtFreigegeben(e.target.value); + }} + optionType="button" + buttonStyle="solid" + > + Nicht freigegeben + Alle + + + + + dataSource={data} + columns={columns} + rowKey="id" + loading={loading} + size="small" + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: ['25', '50', '100'], + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + showTotal: (t) => `${t} Belege`, + }} + /> + + { setModalOpen(false); setSelectedDoc(null); }} + okText="Speichern" + cancelText="Abbrechen" + confirmLoading={saving} + > +

+ {selectedDoc?.title} +

+ + + + {editing && ( From d5bc1bcee0149adf7ddf1fcacfe1f3223e0f6955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Tue, 26 May 2026 07:01:39 +0200 Subject: [PATCH 02/22] fix: handle object-format select_options from Paperless for Freigabe field Paperless may return extra_data.select_options as an array of objects {id, label} instead of plain strings. This caused React error #31 when Ant Design tried to render an object as a child in the Select and Table components. - Backend: coerce option items to {id: string, label: string} regardless of whether Paperless returns strings or objects - Frontend: normalize cf.value to a plain string before rendering or storing in state, guarding against object-typed values Co-Authored-By: Claude Sonnet 4.6 --- .../src/freigabe/freigabe.service.ts | 16 +++++++++++++--- paperless-frontend/src/pages/FreigabePage.tsx | 17 +++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/paperless-backend/src/freigabe/freigabe.service.ts b/paperless-backend/src/freigabe/freigabe.service.ts index 043324b..3f5c2c8 100644 --- a/paperless-backend/src/freigabe/freigabe.service.ts +++ b/paperless-backend/src/freigabe/freigabe.service.ts @@ -98,9 +98,19 @@ export class FreigabeService { const field = (fields as any[]).find((f: any) => f.id === FREIGABE_FIELD_ID); if (!field) return []; - const options: string[] = field.extra_data?.select_options ?? []; - return options + const rawOptions: any[] = field.extra_data?.select_options ?? []; + return rawOptions .filter((o) => o !== null && o !== undefined && o !== '') - .map((o) => ({ id: o, label: o })); + .map((o) => { + if (typeof o === 'object') { + // Paperless kann select_options als Objekte liefern + return { + id: String(o.id ?? o.value ?? o.label ?? ''), + label: String(o.label ?? o.name ?? o.id ?? ''), + }; + } + return { id: String(o), label: String(o) }; + }) + .filter((o) => o.id !== ''); } } diff --git a/paperless-frontend/src/pages/FreigabePage.tsx b/paperless-frontend/src/pages/FreigabePage.tsx index 4502ca3..d6c1b18 100644 --- a/paperless-frontend/src/pages/FreigabePage.tsx +++ b/paperless-frontend/src/pages/FreigabePage.tsx @@ -67,19 +67,24 @@ export default function FreigabePage() { 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 getFreigabeValue = (doc: FreigabeDocument) => { const cf = doc.custom_fields?.find((f) => f.field === FREIGABE_FIELD_ID); - if (!cf || cf.value === null || cf.value === '' || cf.value === undefined) { - return Nicht gesetzt; - } - const opt = freigabeOptions.find((o) => String(o.id) === String(cf.value)); - return {opt?.label ?? String(cf.value)}; + const cfStr = toCfString(cf?.value); + if (!cfStr) return Nicht gesetzt; + const opt = freigabeOptions.find((o) => o.id === cfStr); + return {opt?.label ?? cfStr}; }; const openModal = (doc: FreigabeDocument) => { const cf = doc.custom_fields?.find((f) => f.field === FREIGABE_FIELD_ID); setSelectedDoc(doc); - setSelectedValue(cf?.value ?? null); + setSelectedValue(toCfString(cf?.value)); setModalOpen(true); }; From 4016802c1e5b4ba6ce7d0587c28c39e0317ad134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Tue, 26 May 2026 10:43:03 +0200 Subject: [PATCH 03/22] fix: use manual res.json() in getNextJob to prevent double-response on 204 Co-Authored-By: Claude Sonnet 4.6 --- .../src/label-print-agent/label-print-agent.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/paperless-backend/src/label-print-agent/label-print-agent.controller.ts b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts index b0114a5..7d9d64f 100644 --- a/paperless-backend/src/label-print-agent/label-print-agent.controller.ts +++ b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts @@ -60,19 +60,19 @@ export class LabelPrintAgentController { // Agent: nächsten Job abholen (Polling) @Get('jobs/next') - async getNextJob(@Query('agentId') agentId: string, @Res({ passthrough: true }) res: Response) { + async getNextJob(@Query('agentId') agentId: string, @Res() res: Response) { const job = await this.service.claimNextJob(agentId ?? 'unknown'); if (!job) { res.status(HttpStatus.NO_CONTENT).send(); return; } - return { + res.status(HttpStatus.OK).json({ jobId: String(job.Id), labelImageBase64: job.LabelImageData ? job.LabelImageData.toString('base64') : null, labelImageContentType: 'image/png', labelWidthMm: job.LabelWidthMm, labelHeightMm: job.LabelHeightMm, - }; + }); } // Agent: Bild separat abrufen From 036d135109ef155161b8e69056e53aa9cd8d1c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Tue, 26 May 2026 13:28:27 +0200 Subject: [PATCH 04/22] fix: import documents without buchungsDatum instead of skipping them Co-Authored-By: Claude Sonnet 4.6 --- .../src/agrarmonitor/agrarmonitor-polling.service.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 60294ad..a976cbf 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -231,8 +231,9 @@ export class AgrarmonitorPollingService implements OnModuleInit { this.logger.log(`Eingangsdatum für ${interneBelegnummer} gesetzt`); } } - result.skipped++; - } else if (amDoc.buchungsDatum) { + } + + if (!amDoc.buchungsDatum) { try { let correspondentId: number | undefined; const customer = customers.find((c) => Number(c.id) === amDoc.kundenId); @@ -262,9 +263,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { this.logger.error(`${msg}: ${err instanceof Error ? err.message : err}`); result.errors.push(msg); } - } else { - result.skipped++; - } + } await this.delay(500); } From b4dd959b4a707e2b80ef93b83fb1bf720323fa4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Tue, 26 May 2026 13:56:19 +0200 Subject: [PATCH 05/22] fix: load all correspondents instead of first 100 in Paperless API Raised page_size from 100 to 9999 on GET /api/paperless/correspondents so the FreigabePage can resolve all correspondent IDs to names. Co-Authored-By: Claude Sonnet 4.6 --- paperless-backend/src/paperless/paperless.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paperless-backend/src/paperless/paperless.controller.ts b/paperless-backend/src/paperless/paperless.controller.ts index 6c57123..e129f42 100644 --- a/paperless-backend/src/paperless/paperless.controller.ts +++ b/paperless-backend/src/paperless/paperless.controller.ts @@ -88,7 +88,7 @@ export class PaperlessController { @Get('correspondents') async getCorrespondents(@Query('search') search?: string) { - const params: any = { page_size: 100 }; + const params: any = { page_size: 9999 }; if (search) { params.name__icontains = search; } From 1698eba9689e0fcf06873556e9645ba7f88e48bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Tue, 26 May 2026 14:05:11 +0200 Subject: [PATCH 06/22] fix: correct polling conditions for eingangsDatum and buchungsDatum - Only set eingangsDatum when belegNummer is present - Import documents when buchungsDatum is set (revert inverted condition) Co-Authored-By: Claude Sonnet 4.6 --- .../src/agrarmonitor/agrarmonitor-polling.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index a976cbf..7d4c855 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -220,7 +220,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { } } - if (!amDoc.eingangsDatum) { + if (!amDoc.eingangsDatum && amDoc.belegNummer) { const eingangsdatumField = ((doc.custom_fields as any[]) ?? []).find( (cf: any) => cf.field === EINGANGSDATUM_FIELD_ID, ); @@ -233,7 +233,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { } } - if (!amDoc.buchungsDatum) { + if (amDoc.buchungsDatum) { try { let correspondentId: number | undefined; const customer = customers.find((c) => Number(c.id) === amDoc.kundenId); From e6436b2b9c6a02acc6dc767dd576f32c6f0e7906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Tue, 26 May 2026 21:45:58 +0200 Subject: [PATCH 07/22] feat: tag documents as Posteingang when AM entry is missing during upload check - Add agrarmonitor_tag_posteingang setting (default empty) - When a document is not found in Agrarmonitor, move it back to Posteingang tag instead of skipping (if tagPosteingang is configured) - Expose tagPosteingang in polling config API and settings UI Co-Authored-By: Claude Sonnet 4.6 --- .../agrarmonitor-polling.service.ts | 27 ++++++++++++++----- .../agrarmonitor/agrarmonitor.controller.ts | 4 +-- paperless-frontend/src/api/settings.ts | 1 + paperless-frontend/src/pages/SettingsPage.tsx | 3 +++ 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 7d4c855..0a5147c 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -53,6 +53,7 @@ export class AgrarmonitorPollingService implements OnModuleInit { await this.upsertSetting('agrarmonitor_tag_verbucht', '9'); await this.upsertSetting('agrarmonitor_tag_hochgeladen', ''); await this.upsertSetting('agrarmonitor_link_field', ''); + await this.upsertSetting('agrarmonitor_tag_posteingang', ''); } @Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *') @@ -67,18 +68,20 @@ export class AgrarmonitorPollingService implements OnModuleInit { this.processVerarbeiteteDocuments().catch((err) => this.logger.error('Cron-Upload-Check-Fehler:', err)); } - async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string }> { - const [fertig, verbucht, hochgeladen, linkField] = await Promise.all([ + async getPollingConfig(): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string; tagPosteingang: string }> { + const [fertig, verbucht, hochgeladen, linkField, posteingang] = await Promise.all([ this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_posteingang' }), ]); return { tagFertig: fertig?.Wert ?? '4', tagVerbucht: verbucht?.Wert ?? '9', tagHochgeladen: hochgeladen?.Wert ?? '', linkField: linkField?.Wert ?? '', + tagPosteingang: posteingang?.Wert ?? '', }; } @@ -87,14 +90,16 @@ export class AgrarmonitorPollingService implements OnModuleInit { tagVerbucht: string, tagHochgeladen: string, linkField: string, - ): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string }> { + tagPosteingang: string, + ): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string; tagPosteingang: string }> { await Promise.all([ this.settingRepo.update({ Tag: 'agrarmonitor_tag_fertig' }, { Wert: tagFertig }), this.settingRepo.update({ Tag: 'agrarmonitor_tag_verbucht' }, { Wert: tagVerbucht }), this.settingRepo.update({ Tag: 'agrarmonitor_tag_hochgeladen' }, { Wert: tagHochgeladen }), this.settingRepo.update({ Tag: 'agrarmonitor_link_field' }, { Wert: linkField }), + this.settingRepo.update({ Tag: 'agrarmonitor_tag_posteingang' }, { Wert: tagPosteingang }), ]); - return { tagFertig, tagVerbucht, tagHochgeladen, linkField }; + return { tagFertig, tagVerbucht, tagHochgeladen, linkField, tagPosteingang }; } async runPolling(): Promise { @@ -290,15 +295,17 @@ export class AgrarmonitorPollingService implements OnModuleInit { this.logger.log('Starte Upload-Check'); try { - const [hochgeladenSetting, fertigSetting, linkFieldSetting] = await Promise.all([ + const [hochgeladenSetting, fertigSetting, linkFieldSetting, posteingangSetting] = await Promise.all([ this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_posteingang' }), ]); const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10); const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10); const linkFieldId = parseInt(linkFieldSetting?.Wert ?? '', 10); + const tagPosteingangId = parseInt(posteingangSetting?.Wert ?? '', 10); if (isNaN(tagHochgeladenId)) { this.logger.warn('Tag "hochgeladen" nicht konfiguriert — Upload-Check übersprungen'); @@ -361,7 +368,15 @@ export class AgrarmonitorPollingService implements OnModuleInit { } if (!vorhanden) { - result.skipped++; + if (!isNaN(tagPosteingangId)) { + const currentTags: number[] = (doc.tags as number[]) ?? []; + const newTags = [...new Set(currentTags.filter(t => t !== tagHochgeladenId).concat([tagPosteingangId]))]; + await this.paperlessService.updateDocument(doc.id as number, { tags: newTags }); + this.logger.log(`${interneBelegnummer} nicht mehr in Agrarmonitor — zurück in Posteingang`); + result.updated++; + } else { + result.skipped++; + } await this.delay(500); continue; } diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts index c7124c7..d8e5da3 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts @@ -32,8 +32,8 @@ export class AgrarmonitorController { @Put('polling-config') @RequirePermissions(Permission.MANAGE_SETTINGS) - async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string }) { - return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht, body.tagHochgeladen, body.linkField); + async updatePollingConfig(@Body() body: { tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string; tagPosteingang: string }) { + return this.pollingService.updatePollingConfig(body.tagFertig, body.tagVerbucht, body.tagHochgeladen, body.linkField, body.tagPosteingang ?? ''); } @Post('run-polling') diff --git a/paperless-frontend/src/api/settings.ts b/paperless-frontend/src/api/settings.ts index 5081769..735ba49 100644 --- a/paperless-frontend/src/api/settings.ts +++ b/paperless-frontend/src/api/settings.ts @@ -202,6 +202,7 @@ export interface AgrarmonitorPollingConfig { tagVerbucht: string; tagHochgeladen: string; linkField: string; + tagPosteingang: string; } export interface AgrarmonitorPollingResult { diff --git a/paperless-frontend/src/pages/SettingsPage.tsx b/paperless-frontend/src/pages/SettingsPage.tsx index 56081f3..6533cde 100644 --- a/paperless-frontend/src/pages/SettingsPage.tsx +++ b/paperless-frontend/src/pages/SettingsPage.tsx @@ -2588,6 +2588,9 @@ function AgrarmonitorTab() { + + + - - + + ({ value: t.id, label: t.name }))} + tagRender={({ value, closable, onClose }) => { + const tag = allTags.find(t => t.id === value); + return ( + + {tag?.name ?? value} + + ); + }} + optionRender={(opt) => { + const tag = contentTags.find(t => t.id === opt.value); + return tag ? ( + {tag.name} + ) : opt.label; + }} + /> + + {/* Rendering dynamic requirements based on DocumentType */} {requirements.map(req => { if (req.feldId === '2' || (req.isCustomField && req.customFieldIndex === 9)) { diff --git a/paperless-frontend/src/pages/ManuellBearbeitenPage.tsx b/paperless-frontend/src/pages/ManuellBearbeitenPage.tsx index 16e14dd..49d19fb 100644 --- a/paperless-frontend/src/pages/ManuellBearbeitenPage.tsx +++ b/paperless-frontend/src/pages/ManuellBearbeitenPage.tsx @@ -1,11 +1,13 @@ import { useEffect, useState } from 'react'; -import { Table, Popover, Button, Space, message, Tooltip, Typography } from 'antd'; +import { Table, Popover, Button, Space, message, Tooltip, Typography, Tag } from 'antd'; const { Title } = Typography; import { ReloadOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import { posteingangApi } from '../api/posteingang'; import type { PosteingangDocument } from '../api/posteingang'; +import { paperlessApi } from '../api/paperless'; +import type { PaperlessTag } from '../api/paperless'; import DocumentEditModal from '../components/DocumentEditModal'; import { getEnv } from '../utils/env'; import { AuthImage } from '../utils/auth-resource'; @@ -15,6 +17,8 @@ export default function ManuellBearbeitenPage() { const [loading, setLoading] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); const [selectedDoc, setSelectedDoc] = useState(null); + const [allTags, setAllTags] = useState([]); + const [steuertagIds, setSteuertagIds] = useState([]); const fetchData = async () => { setLoading(true); @@ -28,6 +32,15 @@ export default function ManuellBearbeitenPage() { } }; + useEffect(() => { + Promise.all([paperlessApi.getTags(), paperlessApi.getSteuertagIds()]) + .then(([tags, ids]) => { + setAllTags(tags); + setSteuertagIds(ids); + }) + .catch(() => { /* Tags optional; Chips bleiben dann leer */ }); + }, []); + useEffect(() => { fetchData(); // Refresh interval every 30 seconds @@ -84,6 +97,26 @@ export default function ManuellBearbeitenPage() { dataIndex: 'title', key: 'title', width: '35%', + render: (_: any, record: PosteingangDocument) => { + const contentTags = (record.tags || []) + .filter(id => !steuertagIds.includes(id)) + .map(id => allTags.find(t => t.id === id)) + .filter((t): t is PaperlessTag => !!t); + return ( +
+
{record.title}
+ {contentTags.length > 0 && ( + + {contentTags.map(t => ( + + {t.name} + + ))} + + )} +
+ ); + }, }, { title: 'Eingangsdatum', diff --git a/paperless-frontend/src/pages/SettingsPage.tsx b/paperless-frontend/src/pages/SettingsPage.tsx index a2e27b4..9e67688 100644 --- a/paperless-frontend/src/pages/SettingsPage.tsx +++ b/paperless-frontend/src/pages/SettingsPage.tsx @@ -9,6 +9,7 @@ import { PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined, HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined, QrcodeOutlined, UnorderedListOutlined, PrinterOutlined, GlobalOutlined, + TagsOutlined, } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import type { FormInstance } from 'antd'; @@ -2663,6 +2664,86 @@ function AgrarmonitorTab() { } +// ═══════════════════════════════════════════════════════════════════ +// Steuertags Tab +// ═══════════════════════════════════════════════════════════════════ + +function SteuertagsTab() { + const [tags, setTags] = useState([]); + const [selected, setSelected] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const [fetchedTags, steuertagIds] = await Promise.all([ + paperlessApi.getTags(), + settingsApi.getSteuertagIds(), + ]); + setTags(fetchedTags); + setSelected(steuertagIds); + } catch { + message.error('Tags konnten nicht geladen werden.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const handleSave = async () => { + setSaving(true); + try { + await settingsApi.updateSteuertagIds(selected); + message.success('Steuertags gespeichert.'); + } catch { + message.error('Steuertags konnten nicht gespeichert werden.'); + } finally { + setSaving(false); + } + }; + + return ( +
+ +