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 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 21:54:09 +02:00
parent 72d199fb3a
commit 37ffc6c13b
13 changed files with 438 additions and 0 deletions
+2
View File
@@ -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 {}
@@ -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);
}
@@ -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;
}
@@ -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();
}
}
@@ -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 {}
@@ -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<DocumentType>,
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<string, any> = {
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<string, any> = {
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 }));
}
}