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] 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 && (