Merge pull request 'handover pdf' (#1) from uebergabe-export into master

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-03-12 11:42:42 +01:00
20 changed files with 2891 additions and 33 deletions

2363
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1007.0",
"@nestjs-modules/mailer": "2.0.2",
"@nestjs/axios": "4.0.1",
"@nestjs/cache-manager": "3.1.0",
@@ -35,6 +36,7 @@
"class-transformer": "^0.5.1",
"class-validator": "0.14.1",
"mysql2": "3.18.2",
"puppeteer": "^24.39.0",
"reflect-metadata": "^0.2.2",
"rxjs": "7.8.2",
"typeorm": "0.3.28"

View File

@@ -16,6 +16,7 @@ import { APP_INTERCEPTOR } from '@nestjs/core';
import { MailModule } from './modules/mail/mail.module';
import { LogModule } from './modules/log/log.module';
import { SseModule } from './modules/realtime/sse/sse.module';
import { PdfModule } from './shared/pdf/pdf.module';
@Module({
imports: [
@@ -34,7 +35,8 @@ import { SseModule } from './modules/realtime/sse/sse.module';
SystemModule,
MailModule,
LogModule,
SseModule
SseModule,
PdfModule
],
controllers: [AppController],
providers: [

View File

@@ -0,0 +1,17 @@
export class KeyHandoverDto {
handoverId!: string;
handoverDate!: Date;
place!: string;
giverName!: string;
giverAddress?: string;
receiverName!: string;
receiverAddress?: string;
keyType!: string;
keyNumber?: string;
quantity!: number;
objectDescription?: string;
notes?: string;
}

View File

@@ -33,6 +33,10 @@ export class KeyHandout {
@ManyToOne(() => User)
user: User;
@Column({ nullable: true })
pdfFormKey: string;
@BeforeInsert()
insertTimestamp() {
if (this.timestamp == null) {

View File

@@ -7,19 +7,23 @@ import {
Post,
Put,
Req,
Res,
Sse,
UseGuards,
} from '@nestjs/common';
import { Response } from 'express';
import { KeyService } from './key.service';
import { AuthenticatedRequest } from 'src/model/interface/authenticated-request.interface';
import { AuthGuard } from 'src/core/guards/auth.guard';
import { Key } from 'src/model/entitites';
import { interval, map, Observable } from 'rxjs';
import { KeyHandoverPdfService } from 'src/shared/pdf/key-handover-pdf/key-handover-pdf.service';
import { KeyHandoverDto } from 'src/model/dto/key-handover.dto';
@Controller('key')
export class KeyController {
constructor(private service: KeyService) {}
constructor(private service: KeyService, private pdfService: KeyHandoverPdfService) {}
@UseGuards(AuthGuard)
@Get()
@@ -78,4 +82,15 @@ export class KeyController {
getArchive(@Req() req: AuthenticatedRequest) {
return this.service.getDeletedKeys(req.user);
}
@Post('pdf')
async generatePdf(@Body() dto: KeyHandoverDto, @Res() res: Response): Promise<void> {
const pdfBuffer = await this.pdfService.generatePdf(dto);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="schluesseluebergabe.pdf"');
res.setHeader('Content-Length', pdfBuffer.length);
res.end(pdfBuffer);
}
}

View File

@@ -7,10 +7,11 @@ import { SharedServiceModule } from 'src/shared/service/shared.service.module';
import { ConfigService } from '@nestjs/config';
import { MailModule } from '../mail/mail.module';
import { SseModule } from '../realtime/sse/sse.module';
import { PdfModule } from 'src/shared/pdf/pdf.module';
@Module({
controllers: [KeyController],
providers: [KeyService, ConfigService],
imports: [DatabaseModule, AuthModule, SharedServiceModule, MailModule, SseModule],
imports: [DatabaseModule, AuthModule, SharedServiceModule, MailModule, SseModule, PdfModule],
})
export class KeyModule {}

View File

@@ -0,0 +1,248 @@
import { Injectable } from '@nestjs/common';
import { KeyHandoverDto } from 'src/model/dto/key-handover.dto';
import * as puppeteer from 'puppeteer';
import { MinioService } from '../minio/minio.service';
import { KeyHandoutRepository } from 'src/model/repositories/key-handout.repository';
import { KeyHandout } from 'src/model/entitites';
@Injectable()
export class KeyHandoverPdfService {
constructor(private readonly minioService: MinioService, private handoverRepo: KeyHandoutRepository) {}
async generatePdf(dto: KeyHandoverDto): Promise<Buffer> {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
try {
const page = await browser.newPage();
const html = this.buildHtml(dto);
await page.setContent(html, {
waitUntil: 'networkidle0',
});
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm',
},
displayHeaderFooter: true,
headerTemplate: `<div></div>`,
footerTemplate: `
<div style="
width: 100%;
font-size: 10px;
padding: 0 20px;
color: #666;
display: flex;
justify-content: space-between;
">
<span>Übergabeprotokoll</span>
<span>${new Date().toLocaleString()}</span>
</div>
`,
});
const buffer = Buffer.from(pdf);
const bucket = 'keyvault-pro-develop';
const key = `handover-forms/${Date.now()}-schluesseluebergabe.pdf`;
await this.minioService.uploadPdf(bucket, key, buffer);
await this.saveKeyToHandover(dto.handoverId, key)
return buffer;
} finally {
await browser.close();
}
}
private async saveKeyToHandover(handoverId: string, fileKey: string) {
const handover: KeyHandout = await this.handoverRepo.findOneBy({ id: handoverId });
if (!handover) { return; }
handover.pdfFormKey = fileKey;
this.handoverRepo.save(handover);
}
private buildHtml(dto: KeyHandoverDto): string {
return `
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Übergabeprotokoll</title>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
font-size: 12px;
color: #222;
margin: 0;
padding: 0;
}
h1 {
text-align: center;
margin: 0 0 24px 0;
font-size: 22px;
}
.section {
margin-bottom: 18px;
}
.section-title {
font-weight: bold;
font-size: 14px;
margin-bottom: 8px;
border-bottom: 1px solid #ccc;
padding-bottom: 4px;
}
.row {
margin-bottom: 6px;
}
.label {
display: inline-block;
width: 170px;
font-weight: bold;
vertical-align: top;
}
.value {
display: inline-block;
max-width: 360px;
word-break: break-word;
}
.note-box {
min-height: 60px;
border: 1px solid #ccc;
padding: 8px;
margin-top: 6px;
white-space: pre-wrap;
}
.declaration {
margin-top: 24px;
line-height: 1.5;
}
.signature-wrapper {
margin-top: 60px;
display: flex;
justify-content: space-between;
gap: 40px;
}
.signature-block {
width: 45%;
}
.signature-line {
border-top: 1px solid #000;
margin-top: 50px;
padding-top: 6px;
text-align: center;
}
</style>
</head>
<body>
<h1>Übergabeprotokoll</h1>
<div class="section">
<div class="section-title">Übergabedaten</div>
<div class="row">
<span class="label">Datum der Übergabe:</span>
<span class="value">${this.escapeHtml(new Date(dto.handoverDate).toLocaleDateString())}</span>
</div>
<div class="row" style="display: none">
<span class="label">Ort:</span>
<span class="value">${this.escapeHtml(dto.place)}</span>
</div>
</div>
<div class="section">
<div class="section-title">Übergeber</div>
<div class="row">
<span class="label">Name:</span>
<span class="value">${this.escapeHtml(dto.giverName)}</span>
</div>
<div class="row">
<span class="label">Adresse:</span>
<span class="value">${this.escapeHtml(dto.giverAddress ?? '-')}</span>
</div>
</div>
<div class="section">
<div class="section-title">Empfänger</div>
<div class="row">
<span class="label">Name:</span>
<span class="value">${this.escapeHtml(dto.receiverName)}</span>
</div>
<div class="row">
<span class="label">Adresse:</span>
<span class="value">${this.escapeHtml(dto.receiverAddress ?? '-')}</span>
</div>
</div>
<div class="section">
<div class="section-title">Schlüsselangaben</div>
<div class="row" style="display: none;">
<span class="label">Schlüsselart:</span>
<span class="value">${this.escapeHtml(dto.keyType)}</span>
</div>
<div class="row">
<span class="label">Schlüsselnummer:</span>
<span class="value">${this.escapeHtml(dto.keyNumber ?? '-')}</span>
</div>
<div class="row" style="display: none;">
<span class="label">Anzahl:</span>
<span class="value">${dto.quantity}</span>
</div>
<div class="row">
<span class="label">Objekt / Raum:</span>
<span class="value">${this.escapeHtml(dto.objectDescription ?? '-')}</span>
</div>
<div class="row">
<span class="label">Bemerkungen:</span>
</div>
<div class="note-box">${this.escapeHtml(dto.notes ?? '-')}</div>
</div>
<div class="declaration">
Hiermit bestätigt der Empfänger den Erhalt der oben aufgeführten Schlüssel.
<br />
Mit ihrer Unterschrift bestätigen beide Parteien die ordnungsgemäße Übergabe.
</div>
<div class="signature-wrapper">
<div class="signature-block">
<div class="signature-line">Unterschrift Übergeber</div>
</div>
<div class="signature-block">
<div class="signature-line">Unterschrift Empfänger</div>
</div>
</div>
</body>
</html>
`;
}
private escapeHtml(value: string): string {
if (!value) { return ""; }
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class MinioService {
constructor(private configService: ConfigService) {}
private readonly client = new S3Client({
region: 'us-east-1',
endpoint: this.configService.get('MINIOHOST'),
credentials: {
accessKeyId: this.configService.get('MINIOUSER'),
secretAccessKey: this.configService.get('MINIOACCESSKEY'),
},
forcePathStyle: true,
});
async uploadPdf(bucket: string, key: string, pdfBuffer: Buffer): Promise<void> {
await this.client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: pdfBuffer,
ContentType: 'application/pdf',
}),
);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { KeyHandoverPdfService } from './key-handover-pdf/key-handover-pdf.service';
import { MinioService } from './minio/minio.service';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from '../database/database.module';
@Module({
providers: [KeyHandoverPdfService, MinioService],
imports: [ConfigModule, DatabaseModule],
exports: [KeyHandoverPdfService]
})
export class PdfModule {}

View File

@@ -0,0 +1,17 @@
export interface IKeyHandoverPDF {
handoverId: string;
handoverDate: Date;
place: string;
giverName: string;
giverAddress?: string;
receiverName: string;
receiverAddress?: string;
keyType: string;
keyNumber?: string;
quantity: number;
objectDescription?: string;
notes?: string;
}

View File

@@ -0,0 +1,54 @@
<h2 mat-dialog-title>Übergabeprotokoll</h2>
<mat-dialog-content>
<div class="text" style="margin: 0 0 24px 0;">
Fülle alle Daten aus und klicke auf PDF erzeugen. Das PDF wird anschließend heruntergeladen.
</div>
<form [formGroup]="handoverPDFForm" class="flex flex-col items-stretch justify-stretch">
<div class="flex-row">
<mat-form-field style="flex: 1 1 auto;">
<mat-label>Herausgeber</mat-label>
<input type="text" matInput formControlName="giverName">
</mat-form-field>
<mat-form-field style="flex: 1 1 auto;">
<mat-label>Empfänger</mat-label>
<input type="text" matInput formControlName="receiverName">
</mat-form-field>
</div>
<div class="flex-row">
<mat-form-field style="flex: 1 1 auto;">
<mat-label>Adresse des Herausgebendens</mat-label>
<input type="text" matInput formControlName="giverAddress">
</mat-form-field>
<mat-form-field style="flex: 1 1 auto;">
<mat-label>Adresse des Empfängers</mat-label>
<input type="text" matInput formControlName="receiverAddress">
</mat-form-field>
</div>
<mat-form-field>
<mat-label>Objektbeschreibung / Schließanlage</mat-label>
<input type="text" matInput formControlName="objectDescription">
</mat-form-field>
<mat-form-field>
<mat-label>Notizen</mat-label>
<textarea type="text" matInput formControlName="notes" rows="5"></textarea>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions>
<button matButton mat-dialog-close class="btn-warning">Schließen</button>
<button matButton="elevated" (click)="createPdf()" class="btn-primary">
<mat-icon>save</mat-icon>
PDF erzeugen
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CreateHandoverPdfDialogComponent } from './create-handover-pdf-dialog.component';
describe('CreateHandoverPdfDialogComponent', () => {
let component: CreateHandoverPdfDialogComponent;
let fixture: ComponentFixture<CreateHandoverPdfDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CreateHandoverPdfDialogComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CreateHandoverPdfDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,65 @@
import { Component, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { ApiService } from '../../../../shared/api.service';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { IKeyHandoverPDF } from '../../../../model/interface/handover-pdf.interface';
@Component({
selector: 'app-create-handover-pdf-dialog',
imports: [MatDialogModule, FormsModule, ReactiveFormsModule, CommonModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule],
templateUrl: './create-handover-pdf-dialog.component.html',
styleUrl: './create-handover-pdf-dialog.component.scss',
})
export class CreateHandoverPdfDialogComponent {
private api: ApiService = inject(ApiService);
readonly dialogRef = inject(MatDialogRef<CreateHandoverPdfDialogComponent>);
readonly data = inject<any>(MAT_DIALOG_DATA);
handoverPDFForm = new FormGroup({
handoverDate: new FormControl<Date>(new Date(), Validators.required),
place: new FormControl<string | null>(null, Validators.required),
giverName: new FormControl<string | null>(null, Validators.required),
giverAddress: new FormControl<string | null>(null),
receiverName: new FormControl<string | null>(null, Validators.required),
receiverAddress: new FormControl<string | null>(null),
objectDescription: new FormControl<string | null>(null),
notes: new FormControl<string | null>(null)
});
ngOnInit() {
console.log(this.data)
this.patchInitialData();
}
patchInitialData() {
const dto = {
handoverDate: this.data.timestamp,
receiverName: this.data.customer.name,
receiverAddress: this.data.customer.address,
giverName: `${this.api.user.value.firstName} ${this.api.user.value.lastName}`
}
this.handoverPDFForm.patchValue(dto);
}
async createPdf() {
const dto: IKeyHandoverPDF = {
...this.handoverPDFForm.value as any,
quantity: 1,
keyType: "",
keyNumber: this.data.key.nr,
handoverId: this.data.handoverId
}
await this.api.createPdf(dto)
this.dialogRef.close();
}
}

View File

@@ -40,6 +40,13 @@
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
<div style="padding: 8px 0;margin-top: 12px">
<mat-slide-toggle formControlName="handoverDocument">
Übergabeprotokoll anlegen
</mat-slide-toggle>
</div>
</form>
</mat-dialog-content>
<mat-dialog-actions>

View File

@@ -1,7 +1,7 @@
import { Component, inject, LOCALE_ID, signal } from '@angular/core';
import { ApiService } from '../../../../shared/api.service';
import { IKey } from '../../../../model/interface/key.interface';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule, MatDialog } from '@angular/material/dialog';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -27,10 +27,12 @@ import { AgGridAngular } from 'ag-grid-angular';
import { MatIconModule } from '@angular/material/icon';
import { AgGridContainerComponent } from '../../../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
import { ICustomer } from '../../../../model/interface/customer.interface';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { CreateHandoverPdfDialogComponent } from '../create-handover-pdf-dialog/create-handover-pdf-dialog.component';
@Component({
selector: 'app-handover-dialog',
imports: [FormsModule, MatTabsModule, AgGridAngular, ReactiveFormsModule, MatDatepickerModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatDialogModule, MatAutocompleteModule, MatProgressSpinnerModule, MatRadioModule, AsyncPipe, MatIconModule, DatePipe],
imports: [FormsModule, MatTabsModule, AgGridAngular, ReactiveFormsModule, MatDatepickerModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatDialogModule, MatAutocompleteModule, MatProgressSpinnerModule, MatRadioModule, AsyncPipe, MatIconModule, DatePipe, MatSlideToggleModule],
providers: [
provideNativeDateAdapter(),
{ provide: LOCALE_ID, useValue: 'de-DE' },
@@ -48,6 +50,7 @@ export class HandoverDialogComponent extends AgGridContainerComponent {
private _bottomSheet = inject(MatBottomSheet);
private datePipe = inject(DatePipe);
private toast: HotToastService = inject(HotToastService);
private dialog = inject(MatDialog)
public exampleDate = new Date();
@@ -90,7 +93,8 @@ export class HandoverDialogComponent extends AgGridContainerComponent {
customer: new FormControl<any>(null, Validators.required),
key: new FormControl(this.data),
direction: new FormControl('out', Validators.required),
timestamp: new FormControl(new Date(), Validators.required)
timestamp: new FormControl(new Date(), Validators.required),
handoverDocument: new FormControl<boolean>(false)
});
ngOnInit() {
@@ -148,7 +152,8 @@ export class HandoverDialogComponent extends AgGridContainerComponent {
key: this.data,
customer: this.customers.find(c => c.name == val.customer || c.id == val.customer),
timestamp: val.timestamp,
direction: val.direction
direction: val.direction,
createHandoverPDF: val.handoverDocument
}
if (dto.customer == null) {
@@ -175,8 +180,17 @@ export class HandoverDialogComponent extends AgGridContainerComponent {
})
)
.subscribe({
next: n => {
next: (n: any) => {
this.dialogRef.close(data.direction == 'out');
if (data.createHandoverPDF) {
data.handoverId = n.id
this.dialog.open(CreateHandoverPdfDialogComponent, {
data,
maxWidth: "calc(100vw - 48px)",
width: "900px",
disableClose: true
})
}
}
})
}

View File

@@ -19,6 +19,7 @@ import { SelectKeyCylinderComponent } from './create/select-key-cylinder/select-
import { ActivatedRoute } from '@angular/router';
import { ModuleRegistry } from 'ag-grid-community';
import { AgGridContainerComponent } from '../../shared/ag-grid/components/ag-grid-container/ag-grid-container.component';
import { CreateHandoverPdfDialogComponent } from './components/create-handover-pdf-dialog/create-handover-pdf-dialog.component';
@Component({
selector: 'app-keys',

View File

@@ -7,6 +7,7 @@ import { ICylinder } from '../model/interface/cylinder.interface';
import { HotToastService } from '@ngxpert/hot-toast';
import { ICustomer } from '../model/interface/customer.interface';
import { ISystem } from '../model/interface/keysystem.interface';
import { IKeyHandoverPDF } from '../model/interface/handover-pdf.interface';
@Injectable({
providedIn: 'root'
@@ -26,9 +27,10 @@ export class ApiService {
constructor() {
setTimeout(() => {
this.setupKeyFeed();
}, 2000)
// setTimeout(() => {
// this.setupKeyFeed();
// this.createPdf()
// }, 2000)
}
getMe(): Promise<IUser> {
@@ -455,4 +457,23 @@ export class ApiService {
})
})
}
createPdf(data: IKeyHandoverPDF) {
return new Promise(resolve => {
this.http.post('/api/key/pdf', data, {
responseType: 'blob',
}).subscribe((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'schluesseluebergabe.pdf';
a.click();
URL.revokeObjectURL(url);
resolve(null)
});
})
}
}

View File

@@ -227,4 +227,10 @@ div.ag-row {
// border-top-right-radius: 0 !important;
// border-bottom-right-radius: 0 !important;
color: #2D6B05;
}
.flex-row {
display: flex;
justify-content: space-between;
gap: 12px;
}