| @@ -2886,6 +2886,7 @@ components: | |||||
| - firstName | - firstName | ||||
| - referenceId | - referenceId | ||||
| - lastName | - lastName | ||||
| - active | |||||
| User.jsonld: | User.jsonld: | ||||
| type: object | type: object | ||||
| description: '' | description: '' | ||||
| @@ -2973,6 +2974,7 @@ components: | |||||
| - firstName | - firstName | ||||
| - referenceId | - referenceId | ||||
| - lastName | - lastName | ||||
| - active | |||||
| UserTrip: | UserTrip: | ||||
| type: object | type: object | ||||
| description: '' | description: '' | ||||
| @@ -0,0 +1,136 @@ | |||||
| import { Directive, ViewChild } from '@angular/core'; | |||||
| import { AbstractDataFormComponent, FormSubmitEvent } from "@app/_components/_abstract/abstract-data-form-component"; | |||||
| import { FormGroup } from '@angular/forms'; | |||||
| import { ImageUploadComponent } from '@app/_components/image-upload/image-upload.component'; | |||||
| import { ImageUploadService } from '@app/_services/image-upload.service'; | |||||
| import { ModalStatus } from '@app/_helpers/modal.states'; | |||||
| @Directive() | |||||
| export abstract class AbstractImageFormComponent<T extends { [key: string]: any }> extends AbstractDataFormComponent<T> { | |||||
| // Gemeinsame Eigenschaften für Komponenten mit Bildupload | |||||
| selectedFile: File | null = null; | |||||
| imageToDelete: string | null = null; | |||||
| @ViewChild('imageUpload') imageUpload!: ImageUploadComponent; | |||||
| // Abstrakte Eigenschaften, die von abgeleiteten Klassen implementiert werden müssen | |||||
| abstract get imageIriControlName(): string; | |||||
| abstract get imageDbId(): string | number | undefined | null; | |||||
| constructor( | |||||
| protected imageUploadService: ImageUploadService, | |||||
| formGroup: FormGroup, | |||||
| createFunction: ((data: T) => any) | undefined, | |||||
| updateFunction: (id: string | number, data: T) => any, | |||||
| deleteFunction: (id: string | number) => any, | |||||
| ...args: any[] | |||||
| ) { | |||||
| super(formGroup, createFunction, updateFunction, deleteFunction, ...args); | |||||
| this.submit.subscribe((event: FormSubmitEvent<T>) => { | |||||
| if (event.status === ModalStatus.Submitted) { | |||||
| this.resetImageStatus(); | |||||
| } | |||||
| }); | |||||
| } | |||||
| handleFileSelected(file: File): void { | |||||
| this.selectedFile = file; | |||||
| } | |||||
| handleFileDeleted(): void { | |||||
| // Speichere die aktuelle imageIri, um sie später zu löschen | |||||
| this.imageToDelete = this.form.get(this.imageIriControlName)?.value; | |||||
| // Aktualisiere den Formularwert | |||||
| const patchValue: any = {}; | |||||
| patchValue[this.imageIriControlName] = null; | |||||
| this.form.patchValue(patchValue); | |||||
| // Falls eine Datei zum Hochladen ausgewählt wurde, entferne diese auch | |||||
| this.selectedFile = null; | |||||
| } | |||||
| resetImageStatus(): void { | |||||
| if (this.imageUpload) { | |||||
| this.imageUpload.reset(); | |||||
| } | |||||
| // Zurücksetzen der temporären Variablen | |||||
| this.imageToDelete = null; | |||||
| this.selectedFile = null; | |||||
| } | |||||
| override onSubmit(): void { | |||||
| if (!this.form.valid) { | |||||
| return; | |||||
| } | |||||
| // Drei Fälle: | |||||
| // 1. Ein neues Bild wurde ausgewählt | |||||
| // 2. Ein bestehendes Bild soll gelöscht werden | |||||
| // 3. Keine Änderungen am Bild | |||||
| if (this.selectedFile && this.imageToDelete) { | |||||
| // Fall 1a: Ein neues Bild wurde ausgewählt UND ein altes soll gelöscht werden | |||||
| // Zuerst das alte Bild löschen, dann das neue hochladen | |||||
| const dbId = this.imageDbId; | |||||
| if (dbId) { | |||||
| this.imageUploadService.deleteImage(dbId).subscribe({ | |||||
| next: (success) => { | |||||
| // Altes Bild wurde gelöscht, jetzt neues hochladen | |||||
| this.uploadNewFile(); | |||||
| }, | |||||
| error: () => { | |||||
| // Fehlerbehandlung bereits im Service, trotzdem neues Bild hochladen | |||||
| this.uploadNewFile(); | |||||
| } | |||||
| }); | |||||
| } else { | |||||
| this.uploadNewFile(); | |||||
| } | |||||
| } else if (this.selectedFile) { | |||||
| // Fall 1b: Nur ein neues Bild wurde ausgewählt (kein altes vorhanden) | |||||
| this.uploadNewFile(); | |||||
| } else if (this.imageToDelete && this.imageDbId) { | |||||
| // Fall 2: Nur ein bestehendes Bild soll gelöscht werden | |||||
| this.imageUploadService.deleteImage(this.imageDbId).subscribe({ | |||||
| next: (success) => { | |||||
| this.imageToDelete = null; | |||||
| super.onSubmit(); | |||||
| }, | |||||
| error: () => { | |||||
| // Fehlerbehandlung bereits im Service | |||||
| super.onSubmit(); | |||||
| } | |||||
| }); | |||||
| } else { | |||||
| // Fall 3: Keine Änderungen am Bild | |||||
| super.onSubmit(); | |||||
| } | |||||
| } | |||||
| private uploadNewFile(): void { | |||||
| if (!this.selectedFile) return; | |||||
| this.imageUploadService.uploadImage(this.selectedFile).subscribe({ | |||||
| next: (imageId) => { | |||||
| if (imageId) { | |||||
| // Aktualisiere die Formulardaten mit dem neuen mediaObject | |||||
| const patchValue: any = {}; | |||||
| patchValue[this.imageIriControlName] = imageId; | |||||
| this.form.patchValue(patchValue); | |||||
| // Rufe die übergeordnete Methode auf, um den Standard-Speicherprozess zu verarbeiten | |||||
| super.onSubmit(); | |||||
| } else { | |||||
| this.submit.emit({ | |||||
| status: ModalStatus.Cancelled, | |||||
| data: null | |||||
| }); | |||||
| } | |||||
| } | |||||
| }); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,19 @@ | |||||
| <div class="mb-3"> | |||||
| <label [for]="fieldId" class="form-label">{{ label }}:</label> | |||||
| <!-- File-Input ist deaktiviert, wenn ein Bild existiert und nicht zum Löschen markiert ist --> | |||||
| <input type="file" class="form-control" [id]="fieldId" | |||||
| [disabled]="disabled || (imageUrl && showImage)" | |||||
| (change)="onFileSelected($event)"/> | |||||
| @if (selectedFile) { | |||||
| <small class="text-muted">{{ selectedFile.name }}</small> | |||||
| } | |||||
| @if (imageUrl && showImage) { | |||||
| <div class="mt-1 d-flex align-items-start gap-2"> | |||||
| <img [src]="imageUrl" [alt]="label" class="img-fluid" /> | |||||
| <button type="button" class="btn btn-sm btn-danger" (click)="deleteImage()">X</button> | |||||
| </div> | |||||
| } | |||||
| </div> | |||||
| @@ -0,0 +1,23 @@ | |||||
| import { ComponentFixture, TestBed } from '@angular/core/testing'; | |||||
| import { ImageUploadComponent } from './image-upload.component'; | |||||
| describe('ImageUploadComponent', () => { | |||||
| let component: ImageUploadComponent; | |||||
| let fixture: ComponentFixture<ImageUploadComponent>; | |||||
| beforeEach(async () => { | |||||
| await TestBed.configureTestingModule({ | |||||
| declarations: [ImageUploadComponent] | |||||
| }) | |||||
| .compileComponents(); | |||||
| fixture = TestBed.createComponent(ImageUploadComponent); | |||||
| component = fixture.componentInstance; | |||||
| fixture.detectChanges(); | |||||
| }); | |||||
| it('should create', () => { | |||||
| expect(component).toBeTruthy(); | |||||
| }); | |||||
| }); | |||||
| @@ -0,0 +1,38 @@ | |||||
| import { Component, EventEmitter, Input, Output } from '@angular/core'; | |||||
| @Component({ | |||||
| selector: 'app-image-upload', | |||||
| templateUrl: './image-upload.component.html', | |||||
| styleUrl: './image-upload.component.scss' | |||||
| }) | |||||
| export class ImageUploadComponent { | |||||
| @Input() imageUrl: string | null | undefined = null; | |||||
| @Input() label: string = 'Bild'; | |||||
| @Input() disabled: boolean = false; | |||||
| @Input() fieldId: string = 'mediaFile'; | |||||
| @Output() fileSelected = new EventEmitter<File>(); | |||||
| @Output() fileDeleted = new EventEmitter<void>(); | |||||
| selectedFile: File | null = null; | |||||
| showImage: boolean = true; | |||||
| onFileSelected(event: Event): void { | |||||
| const element = event.target as HTMLInputElement; | |||||
| if (element.files && element.files.length > 0) { | |||||
| this.selectedFile = element.files[0]; | |||||
| this.fileSelected.emit(this.selectedFile); | |||||
| } | |||||
| } | |||||
| deleteImage(): void { | |||||
| this.showImage = false; | |||||
| this.fileDeleted.emit(); | |||||
| this.selectedFile = null; | |||||
| } | |||||
| reset(): void { | |||||
| this.selectedFile = null; | |||||
| this.showImage = this.imageUrl !== null; | |||||
| } | |||||
| } | |||||
| @@ -123,7 +123,7 @@ export const userForm = new FormGroup({ | |||||
| imageUrl: new FormControl(null, []), | imageUrl: new FormControl(null, []), | ||||
| fullName: new FormControl(null, []), | fullName: new FormControl(null, []), | ||||
| password: new FormControl(null, []), | password: new FormControl(null, []), | ||||
| active: new FormControl(null, []), | |||||
| active: new FormControl(null, [Validators.required]), | |||||
| roles: new FormControl(null, []), | roles: new FormControl(null, []), | ||||
| createdAt: new FormControl(null, []) | createdAt: new FormControl(null, []) | ||||
| }); | }); | ||||
| @@ -139,7 +139,7 @@ export const userJsonldForm = new FormGroup({ | |||||
| imageUrl: new FormControl(null, []), | imageUrl: new FormControl(null, []), | ||||
| fullName: new FormControl(null, []), | fullName: new FormControl(null, []), | ||||
| password: new FormControl(null, []), | password: new FormControl(null, []), | ||||
| active: new FormControl(null, []), | |||||
| active: new FormControl(null, [Validators.required]), | |||||
| roles: new FormControl(null, []), | roles: new FormControl(null, []), | ||||
| createdAt: new FormControl(null, []) | createdAt: new FormControl(null, []) | ||||
| }); | }); | ||||
| @@ -0,0 +1,41 @@ | |||||
| import { Injectable } from '@angular/core'; | |||||
| import { MediaObjectService } from '@app/core/api/v1'; | |||||
| import { Observable, catchError, map, of } from 'rxjs'; | |||||
| @Injectable({ | |||||
| providedIn: 'root' | |||||
| }) | |||||
| export class ImageUploadService { | |||||
| constructor(private mediaObjectService: MediaObjectService) { } | |||||
| /** | |||||
| * Lädt ein neues Bild hoch | |||||
| * @param file Die hochzuladende Datei | |||||
| * @returns Observable mit der ID des hochgeladenen Bildes | |||||
| */ | |||||
| uploadImage(file: File): Observable<string | null | undefined> { | |||||
| return this.mediaObjectService.mediaObjectsPost(file).pipe( | |||||
| map(mediaObject => mediaObject.id), | |||||
| catchError(error => { | |||||
| console.error('Fehler beim Hochladen der Datei:', error); | |||||
| return of(null); | |||||
| }) | |||||
| ); | |||||
| } | |||||
| /** | |||||
| * Löscht ein vorhandenes Bild | |||||
| * @param id Die ID des zu löschenden Bildes (kann string oder number sein) | |||||
| * @returns Observable, das true zurückgibt, wenn das Löschen erfolgreich war | |||||
| */ | |||||
| deleteImage(id: string | number): Observable<boolean> { | |||||
| return this.mediaObjectService.mediaObjectsIdDelete(id.toString()).pipe( | |||||
| map(() => true), | |||||
| catchError(error => { | |||||
| console.error('Fehler beim Löschen des Bildes:', error); | |||||
| return of(false); | |||||
| }) | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -18,33 +18,21 @@ | |||||
| </label> | </label> | ||||
| </div> | </div> | ||||
| <!-- Neues File-Upload-Feld für MediaObject --> | |||||
| <div class="mb-3"> | |||||
| <label for="mediaFile" class="form-label">{{ 'user_trip.signature' | translate }}:</label> | |||||
| <!-- File-Input ist deaktiviert, wenn ein Bild existiert und nicht zum Löschen markiert ist --> | |||||
| <input type="file" class="form-control" id="mediaFile" | |||||
| [disabled]="data.signatureUrl && showSignatureImage" | |||||
| (change)="onFileSelected($event)"/> | |||||
| @if (selectedFile) { | |||||
| <small class="text-muted">{{ selectedFile.name }}</small> | |||||
| } | |||||
| @if (data.signatureUrl && showSignatureImage) { | |||||
| <div class="mt-1 d-flex align-items-start gap-2"> | |||||
| <img [src]="data.signatureUrl" alt="Signatur" class="img-fluid" /> | |||||
| <button type="button" class="btn btn-sm btn-danger" (click)="markSignatureForRemoval()">X</button> | |||||
| </div> | |||||
| } | |||||
| </div> | |||||
| <!-- Verwende die neue ImageUpload-Komponente --> | |||||
| <app-image-upload | |||||
| #imageUpload | |||||
| [imageUrl]="data.signatureUrl" | |||||
| [label]="'user_trip.signature' | translate" | |||||
| [disabled]="data.completed" | |||||
| (fileSelected)="handleFileSelected($event)" | |||||
| (fileDeleted)="handleFileDeleted()"> | |||||
| </app-image-upload> | |||||
| </form> | </form> | ||||
| } | } | ||||
| </div> | </div> | ||||
| <div class="flex gap-2"> | <div class="flex gap-2"> | ||||
| <!-- <button type="submit" class="btn btn-primary" [disabled]="form.invalid" (click)="onSubmit()">--> | |||||
| <button type="submit" class="btn btn-primary" (click)="onSubmit()"> | <button type="submit" class="btn btn-primary" (click)="onSubmit()"> | ||||
| {{ 'basic.save' | translate }} | {{ 'basic.save' | translate }} | ||||
| </button> | </button> | ||||
| @@ -1,191 +1,51 @@ | |||||
| import { Component } from '@angular/core'; | import { Component } from '@angular/core'; | ||||
| import { | |||||
| AbstractDataFormComponent, FormSubmitEvent, | |||||
| } from "@app/_components/_abstract/abstract-data-form-component"; | |||||
| import { | |||||
| MediaObjectService, | |||||
| UserTripJsonld, | |||||
| UserTripService, | |||||
| } from "@app/core/api/v1"; | |||||
| import { UserTripJsonld, UserTripService } from "@app/core/api/v1"; | |||||
| import { userTripForm } from "@app/_forms/apiForms"; | import { userTripForm } from "@app/_forms/apiForms"; | ||||
| import { TranslateService } from "@ngx-translate/core"; | import { TranslateService } from "@ngx-translate/core"; | ||||
| import { Router } from "@angular/router"; | import { Router } from "@angular/router"; | ||||
| import { ROUTE_USER_TRIPS } from "@app/app-routing.module"; | import { ROUTE_USER_TRIPS } from "@app/app-routing.module"; | ||||
| import { ModalStatus } from "@app/_helpers/modal.states"; | |||||
| import {AppHelperService} from "@app/_helpers/app-helper.service"; | |||||
| import { AppHelperService } from "@app/_helpers/app-helper.service"; | |||||
| import { AbstractImageFormComponent } from "@app/_components/_abstract/abstract-image-form-component"; | |||||
| import { ImageUploadService } from "@app/_services/image-upload.service"; | |||||
| @Component({ | @Component({ | ||||
| selector: 'app-user-trip-form', | selector: 'app-user-trip-form', | ||||
| templateUrl: './user-trip-form.component.html', | templateUrl: './user-trip-form.component.html', | ||||
| styleUrl: './user-trip-form.component.scss' | styleUrl: './user-trip-form.component.scss' | ||||
| }) | }) | ||||
| export class UserTripFormComponent extends AbstractDataFormComponent<UserTripJsonld> { | |||||
| export class UserTripFormComponent extends AbstractImageFormComponent<UserTripJsonld> { | |||||
| protected readonly userTripForm = userTripForm; | protected readonly userTripForm = userTripForm; | ||||
| selectedFile: File | null = null; | |||||
| signatureToDelete: string | null = null; | |||||
| showSignatureImage: boolean = true; | |||||
| // Implementierung der abstrakten Eigenschaften | |||||
| get imageIriControlName(): string { | |||||
| return 'signatureIri'; | |||||
| } | |||||
| get imageDbId(): string | undefined | null | number { | |||||
| return this.data?.signature?.dbId; | |||||
| } | |||||
| constructor( | constructor( | ||||
| private userTripService: UserTripService, | private userTripService: UserTripService, | ||||
| private mediaObjectService: MediaObjectService, | |||||
| imageUploadService: ImageUploadService, | |||||
| private appHelperService: AppHelperService, | private appHelperService: AppHelperService, | ||||
| translateService: TranslateService, | translateService: TranslateService, | ||||
| router: Router | router: Router | ||||
| ) { | ) { | ||||
| super( | super( | ||||
| imageUploadService, | |||||
| userTripForm, | userTripForm, | ||||
| undefined, | undefined, | ||||
| (id: string | number, data: UserTripJsonld) => | (id: string | number, data: UserTripJsonld) => | ||||
| this.userTripService.userTripsIdPatch( | |||||
| userTripService.userTripsIdPatch( | |||||
| id.toString(), | id.toString(), | ||||
| this.appHelperService.convertJsonldToJson(data) | |||||
| appHelperService.convertJsonldToJson(data) | |||||
| ), | ), | ||||
| (id: string | number) => this.userTripService.userTripsIdDelete(id.toString()), | |||||
| (id: string | number) => userTripService.userTripsIdDelete(id.toString()), | |||||
| translateService, | translateService, | ||||
| router | router | ||||
| ); | ); | ||||
| this.redirectAfterDelete = '/' + ROUTE_USER_TRIPS; | this.redirectAfterDelete = '/' + ROUTE_USER_TRIPS; | ||||
| } | } | ||||
| override ngOnInit(): void { | |||||
| super.ngOnInit(); | |||||
| this.submit.subscribe((event: FormSubmitEvent<UserTripJsonld>) => { | |||||
| if (event.status === ModalStatus.Submitted) { | |||||
| this.updateImageStatus(); | |||||
| } | |||||
| }); | |||||
| // Debug-Ausgabe für Formularvalidierung | |||||
| // this.form.statusChanges.subscribe(status => { | |||||
| // console.log('Form status:', status); | |||||
| // console.log('Form errors:', this.form.errors); | |||||
| // | |||||
| // Object.keys(this.form.controls).forEach(key => { | |||||
| // const control = this.form.get(key); | |||||
| // if (control?.invalid) { | |||||
| // console.log(`Control '${key}' is invalid:`, control.errors); | |||||
| // } | |||||
| // }); | |||||
| // }); | |||||
| } | |||||
| onFileSelected(event: Event): void { | |||||
| const element = event.target as HTMLInputElement; | |||||
| if (element.files && element.files.length > 0) { | |||||
| this.selectedFile = element.files[0]; | |||||
| } | |||||
| } | |||||
| markSignatureForRemoval(): void { | |||||
| // Speichere die aktuelle signatureIri, um sie später zu löschen | |||||
| this.signatureToDelete = this.form.get('signatureIri')?.value; | |||||
| // Aktualisiere den Formularwert | |||||
| this.form.patchValue({ | |||||
| signatureIri: null | |||||
| }); | |||||
| // Verstecke das Bild in der UI | |||||
| this.showSignatureImage = false; | |||||
| // Falls eine Datei zum Hochladen ausgewählt wurde, entferne diese auch | |||||
| this.selectedFile = null; | |||||
| } | |||||
| updateImageStatus(): void { | |||||
| // Wenn das Formular erfolgreich gespeichert wurde, wird diese Methode aufgerufen | |||||
| // Prüfen, ob es ein Bild gibt oder nicht | |||||
| if (this.data?.signatureUrl) { | |||||
| // Es gibt ein Bild, also zeige es an | |||||
| this.showSignatureImage = true; | |||||
| } else { | |||||
| // Es gibt kein Bild, also verstecke es | |||||
| this.showSignatureImage = false; | |||||
| } | |||||
| // Zurücksetzen der temporären Variablen | |||||
| this.signatureToDelete = null; | |||||
| this.selectedFile = null; | |||||
| } | |||||
| override onSubmit(): void { | |||||
| if (!this.form.valid) { | |||||
| // console.log('Form is invalid:', this.form.errors); | |||||
| // Object.keys(this.form.controls).forEach(key => { | |||||
| // const control = this.form.get(key); | |||||
| // if (control?.invalid) { | |||||
| // console.log(`Control '${key}' is invalid:`, control.errors); | |||||
| // } | |||||
| // }); | |||||
| return; | |||||
| } | |||||
| // Drei Fälle: | |||||
| // 1. Ein neues Bild wurde ausgewählt | |||||
| // 2. Ein bestehendes Bild soll gelöscht werden | |||||
| // 3. Keine Änderungen am Bild | |||||
| if (this.selectedFile && this.signatureToDelete) { | |||||
| // Fall 1a: Ein neues Bild wurde ausgewählt UND ein altes soll gelöscht werden | |||||
| // Zuerst das alte Bild löschen, dann das neue hochladen | |||||
| if (this.data?.signature?.dbId) { | |||||
| this.mediaObjectService.mediaObjectsIdDelete(this.data?.signature?.dbId?.toString()).subscribe({ | |||||
| next: () => { | |||||
| // Altes Bild wurde gelöscht, jetzt neues hochladen | |||||
| this.uploadNewFile(); | |||||
| }, | |||||
| error: (error) => { | |||||
| console.error('Error deleting signature:', error); | |||||
| // Trotz Fehler neues Bild hochladen | |||||
| this.uploadNewFile(); | |||||
| } | |||||
| }); | |||||
| } | |||||
| } else if (this.selectedFile) { | |||||
| // Fall 1b: Nur ein neues Bild wurde ausgewählt (kein altes vorhanden) | |||||
| this.uploadNewFile(); | |||||
| } else if (this.signatureToDelete && this.data?.signature?.dbId) { | |||||
| // Fall 2: Nur ein bestehendes Bild soll gelöscht werden | |||||
| this.mediaObjectService.mediaObjectsIdDelete(this.data?.signature?.dbId?.toString()).subscribe({ | |||||
| next: () => { | |||||
| this.signatureToDelete = null; | |||||
| super.onSubmit(); | |||||
| }, | |||||
| error: (error) => { | |||||
| console.error('Error deleting signature:', error); | |||||
| super.onSubmit(); | |||||
| } | |||||
| }); | |||||
| } else { | |||||
| // Fall 3: Keine Änderungen am Bild | |||||
| super.onSubmit(); | |||||
| } | |||||
| } | |||||
| private uploadNewFile(): void { | |||||
| if (!this.selectedFile) return; | |||||
| this.mediaObjectService.mediaObjectsPost(this.selectedFile).subscribe({ | |||||
| next: (mediaObject) => { | |||||
| // Aktualisiere die Formulardaten mit dem neuen mediaObject | |||||
| this.form.patchValue({ | |||||
| signatureIri: mediaObject.id | |||||
| }); | |||||
| // Rufe die übergeordnete Methode auf, um den Standard-Speicherprozess zu verarbeiten | |||||
| super.onSubmit(); | |||||
| }, | |||||
| error: (error) => { | |||||
| console.error('Error uploading file:', error); | |||||
| this.submit.emit({ | |||||
| status: ModalStatus.Cancelled, | |||||
| data: null | |||||
| }); | |||||
| } | |||||
| }); | |||||
| } | |||||
| } | } | ||||
| @@ -1,7 +1,7 @@ | |||||
| import {Component, ViewChild} from '@angular/core'; | |||||
| import {Component, Input, ViewChild} from '@angular/core'; | |||||
| import {ListComponent} from "@app/_components/list/list.component"; | import {ListComponent} from "@app/_components/list/list.component"; | ||||
| import {ListColDefinition} from "@app/_components/list/list-col-definition"; | import {ListColDefinition} from "@app/_components/list/list-col-definition"; | ||||
| import {UserTripService} from "@app/core/api/v1"; | |||||
| import {UserJsonld, UserTripService} from "@app/core/api/v1"; | |||||
| import {AppHelperService} from "@app/_helpers/app-helper.service"; | import {AppHelperService} from "@app/_helpers/app-helper.service"; | ||||
| import {FilterBarComponent} from "@app/_components/filter-bar/filter-bar.component"; | import {FilterBarComponent} from "@app/_components/filter-bar/filter-bar.component"; | ||||
| import {ListGetDataFunctionType} from "@app/_components/list/list-get-data-function-type"; | import {ListGetDataFunctionType} from "@app/_components/list/list-get-data-function-type"; | ||||
| @@ -12,7 +12,7 @@ import {ListGetDataFunctionType} from "@app/_components/list/list-get-data-funct | |||||
| styleUrl: './user-trip-list.component.scss' | styleUrl: './user-trip-list.component.scss' | ||||
| }) | }) | ||||
| export class UserTripListComponent { | export class UserTripListComponent { | ||||
| @Input() public user?: UserJsonld; | |||||
| @ViewChild("listComponent", {static: false}) listComponent!: ListComponent; | @ViewChild("listComponent", {static: false}) listComponent!: ListComponent; | ||||
| protected listColDefinitions!: ListColDefinition[]; | protected listColDefinitions!: ListColDefinition[]; | ||||
| @@ -109,6 +109,10 @@ export class UserTripListComponent { | |||||
| return this.userTripService.userTripsGetCollection( | return this.userTripService.userTripsGetCollection( | ||||
| index, | index, | ||||
| pageSize, | pageSize, | ||||
| undefined, | |||||
| undefined, | |||||
| this.user ? this.user.id : undefined, | |||||
| undefined, | |||||
| // term ? Number(term) : undefined, | // term ? Number(term) : undefined, | ||||
| this.listComponent.getFilterJsonString(), | this.listComponent.getFilterJsonString(), | ||||
| this.listComponent.getSortingJsonString() | this.listComponent.getSortingJsonString() | ||||
| @@ -11,29 +11,11 @@ | |||||
| (submit)="onFormUpdate($event)" | (submit)="onFormUpdate($event)" | ||||
| > | > | ||||
| </app-user-form> | </app-user-form> | ||||
| <div class="card contacts-detail"> | |||||
| <div class="card-body row"> | |||||
| <div class="spt-col col-12 col-sm-6 col-lg-8"> | |||||
| <h2>{{ user.firstName }} {{ user.lastName }}</h2> | |||||
| <dl class="spt-dl"> | |||||
| <dt>{{ ('users.email' | translate) }}</dt> | |||||
| <dd><a href="mailto:{{ user.email }}">{{ user.email }}</a></dd> | |||||
| </dl> | |||||
| </div> | |||||
| <div class="col-12 col-sm-6 col-lg-4 has-image"> | |||||
| @if (user.imageUrl !== null && user.imageUrl !== undefined) { | |||||
| <img src="{{user.imageUrl}}" width="247" height="94" | |||||
| alt="{{user.firstName}} {{user.lastName}}" title="{{user.firstName}} {{user.lastName}}"/> | |||||
| } | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </mat-tab> | </mat-tab> | ||||
| <mat-tab label="{{ 'users.userTrips' | translate }}"> | <mat-tab label="{{ 'users.userTrips' | translate }}"> | ||||
| # List of all user trips of this user | |||||
| <app-user-trip-list | |||||
| [user]="user" | |||||
| ></app-user-trip-list> | |||||
| </mat-tab> | </mat-tab> | ||||
| </mat-tab-group> | </mat-tab-group> | ||||
| </div> | </div> | ||||
| @@ -3,7 +3,8 @@ import {UserJsonld, UserService} from "@app/core/api/v1"; | |||||
| import {AccountService} from "@app/_services"; | import {AccountService} from "@app/_services"; | ||||
| import {AppHelperService} from "@app/_helpers/app-helper.service"; | import {AppHelperService} from "@app/_helpers/app-helper.service"; | ||||
| import {ActivatedRoute} from "@angular/router"; | import {ActivatedRoute} from "@angular/router"; | ||||
| import {FormMode} from "@app/_components/_abstract/abstract-data-form-component"; | |||||
| import {FormMode, FormSubmitEvent} from "@app/_components/_abstract/abstract-data-form-component"; | |||||
| import {ModalStatus} from "@app/_helpers/modal.states"; | |||||
| @Component({ | @Component({ | ||||
| selector: 'app-user-detail', | selector: 'app-user-detail', | ||||
| @@ -12,6 +13,7 @@ import {FormMode} from "@app/_components/_abstract/abstract-data-form-component" | |||||
| }) | }) | ||||
| export class UserDetailComponent implements OnInit, AfterViewInit { | export class UserDetailComponent implements OnInit, AfterViewInit { | ||||
| @Input() public user!: UserJsonld; | @Input() public user!: UserJsonld; | ||||
| protected readonly FormMode = FormMode; | |||||
| protected isCurrentUser: boolean; | protected isCurrentUser: boolean; | ||||
| @@ -56,5 +58,9 @@ export class UserDetailComponent implements OnInit, AfterViewInit { | |||||
| } | } | ||||
| } | } | ||||
| protected readonly FormMode = FormMode; | |||||
| onFormUpdate(event: FormSubmitEvent<UserJsonld>) { | |||||
| if (event.status === ModalStatus.Submitted && event.data) { | |||||
| this.user = event.data; | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -6,6 +6,8 @@ | |||||
| } | } | ||||
| <div class="spt-form"> | <div class="spt-form"> | ||||
| <form [formGroup]="userForm" (ngSubmit)="onSubmit()"> | <form [formGroup]="userForm" (ngSubmit)="onSubmit()"> | ||||
| <input type="hidden" formControlName="imageIri" /> | |||||
| <div class="mb-3"> | <div class="mb-3"> | ||||
| <label for="email" class="form-label">{{ 'users.email' | translate }}:</label> | <label for="email" class="form-label">{{ 'users.email' | translate }}:</label> | ||||
| <input type="text" class="form-control" id="email" formControlName="email"/> | <input type="text" class="form-control" id="email" formControlName="email"/> | ||||
| @@ -26,6 +28,23 @@ | |||||
| <input type="text" class="form-control" id="password" formControlName="password"/> | <input type="text" class="form-control" id="password" formControlName="password"/> | ||||
| </div> | </div> | ||||
| <div class="col-12 col-sm-6 col-md-4 col-lg-3 mb-3 switch-widget"> | |||||
| <p class="form-label">{{ 'users.active' | translate }}:</p> | |||||
| <label class="switch"> | |||||
| <input type="checkbox" formControlName="active"> | |||||
| <span class="slider round"></span> | |||||
| </label> | |||||
| </div> | |||||
| <!-- Verwende die neue ImageUpload-Komponente --> | |||||
| <app-image-upload | |||||
| #imageUpload | |||||
| [imageUrl]="data?.imageUrl" | |||||
| [label]="'users.image' | translate" | |||||
| (fileSelected)="handleFileSelected($event)" | |||||
| (fileDeleted)="handleFileDeleted()"> | |||||
| </app-image-upload> | |||||
| <div class="flex gap-2"> | <div class="flex gap-2"> | ||||
| <button type="submit" class="btn btn-primary" [disabled]="form.invalid"> | <button type="submit" class="btn btn-primary" [disabled]="form.invalid"> | ||||
| {{ 'basic.save' | translate }} | {{ 'basic.save' | translate }} | ||||
| @@ -33,10 +52,10 @@ | |||||
| @if (isEditMode()) { | @if (isEditMode()) { | ||||
| <button type="button" class="ms-3 btn btn-primary" (click)="onDelete()"> | <button type="button" class="ms-3 btn btn-primary" (click)="onDelete()"> | ||||
| {{ 'basic.delete' | translate }} {{ 'model.trip' | translate }} | |||||
| {{ 'basic.delete' | translate }} {{ 'model.user' | translate }} | |||||
| </button> | </button> | ||||
| } | } | ||||
| </div> | </div> | ||||
| </form> | </form> | ||||
| </div> | </div> | ||||
| </div> | |||||
| </div> | |||||
| @@ -1,40 +1,52 @@ | |||||
| import { Component } from '@angular/core'; | import { Component } from '@angular/core'; | ||||
| import {AbstractDataFormComponent} from "@app/_components/_abstract/abstract-data-form-component"; | |||||
| import {UserJsonld, UserService} from "@app/core/api/v1"; | |||||
| import {AppHelperService} from "@app/_helpers/app-helper.service"; | |||||
| import {TranslateService} from "@ngx-translate/core"; | |||||
| import {Router} from "@angular/router"; | |||||
| import {ROUTE_USERS} from "@app/app-routing.module"; | |||||
| import {userForm} from "@app/_forms/apiForms"; | |||||
| import { UserJsonld, UserService } from "@app/core/api/v1"; | |||||
| import { AppHelperService } from "@app/_helpers/app-helper.service"; | |||||
| import { TranslateService } from "@ngx-translate/core"; | |||||
| import { Router } from "@angular/router"; | |||||
| import { ROUTE_USERS } from "@app/app-routing.module"; | |||||
| import { userForm } from "@app/_forms/apiForms"; | |||||
| import { AbstractImageFormComponent } from "@app/_components/_abstract/abstract-image-form-component"; | |||||
| import { ImageUploadService } from "@app/_services/image-upload.service"; | |||||
| @Component({ | @Component({ | ||||
| selector: 'app-user-form', | |||||
| templateUrl: './user-form.component.html', | |||||
| styleUrl: './user-form.component.scss' | |||||
| selector: 'app-user-form', | |||||
| templateUrl: './user-form.component.html', | |||||
| styleUrl: './user-form.component.scss' | |||||
| }) | }) | ||||
| export class UserFormComponent extends AbstractDataFormComponent<UserJsonld> { | |||||
| export class UserFormComponent extends AbstractImageFormComponent<UserJsonld> { | |||||
| protected readonly userForm = userForm; | protected readonly userForm = userForm; | ||||
| // Implementierung der abstrakten Eigenschaften | |||||
| get imageIriControlName(): string { | |||||
| return 'imageIri'; | |||||
| } | |||||
| get imageDbId(): string | number | undefined | null { | |||||
| return this.data?.image?.dbId; | |||||
| } | |||||
| constructor( | constructor( | ||||
| private userService: UserService, | private userService: UserService, | ||||
| imageUploadService: ImageUploadService, | |||||
| private appHelperService: AppHelperService, | private appHelperService: AppHelperService, | ||||
| translateService: TranslateService, | translateService: TranslateService, | ||||
| router: Router | router: Router | ||||
| ) { | ) { | ||||
| super( | super( | ||||
| imageUploadService, | |||||
| userForm, | userForm, | ||||
| (data: UserJsonld) => this.userService.usersPost(data), | |||||
| (data: UserJsonld) => userService.usersPost(data), | |||||
| (id: string | number, data: UserJsonld) => | (id: string | number, data: UserJsonld) => | ||||
| this.userService.usersIdPatch( | |||||
| userService.usersIdPatch( | |||||
| id.toString(), | id.toString(), | ||||
| this.appHelperService.convertJsonldToJson(data) | |||||
| appHelperService.convertJsonldToJson(data) | |||||
| ), | ), | ||||
| (id: string | number) => this.userService.usersIdDelete(id.toString()), | |||||
| (id: string | number) => userService.usersIdDelete(id.toString()), | |||||
| translateService, | translateService, | ||||
| router | router | ||||
| ); | ); | ||||
| this.redirectAfterDelete = '/' + ROUTE_USERS; | this.redirectAfterDelete = '/' + ROUTE_USERS; | ||||
| } | } | ||||
| } | |||||
| } | |||||
| @@ -7,6 +7,7 @@ import {AppHelperService} from "@app/_helpers/app-helper.service"; | |||||
| import {ListComponent} from "@app/_components/list/list.component"; | import {ListComponent} from "@app/_components/list/list.component"; | ||||
| import {ListColDefinition} from "@app/_components/list/list-col-definition"; | import {ListColDefinition} from "@app/_components/list/list-col-definition"; | ||||
| import {ListGetDataFunctionType} from "@app/_components/list/list-get-data-function-type"; | import {ListGetDataFunctionType} from "@app/_components/list/list-get-data-function-type"; | ||||
| import {FilterBarComponent} from "@app/_components/filter-bar/filter-bar.component"; | |||||
| @Component({ | @Component({ | ||||
| selector: 'app-user-list', | selector: 'app-user-list', | ||||
| @@ -62,6 +63,14 @@ export class UserListComponent implements OnInit, AfterViewInit { | |||||
| type: ListComponent.COLUMN_TYPE_TEXT_BOLD, | type: ListComponent.COLUMN_TYPE_TEXT_BOLD, | ||||
| field: 'referenceId', | field: 'referenceId', | ||||
| } as ListColDefinition, | } as ListColDefinition, | ||||
| { | |||||
| name: 'active', | |||||
| text: 'users.active', | |||||
| type: ListComponent.COLUMN_TYPE_BOOLEAN, | |||||
| field: 'active', | |||||
| sortable: true, | |||||
| filterType: FilterBarComponent.FILTER_TYPE_BOOLEAN, | |||||
| } as ListColDefinition, | |||||
| ]; | ]; | ||||
| } | } | ||||
| @@ -70,6 +70,7 @@ import { UserTripFormComponent } from './_views/user-trip/user-trip-form/user-tr | |||||
| import { UserTripEventComponent } from './_views/user-trip-event/user-trip-event.component'; | import { UserTripEventComponent } from './_views/user-trip-event/user-trip-event.component'; | ||||
| import { UserTripEventListComponent } from './_views/user-trip-event/user-trip-event-list/user-trip-event-list.component'; | import { UserTripEventListComponent } from './_views/user-trip-event/user-trip-event-list/user-trip-event-list.component'; | ||||
| import { UserFormComponent } from './_views/user/user-form/user-form.component'; | import { UserFormComponent } from './_views/user/user-form/user-form.component'; | ||||
| import { ImageUploadComponent } from './_components/image-upload/image-upload.component'; | |||||
| registerLocaleData(localeDe, 'de-DE'); | registerLocaleData(localeDe, 'de-DE'); | ||||
| @@ -165,6 +166,7 @@ export function HttpLoaderFactory(http: HttpClient) { | |||||
| UserTripEventComponent, | UserTripEventComponent, | ||||
| UserTripEventListComponent, | UserTripEventListComponent, | ||||
| UserFormComponent, | UserFormComponent, | ||||
| ImageUploadComponent, | |||||
| ], | ], | ||||
| providers: [ | providers: [ | ||||
| {provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true}, | {provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true}, | ||||
| @@ -28,7 +28,7 @@ export interface User { | |||||
| * The plaintext password when being set or changed. | * The plaintext password when being set or changed. | ||||
| */ | */ | ||||
| password?: string | null; | password?: string | null; | ||||
| active?: boolean; | |||||
| active: boolean; | |||||
| roles?: Array<string>; | roles?: Array<string>; | ||||
| readonly createdAt?: string | null; | readonly createdAt?: string | null; | ||||
| } | } | ||||
| @@ -33,7 +33,7 @@ export interface UserJsonld { | |||||
| * The plaintext password when being set or changed. | * The plaintext password when being set or changed. | ||||
| */ | */ | ||||
| password?: string | null; | password?: string | null; | ||||
| active?: boolean; | |||||
| active: boolean; | |||||
| roles?: Array<string>; | roles?: Array<string>; | ||||
| readonly createdAt?: string | null; | readonly createdAt?: string | null; | ||||
| } | } | ||||
| @@ -121,7 +121,9 @@ | |||||
| "lastname": "Lastname", | "lastname": "Lastname", | ||||
| "userTrips": "User trips", | "userTrips": "User trips", | ||||
| "pilotIdNo": "#Pilot id", | "pilotIdNo": "#Pilot id", | ||||
| "password": "Password" | |||||
| "password": "Password", | |||||
| "active": "Active", | |||||
| "image": "Image" | |||||
| }, | }, | ||||
| "form": | "form": | ||||
| { | { | ||||
| @@ -17,7 +17,6 @@ use ApiPlatform\Metadata\Get; | |||||
| use ApiPlatform\Metadata\GetCollection; | use ApiPlatform\Metadata\GetCollection; | ||||
| use ApiPlatform\Metadata\Patch; | use ApiPlatform\Metadata\Patch; | ||||
| use ApiPlatform\Metadata\Post; | use ApiPlatform\Metadata\Post; | ||||
| use App\Entity\MediaObject; | |||||
| use App\Entity\User; | use App\Entity\User; | ||||
| use App\Filter\CustomJsonOrderFilter; | use App\Filter\CustomJsonOrderFilter; | ||||
| use App\Filter\UserNameSearchFilter; | use App\Filter\UserNameSearchFilter; | ||||
| @@ -40,7 +39,7 @@ use Symfony\Component\Validator\Constraints as Assert; | |||||
| validationContext: ['groups' => ['Default', 'postValidation']] | validationContext: ['groups' => ['Default', 'postValidation']] | ||||
| ), | ), | ||||
| new Patch( | new Patch( | ||||
| security: 'is_granted("is_granted("EDIT", object)")' | |||||
| security: 'is_granted("ROLE_ADMIN")' | |||||
| ), | ), | ||||
| new Delete( | new Delete( | ||||
| security: 'is_granted("ROLE_ADMIN")' | security: 'is_granted("ROLE_ADMIN")' | ||||
| @@ -108,11 +107,7 @@ class UserApi | |||||
| #[Assert\NotBlank(groups: ['postValidation'])] | #[Assert\NotBlank(groups: ['postValidation'])] | ||||
| public ?string $password = null; | public ?string $password = null; | ||||
| // Object is null ONLY during deserialization: so this allows isPublished | |||||
| // to be writable in ALL cases (which is ok because the operations are secured). | |||||
| // During serialization, object will always be a DragonTreasureApi, so our | |||||
| // voter is called. | |||||
| #[ApiProperty(security: 'object === null or is_granted("EDIT", object)')] | |||||
| #[Assert\NotNull] | |||||
| public bool $active; | public bool $active; | ||||
| #[ApiProperty(writable: false)] | #[ApiProperty(writable: false)] | ||||
| @@ -46,6 +46,7 @@ class UserApiToEntityMapper implements MapperInterface | |||||
| $entity->setReferenceId($dto->referenceId); | $entity->setReferenceId($dto->referenceId); | ||||
| $entity->setFirstName($dto->firstName); | $entity->setFirstName($dto->firstName); | ||||
| $entity->setLastName($dto->lastName); | $entity->setLastName($dto->lastName); | ||||
| $entity->setActive($dto->active); | |||||
| if ($dto->password) { | if ($dto->password) { | ||||
| $entity->setPassword($this->userPasswordHasher->hashPassword($entity, $dto->password)); | $entity->setPassword($this->userPasswordHasher->hashPassword($entity, $dto->password)); | ||||
| } | } | ||||
| @@ -44,6 +44,7 @@ class UserEntityToApiMapper implements MapperInterface | |||||
| $dto->firstName = $entity->getFirstName(); | $dto->firstName = $entity->getFirstName(); | ||||
| $dto->lastName = $entity->getLastName(); | $dto->lastName = $entity->getLastName(); | ||||
| $dto->roles = $entity->getRoles(); | $dto->roles = $entity->getRoles(); | ||||
| $dto->active = $entity->isActive(); | |||||
| $dto->imageIri = $dto->image = null; | $dto->imageIri = $dto->image = null; | ||||
| if ($entity->getImage() !== null) { | if ($entity->getImage() !== null) { | ||||