From 4d05a94681069d812492f1cc9affb56e3a2b27bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Thu, 18 Jun 2026 21:29:50 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20two-step=20Freigabe?= =?UTF-8?q?=E2=86=92Zahlung=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a payment step after document approval: PM_Freigabe approves (Field 15 = "freigegeben"), then PM_Zahlung can mark as paid (Field 16). - Backend: VIEW_ZAHLUNG permission mapped to PM_Zahlung OIDC group - Backend: ZahlungModule with endpoints to list documents by filter (ausstehend/freigegeben/alle), set Field 16, fetch options from Paperless - Backend: setZahlung() throws ForbiddenException if Field 15 ≠ "freigegeben" - Frontend: /zahlung route with 3-way filter, two status columns (Freigabe + Zahlung) - Frontend: "Zahlung verbuchen" button disabled with tooltip for non-approved docs - Frontend: Zahlung menu item with EuroOutlined icon Co-Authored-By: Claude Sonnet 4.6 --- paperless-backend/src/app.module.ts | 2 + .../src/auth/permissions.enum.ts | 3 + .../src/zahlung/zahlung.controller.ts | 36 +++ .../src/zahlung/zahlung.module.ts | 13 + .../src/zahlung/zahlung.service.ts | 124 +++++++++ paperless-frontend/src/App.tsx | 2 + paperless-frontend/src/api/zahlung.ts | 40 +++ paperless-frontend/src/auth/permissions.ts | 5 + paperless-frontend/src/layouts/AppLayout.tsx | 2 + paperless-frontend/src/pages/ZahlungPage.tsx | 244 ++++++++++++++++++ 10 files changed, 471 insertions(+) create mode 100644 paperless-backend/src/zahlung/zahlung.controller.ts create mode 100644 paperless-backend/src/zahlung/zahlung.module.ts create mode 100644 paperless-backend/src/zahlung/zahlung.service.ts create mode 100644 paperless-frontend/src/api/zahlung.ts create mode 100644 paperless-frontend/src/pages/ZahlungPage.tsx diff --git a/paperless-backend/src/app.module.ts b/paperless-backend/src/app.module.ts index f28e841..a70dfeb 100644 --- a/paperless-backend/src/app.module.ts +++ b/paperless-backend/src/app.module.ts @@ -20,6 +20,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module'; import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module'; import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module'; import { FreigabeModule } from './freigabe/freigabe.module'; +import { ZahlungModule } from './zahlung/zahlung.module'; import { DailyDigestModule } from './daily-digest/daily-digest.module'; import * as path from 'path'; @@ -52,6 +53,7 @@ import * as path from 'path'; LabelPrintAgentModule, AgrarmonitorModule, FreigabeModule, + ZahlungModule, DailyDigestModule, ], }) diff --git a/paperless-backend/src/auth/permissions.enum.ts b/paperless-backend/src/auth/permissions.enum.ts index 5564e78..ac28159 100644 --- a/paperless-backend/src/auth/permissions.enum.ts +++ b/paperless-backend/src/auth/permissions.enum.ts @@ -6,6 +6,7 @@ export const Permission = { VIEW_SCANNER: 'VIEW_SCANNER', MANAGE_SETTINGS: 'MANAGE_SETTINGS', VIEW_FREIGABE: 'VIEW_FREIGABE', + VIEW_ZAHLUNG: 'VIEW_ZAHLUNG', } as const; export type Permission = (typeof Permission)[keyof typeof Permission]; @@ -27,6 +28,7 @@ export function mapGroupsToPermissions( permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.MANAGE_SETTINGS); permissions.add(Permission.VIEW_FREIGABE); + permissions.add(Permission.VIEW_ZAHLUNG); return Array.from(permissions); } @@ -36,6 +38,7 @@ export function mapGroupsToPermissions( if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX); if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER); if (groups.includes('PM_Freigabe')) permissions.add(Permission.VIEW_FREIGABE); + if (groups.includes('PM_Zahlung')) permissions.add(Permission.VIEW_ZAHLUNG); return Array.from(permissions); } diff --git a/paperless-backend/src/zahlung/zahlung.controller.ts b/paperless-backend/src/zahlung/zahlung.controller.ts new file mode 100644 index 0000000..b8dad7e --- /dev/null +++ b/paperless-backend/src/zahlung/zahlung.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Put, Param, Body, Query } from '@nestjs/common'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; +import { ZahlungService, type ZahlungFilter } from './zahlung.service'; + +@Controller('api/zahlung') +@RequirePermissions(Permission.VIEW_ZAHLUNG) +export class ZahlungController { + constructor(private readonly zahlungService: ZahlungService) {} + + @Get('documents') + getDocuments( + @Query('page') page = '1', + @Query('pageSize') pageSize = '25', + @Query('filter') filter: ZahlungFilter = 'ausstehend', + ) { + return this.zahlungService.getZahlungDocuments( + parseInt(page, 10), + Math.min(parseInt(pageSize, 10), 100), + filter, + ); + } + + @Put('documents/:id/zahlung') + setZahlung( + @Param('id') id: string, + @Body('value') value: string | null, + ) { + return this.zahlungService.setZahlung(parseInt(id, 10), value ?? null); + } + + @Get('options') + getOptions() { + return this.zahlungService.getZahlungOptions(); + } +} diff --git a/paperless-backend/src/zahlung/zahlung.module.ts b/paperless-backend/src/zahlung/zahlung.module.ts new file mode 100644 index 0000000..4ad660d --- /dev/null +++ b/paperless-backend/src/zahlung/zahlung.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DocumentType } from '../database/entities/document-type.entity'; +import { PaperlessModule } from '../paperless/paperless.module'; +import { ZahlungController } from './zahlung.controller'; +import { ZahlungService } from './zahlung.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([DocumentType]), PaperlessModule], + controllers: [ZahlungController], + providers: [ZahlungService], +}) +export class ZahlungModule {} diff --git a/paperless-backend/src/zahlung/zahlung.service.ts b/paperless-backend/src/zahlung/zahlung.service.ts new file mode 100644 index 0000000..4d7ceda --- /dev/null +++ b/paperless-backend/src/zahlung/zahlung.service.ts @@ -0,0 +1,124 @@ +import { Injectable, Logger, ForbiddenException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DocumentType } from '../database/entities/document-type.entity'; +import { PaperlessService } from '../paperless/paperless.service'; + +const FREIGABE_FIELD_ID = 15; +const ZAHLUNG_FIELD_ID = 16; +const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben'; + +export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle'; + +@Injectable() +export class ZahlungService { + private readonly logger = new Logger(ZahlungService.name); + + constructor( + @InjectRepository(DocumentType) + private readonly documentTypeRepo: Repository, + private readonly paperlessService: PaperlessService, + ) {} + + async getZahlungDocuments(page: number, pageSize: number, filter: ZahlungFilter) { + const docTypes = await this.documentTypeRepo.find({ + where: { FreigabeErforderlich: true as any }, + }); + + if (docTypes.length === 0) { + return { count: 0, results: [] }; + } + + const docTypeIds = docTypes.map((dt) => dt.DocumentTypeId).join(','); + + const params: Record = { + page: 1, + page_size: 9999, + document_type__id__in: docTypeIds, + ordering: '-created', + truncate_content: true, + }; + + let allDocs: any[]; + try { + const result = await this.paperlessService.getDocuments(params); + allDocs = result.results ?? []; + } catch (err: any) { + this.logger.warn('Fehler beim Laden der Belege für Zahlung', err?.message); + return { count: 0, results: [] }; + } + + const filtered = this.applyFilter(allDocs, filter); + const start = (page - 1) * pageSize; + return { + count: filtered.length, + results: filtered.slice(start, start + pageSize), + }; + } + + private applyFilter(docs: any[], filter: ZahlungFilter): any[] { + if (filter === 'alle') return docs; + + return docs.filter((doc: any) => { + const freigabeValue = this.getCfValue(doc, FREIGABE_FIELD_ID); + const istFreigegeben = freigabeValue === FREIGABE_WERT_FREIGEGEBEN; + + if (filter === 'freigegeben') return istFreigegeben; + + // 'ausstehend': freigegeben aber noch nicht bezahlt + const zahlungValue = this.getCfValue(doc, ZAHLUNG_FIELD_ID); + return istFreigegeben && !zahlungValue; + }); + } + + private getCfValue(doc: any, fieldId: number): string | null { + const cf = (doc.custom_fields ?? []).find((f: any) => f.field === fieldId); + if (!cf || cf.value === null || cf.value === undefined || cf.value === '') return null; + if (typeof cf.value === 'object') { + return String(cf.value?.id ?? cf.value?.value ?? cf.value?.label ?? '') || null; + } + return String(cf.value); + } + + async setZahlung(documentId: number, value: string | null) { + const doc = await this.paperlessService.getDocument(documentId); + + const freigabeValue = this.getCfValue(doc, FREIGABE_FIELD_ID); + if (freigabeValue !== FREIGABE_WERT_FREIGEGEBEN) { + throw new ForbiddenException( + 'Zahlung kann nur für freigegebene Belege gesetzt werden', + ); + } + + const customFields: any[] = [...(doc.custom_fields ?? [])]; + const existing = customFields.find((f: any) => f.field === ZAHLUNG_FIELD_ID); + if (existing) { + existing.value = value; + } else if (value !== null && value !== '') { + customFields.push({ field: ZAHLUNG_FIELD_ID, value }); + } + + await this.paperlessService.updateDocument(documentId, { custom_fields: customFields }); + return { success: true }; + } + + async getZahlungOptions(): Promise<{ id: string; label: string }[]> { + const fields = await this.paperlessService.getCustomFields(); + const field = (fields as any[]).find((f: any) => f.id === ZAHLUNG_FIELD_ID); + if (!field) return []; + + const rawOptions: any[] = field.extra_data?.select_options ?? []; + return rawOptions + .filter((o) => o !== null && o !== undefined && o !== '') + .map((o) => { + if (typeof o === 'object') { + return { + id: String(o.id ?? o.value ?? o.label ?? ''), + label: String(o.label ?? o.name ?? o.id ?? ''), + }; + } + return { id: String(o), label: String(o) }; + }) + .filter((o) => o.id !== ''); + } +} diff --git a/paperless-frontend/src/App.tsx b/paperless-frontend/src/App.tsx index 45cd76b..21bdd9f 100644 --- a/paperless-frontend/src/App.tsx +++ b/paperless-frontend/src/App.tsx @@ -21,6 +21,7 @@ const UserSettingsPage = lazy(() => import('./pages/UserSettingsPage')); const LoginPage = lazy(() => import('./pages/LoginPage')); const DashboardPage = lazy(() => import('./pages/DashboardPage')); const FreigabePage = lazy(() => import('./pages/FreigabePage')); +const ZahlungPage = lazy(() => import('./pages/ZahlungPage')); import { Permission } from './auth/permissions'; function UnauthorizedPage() { @@ -133,6 +134,7 @@ function ThemedApp() { } /> } /> } /> + } /> } /> diff --git a/paperless-frontend/src/api/zahlung.ts b/paperless-frontend/src/api/zahlung.ts new file mode 100644 index 0000000..3f33137 --- /dev/null +++ b/paperless-frontend/src/api/zahlung.ts @@ -0,0 +1,40 @@ +import api from './client'; + +export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle'; + +export interface ZahlungDocument { + id: number; + title: string; + created: string; + created_date: string; + correspondent: number | null; + document_type: number | null; + archive_serial_number: number | null; + tags: number[]; + custom_fields: { field: number; value: any }[]; +} + +export interface ZahlungOption { + id: string; + label: string; +} + +export interface ZahlungResult { + count: number; + results: ZahlungDocument[]; +} + +export const zahlungApi = { + getDocuments: (page = 1, pageSize = 25, filter: ZahlungFilter = 'ausstehend') => + api + .get('/api/zahlung/documents', { params: { page, pageSize, filter } }) + .then((r) => r.data), + + setZahlung: (docId: number, value: string | null) => + api + .put<{ success: boolean }>(`/api/zahlung/documents/${docId}/zahlung`, { value }) + .then((r) => r.data), + + getOptions: () => + api.get('/api/zahlung/options').then((r) => r.data), +}; diff --git a/paperless-frontend/src/auth/permissions.ts b/paperless-frontend/src/auth/permissions.ts index ed90aa4..518f0e6 100644 --- a/paperless-frontend/src/auth/permissions.ts +++ b/paperless-frontend/src/auth/permissions.ts @@ -6,6 +6,7 @@ export const Permission = { VIEW_SCANNER: 'VIEW_SCANNER', MANAGE_SETTINGS: 'MANAGE_SETTINGS', VIEW_FREIGABE: 'VIEW_FREIGABE', + VIEW_ZAHLUNG: 'VIEW_ZAHLUNG', } as const; export type Permission = typeof Permission[keyof typeof Permission]; @@ -26,6 +27,7 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.MANAGE_SETTINGS); permissions.add(Permission.VIEW_FREIGABE); + permissions.add(Permission.VIEW_ZAHLUNG); return Array.from(permissions); } @@ -44,6 +46,9 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per if (groups.includes('PM_Freigabe')) { permissions.add(Permission.VIEW_FREIGABE); } + if (groups.includes('PM_Zahlung')) { + permissions.add(Permission.VIEW_ZAHLUNG); + } return Array.from(permissions); } diff --git a/paperless-frontend/src/layouts/AppLayout.tsx b/paperless-frontend/src/layouts/AppLayout.tsx index 2cfc2c7..7e1294b 100644 --- a/paperless-frontend/src/layouts/AppLayout.tsx +++ b/paperless-frontend/src/layouts/AppLayout.tsx @@ -14,6 +14,7 @@ import { AppstoreOutlined, GlobalOutlined, CheckCircleOutlined, + EuroOutlined, } from '@ant-design/icons'; import { useAuth } from '../auth/AuthContext'; import { useTheme } from '../theme/ThemeContext'; @@ -40,6 +41,7 @@ const allMenuItems: MenuItemDef[] = [ { key: '/mailpostfach', icon: , label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' }, { key: 'agrarmonitor', icon: , label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' }, { key: '/freigabe', icon: , label: 'Freigabe', permission: Permission.VIEW_FREIGABE }, + { key: '/zahlung', icon: , label: 'Zahlung', permission: Permission.VIEW_ZAHLUNG }, { key: '/settings', icon: , label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS }, ]; diff --git a/paperless-frontend/src/pages/ZahlungPage.tsx b/paperless-frontend/src/pages/ZahlungPage.tsx new file mode 100644 index 0000000..1062ab7 --- /dev/null +++ b/paperless-frontend/src/pages/ZahlungPage.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Table, Typography, Tag, Button, Modal, Select, message, Space, Radio, Tooltip } from 'antd'; +import { EuroOutlined } from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { zahlungApi, type ZahlungDocument, type ZahlungOption, type ZahlungFilter } from '../api/zahlung'; +import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless'; + +const { Title } = Typography; +const FREIGABE_FIELD_ID = 15; +const ZAHLUNG_FIELD_ID = 16; +const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben'; + +export default function ZahlungPage() { + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [filter, setFilter] = useState('ausstehend'); + + const [docTypes, setDocTypes] = useState([]); + const [correspondents, setCorrespondents] = useState([]); + const [zahlungOptions, setZahlungOptions] = useState([]); + + const [selectedDoc, setSelectedDoc] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [selectedValue, setSelectedValue] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + Promise.all([ + paperlessApi.getDocumentTypes(), + paperlessApi.getCorrespondents(), + zahlungApi.getOptions(), + ]).then(([dts, corrs, opts]) => { + setDocTypes(dts); + setCorrespondents(corrs); + setZahlungOptions(opts); + }); + }, []); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const result = await zahlungApi.getDocuments(page, pageSize, filter); + setData(result.results ?? []); + setTotal(result.count ?? 0); + } catch { + message.error('Fehler beim Laden der Belege'); + } finally { + setLoading(false); + } + }, [page, pageSize, filter]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const getDocTypeName = (id: number | null) => { + if (!id) return '—'; + return docTypes.find((d) => d.id === id)?.name ?? String(id); + }; + + const getCorrespondentName = (id: number | null) => { + if (!id) return '—'; + return correspondents.find((c) => c.id === id)?.name ?? String(id); + }; + + const toCfString = (value: any): string | null => { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'object') return String(value?.id ?? value?.value ?? value?.label ?? '') || null; + return String(value); + }; + + const getCfValue = (doc: ZahlungDocument, fieldId: number) => { + const cf = doc.custom_fields?.find((f) => f.field === fieldId); + return toCfString(cf?.value); + }; + + const renderFreigabeTag = (doc: ZahlungDocument) => { + const val = getCfValue(doc, FREIGABE_FIELD_ID); + if (!val) return Nicht gesetzt; + if (val === FREIGABE_WERT_FREIGEGEBEN) return Freigegeben; + return {val}; + }; + + const renderZahlungTag = (doc: ZahlungDocument) => { + const val = getCfValue(doc, ZAHLUNG_FIELD_ID); + if (!val) return Nicht gesetzt; + const opt = zahlungOptions.find((o) => o.id === val); + return {opt?.label ?? val}; + }; + + const istFreigegeben = (doc: ZahlungDocument) => + getCfValue(doc, FREIGABE_FIELD_ID) === FREIGABE_WERT_FREIGEGEBEN; + + const openModal = (doc: ZahlungDocument) => { + setSelectedDoc(doc); + setSelectedValue(getCfValue(doc, ZAHLUNG_FIELD_ID)); + setModalOpen(true); + }; + + const handleZahlung = async () => { + if (!selectedDoc) return; + setSaving(true); + try { + await zahlungApi.setZahlung(selectedDoc.id, selectedValue); + message.success('Zahlung gesetzt'); + setModalOpen(false); + setSelectedDoc(null); + fetchData(); + } catch { + message.error('Fehler beim Speichern der Zahlung'); + } finally { + setSaving(false); + } + }; + + const columns: ColumnsType = [ + { + title: 'Dokumenttyp', + dataIndex: 'document_type', + key: 'doctype', + render: getDocTypeName, + }, + { + title: 'Titel', + dataIndex: 'title', + key: 'title', + ellipsis: true, + }, + { + title: 'Erstellt', + dataIndex: 'created_date', + key: 'created', + width: 110, + render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—', + }, + { + title: 'Absender', + dataIndex: 'correspondent', + key: 'correspondent', + render: getCorrespondentName, + }, + { + title: 'Freigabe', + key: 'freigabe', + width: 130, + render: (_, doc) => renderFreigabeTag(doc), + }, + { + title: 'Zahlung', + key: 'zahlung', + width: 130, + render: (_, doc) => renderZahlungTag(doc), + }, + { + title: '', + key: 'action', + width: 150, + render: (_, doc) => { + const freigegeben = istFreigegeben(doc); + return ( + + + + ); + }, + }, + ]; + + return ( + <> + Zahlung + + + { + setPage(1); + setFilter(e.target.value); + }} + optionType="button" + buttonStyle="solid" + > + Freigegeben, noch nicht bezahlt + Alle freigegebenen + Alle + + + + + dataSource={data} + columns={columns} + rowKey="id" + loading={loading} + size="small" + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: ['25', '50', '100'], + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + showTotal: (t) => `${t} Belege`, + }} + /> + + { setModalOpen(false); setSelectedDoc(null); }} + okText="Speichern" + cancelText="Abbrechen" + confirmLoading={saving} + > +

+ {selectedDoc?.title} +

+