From 9c372b31a687d4e9912590e90c40a469518e24fb Mon Sep 17 00:00:00 2001 From: ThompsonNye Date: Mon, 23 Jun 2025 16:20:44 +0200 Subject: [PATCH] Add managing cars --- src/Vegasco-Web/src/app/app.html | 12 +- src/Vegasco-Web/src/app/app.routes.ts | 4 + src/Vegasco-Web/src/app/app.ts | 4 +- .../src/app/modules/cars/cars.routes.ts | 17 ++ .../app/modules/cars/cars/cars.component.html | 38 +++ .../app/modules/cars/cars/cars.component.scss | 3 + .../app/modules/cars/cars/cars.component.ts | 117 ++++++++++ .../car-card/car-card.component.html | 22 ++ .../car-card/car-card.component.scss | 3 + .../components/car-card/car-card.component.ts | 116 ++++++++++ .../components/required-marker.component.html | 1 + .../components/required-marker.component.scss | 3 + .../components/required-marker.component.ts | 11 + .../cars/edit-car/edit-car.component.html | 31 +++ .../cars/edit-car/edit-car.component.scss | 0 .../cars/edit-car/edit-car.component.ts | 217 ++++++++++++++++++ .../edit-entry/edit-entry.component.ts | 5 + .../src/app/services/routing.service.ts | 12 + 18 files changed, 612 insertions(+), 4 deletions(-) create mode 100644 src/Vegasco-Web/src/app/modules/cars/cars.routes.ts create mode 100644 src/Vegasco-Web/src/app/modules/cars/cars/cars.component.html create mode 100644 src/Vegasco-Web/src/app/modules/cars/cars/cars.component.scss create mode 100644 src/Vegasco-Web/src/app/modules/cars/cars/cars.component.ts create mode 100644 src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.html create mode 100644 src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.scss create mode 100644 src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.ts create mode 100644 src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.html create mode 100644 src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.scss create mode 100644 src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.ts create mode 100644 src/Vegasco-Web/src/app/modules/cars/edit-car/edit-car.component.html create mode 100644 src/Vegasco-Web/src/app/modules/cars/edit-car/edit-car.component.scss create mode 100644 src/Vegasco-Web/src/app/modules/cars/edit-car/edit-car.component.ts diff --git a/src/Vegasco-Web/src/app/app.html b/src/Vegasco-Web/src/app/app.html index 0954df0..3628171 100644 --- a/src/Vegasco-Web/src/app/app.html +++ b/src/Vegasco-Web/src/app/app.html @@ -1,13 +1,21 @@
-
-
+ \ No newline at end of file diff --git a/src/Vegasco-Web/src/app/app.routes.ts b/src/Vegasco-Web/src/app/app.routes.ts index d6a5093..b30947e 100644 --- a/src/Vegasco-Web/src/app/app.routes.ts +++ b/src/Vegasco-Web/src/app/app.routes.ts @@ -9,5 +9,9 @@ export const routes: Routes = [ { path: 'entries', loadChildren: () => import('./modules/entries/entries.routes').then(m => m.routes) + }, + { + path: 'cars', + loadChildren: () => import('./modules/cars/cars.routes').then(m => m.routes) } ]; diff --git a/src/Vegasco-Web/src/app/app.ts b/src/Vegasco-Web/src/app/app.ts index 611b7ec..799a02f 100644 --- a/src/Vegasco-Web/src/app/app.ts +++ b/src/Vegasco-Web/src/app/app.ts @@ -1,12 +1,12 @@ import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { RouterLink, RouterOutlet } from '@angular/router'; import { MessageService } from 'primeng/api'; import { ToastModule } from 'primeng/toast'; @Component({ selector: 'app-root', - imports: [RouterOutlet, ToastModule], + imports: [RouterLink, RouterOutlet, ToastModule], providers: [MessageService], templateUrl: './app.html', styleUrl: './app.scss' diff --git a/src/Vegasco-Web/src/app/modules/cars/cars.routes.ts b/src/Vegasco-Web/src/app/modules/cars/cars.routes.ts new file mode 100644 index 0000000..889800c --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/cars.routes.ts @@ -0,0 +1,17 @@ +import { Routes } from "@angular/router"; +import { CarsComponent } from "./cars/cars.component"; + +export const routes: Routes = [ + { + path: '', + component: CarsComponent + }, + { + path: 'create', + loadComponent: () => import('./edit-car/edit-car.component').then(m => m.EditCarComponent) + }, + { + path: 'edit/:id', + loadComponent: () => import('./edit-car/edit-car.component').then(m => m.EditCarComponent) + } +]; \ No newline at end of file diff --git a/src/Vegasco-Web/src/app/modules/cars/cars/cars.component.html b/src/Vegasco-Web/src/app/modules/cars/cars/cars.component.html new file mode 100644 index 0000000..766bdf8 --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/cars/cars.component.html @@ -0,0 +1,38 @@ +
+ +
+
+ + + +
+
+
+ @if (nonDeletedCars$ | async; as cars) { + + +
+ @for (car of cars; track car.id) { + + } +
+
+
+ } @else { +
+ @for (_ of skeletonsIterationSource; track $index) { + + } +
+ } +
+
diff --git a/src/Vegasco-Web/src/app/modules/cars/cars/cars.component.scss b/src/Vegasco-Web/src/app/modules/cars/cars/cars.component.scss new file mode 100644 index 0000000..e71c33b --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/cars/cars.component.scss @@ -0,0 +1,3 @@ +th, td { + padding: 0.5rem; +} \ No newline at end of file diff --git a/src/Vegasco-Web/src/app/modules/cars/cars/cars.component.ts b/src/Vegasco-Web/src/app/modules/cars/cars/cars.component.ts new file mode 100644 index 0000000..c6d1da2 --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/cars/cars.component.ts @@ -0,0 +1,117 @@ +import { AsyncPipe, CommonModule } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { NgIconComponent, provideIcons } from '@ng-icons/core'; +import { + matAddSharp, +} from '@ng-icons/material-icons/sharp'; +import { CarClient } from '@vegasco-web/api/cars/car-client'; +import { MessageService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { DataViewModule } from 'primeng/dataview'; +import { ScrollTopModule } from 'primeng/scrolltop'; +import { SelectModule } from 'primeng/select'; +import { SkeletonModule } from 'primeng/skeleton'; +import { + BehaviorSubject, + catchError, + combineLatest, + EMPTY, + map, + Observable, + throwError +} from 'rxjs'; +import { CarCardComponent } from './components/car-card/car-card.component'; + +@Component({ + selector: 'app-entries', + imports: [ + AsyncPipe, + ButtonModule, + CommonModule, + DataViewModule, + CarCardComponent, + NgIconComponent, + ReactiveFormsModule, + RouterLink, + ScrollTopModule, + SelectModule, + SkeletonModule, + ], + providers: [ + provideIcons({ + matAddSharp, + }), + ], + templateUrl: './cars.component.html', + styleUrl: './cars.component.scss' +}) +export class CarsComponent { + private readonly carClient = inject(CarClient); + private readonly messageService = inject(MessageService); + + protected readonly nonDeletedCars$: Observable; + + protected readonly skeletonsIterationSource = Array(10).fill(0); + + private readonly deletedCars$ = new BehaviorSubject([]); + + constructor() { + const cars$ = this.carClient.getAll() + .pipe( + map(response => response.cars), + map((cars) => cars + .sort((a, b) => a.name.localeCompare(b.name))), + ); + + this.nonDeletedCars$ = combineLatest([ + cars$, + this.deletedCars$ + ]) + .pipe( + takeUntilDestroyed(), + map(([cars, deletedCars]) => cars.filter(car => !deletedCars.includes(car.id))), + catchError((error) => this.handleGetCarsError(error)), + ); + } + + onCarDeleted(entry: Car): void { + this.deletedCars$.next([...this.deletedCars$.value, entry.id]); + this.messageService.add({ + severity: 'success', + summary: 'Auto gelöscht', + detail: 'Das Auto wurde erfolgreich gelöscht.', + }); + } + + private handleGetCarsError(error: unknown): Observable { + if (!(error instanceof HttpErrorResponse)) { + return throwError(() => new Error('An unexpected error occurred')); + } + + switch (true) { + case error.status >= 500 && error.status <= 599: + this.messageService.add({ + severity: 'error', + summary: 'Serverfehler', + detail: + 'Beim Abrufen der Einträge ist ein Fehler aufgetreten. Bitte versuche es erneut.', + }); + break; + default: + console.error(error); + this.messageService.add({ + severity: 'error', + summary: 'Unerwarteter Fehler', + detail: + 'Beim Abrufen der Einträge hat der Server eine unerwartete Antwort zurückgegeben.', + }); + break; + } + + return EMPTY; + } +} diff --git a/src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.html b/src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.html new file mode 100644 index 0000000..671d678 --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.html @@ -0,0 +1,22 @@ + + +
+
+
+ +
+
+ +
{{ car().name }}
+
+
+
+
+
+ +
+
\ No newline at end of file diff --git a/src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.scss b/src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.scss new file mode 100644 index 0000000..42ece6f --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.scss @@ -0,0 +1,3 @@ +.edit-button { + cursor: pointer; +} diff --git a/src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.ts b/src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.ts new file mode 100644 index 0000000..8170817 --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/cars/components/car-card/car-card.component.ts @@ -0,0 +1,116 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, DestroyRef, inject, input, output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NgIconComponent, provideIcons } from '@ng-icons/core'; +import { + matDirectionsCarOutline, +} from '@ng-icons/material-icons/outline'; +import { + matDeleteSharp +} from '@ng-icons/material-icons/sharp'; +import { CarClient } from '@vegasco-web/api/cars/car-client'; +import { RoutingService } from '@vegasco-web/services/routing.service'; +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { CardModule } from 'primeng/card'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { catchError, EMPTY, Observable, tap, throwError } from 'rxjs'; + +@Component({ + selector: 'app-car-card', + imports: [ + ButtonModule, + CardModule, + ConfirmDialogModule, + NgIconComponent, + ], + providers: [ + provideIcons({ + matDeleteSharp, + matDirectionsCarOutline, + }), + ConfirmationService, + ], + templateUrl: './car-card.component.html', + styleUrl: './car-card.component.scss' +}) +export class CarCardComponent { + readonly car = input.required(); + + readonly carDeleted = output(); + + private readonly routingService = inject(RoutingService); + private readonly carClient = inject(CarClient); + private readonly messageService = inject(MessageService); + private readonly confirmationService = inject(ConfirmationService); + + private readonly destroyRef = inject(DestroyRef); + + async navigateToEdit(): Promise { + await this.routingService.navigateToEditCar(this.car().id); + } + + confirmDeleteCar(): void { + this.confirmationService.confirm({ + closeOnEscape: true, + dismissableMask: true, + header: 'Bist du sicher?', + message: `Möchtest du das Auto "${this.car().name}" wirklich löschen?`, + acceptButtonProps: { + label: 'Löschen', + severity: 'danger', + }, + rejectButtonProps: { + label: 'Abbrechen', + outlined: true, + }, + accept: () => this.deleteCar(), + }); + } + + deleteCar(): void { + this.carClient.delete(this.car().id) + .pipe( + takeUntilDestroyed(this.destroyRef), + tap(() => this.carDeleted.emit(this.car())), + catchError((error) => this.handleError(error)), + ) + .subscribe(); + } + + private handleError(error: unknown): Observable { + if (!(error instanceof HttpErrorResponse)) { + return throwError(() => error); + } + + switch (true) { + case error.status >= 500 && error.status <= 599: + this.messageService.add({ + severity: 'error', + summary: 'Serverfehler', + detail: + 'Beim Löschen des Autos ist ein Fehler aufgetreten. Bitte versuche es erneut.', + }); + break; + case error.status === 400: + this.messageService.add({ + severity: 'error', + summary: 'Clientfehler', + detail: + 'Die Anwendung scheint falsche Daten an den Server zu senden.', + }); + break; + default: + console.error(error); + this.messageService.add({ + severity: 'error', + summary: 'Unerwarteter Fehler', + detail: + 'Beim Löschen des Autos hat der Server eine unerwartete Antwort zurückgegeben.', + }); + break; + } + + return EMPTY; + } +} diff --git a/src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.html b/src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.html new file mode 100644 index 0000000..a01d1a4 --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.html @@ -0,0 +1 @@ +* diff --git a/src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.scss b/src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.scss new file mode 100644 index 0000000..00a2ac1 --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.scss @@ -0,0 +1,3 @@ +.required { + color: red; +} \ No newline at end of file diff --git a/src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.ts b/src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.ts new file mode 100644 index 0000000..5017a19 --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/edit-car/components/required-marker.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-required-marker', + imports: [ + ], + templateUrl: './required-marker.component.html', + styleUrl: './required-marker.component.scss' +}) +export class RequiredMarkerComponent { +} diff --git a/src/Vegasco-Web/src/app/modules/cars/edit-car/edit-car.component.html b/src/Vegasco-Web/src/app/modules/cars/edit-car/edit-car.component.html new file mode 100644 index 0000000..8f3d4c4 --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/edit-car/edit-car.component.html @@ -0,0 +1,31 @@ +@if (!isCarDataLoaded()) { +
+ +
+ + +
+
+} @else { +
+ +
+ + +
+ +
+ + +
+ +
+} diff --git a/src/Vegasco-Web/src/app/modules/cars/edit-car/edit-car.component.scss b/src/Vegasco-Web/src/app/modules/cars/edit-car/edit-car.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/Vegasco-Web/src/app/modules/cars/edit-car/edit-car.component.ts b/src/Vegasco-Web/src/app/modules/cars/edit-car/edit-car.component.ts new file mode 100644 index 0000000..01b0f52 --- /dev/null +++ b/src/Vegasco-Web/src/app/modules/cars/edit-car/edit-car.component.ts @@ -0,0 +1,217 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, DestroyRef, inject, input, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { CarClient } from '@vegasco-web/api/cars/car-client'; +import { RoutingService } from '@vegasco-web/services/routing.service'; +import { MessageService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ChipModule } from 'primeng/chip'; +import { DatePickerModule } from 'primeng/datepicker'; +import { FloatLabelModule } from 'primeng/floatlabel'; +import { InputGroupModule } from 'primeng/inputgroup'; +import { InputGroupAddonModule } from 'primeng/inputgroupaddon'; +import { InputNumberModule } from 'primeng/inputnumber'; +import { InputTextModule } from 'primeng/inputtext'; +import { MultiSelectModule } from 'primeng/multiselect'; +import { SelectModule } from 'primeng/select'; +import { SkeletonModule } from 'primeng/skeleton'; +import { catchError, EMPTY, Observable, switchMap, tap, throwError } from 'rxjs'; +import { RequiredMarkerComponent } from './components/required-marker.component'; + +@Component({ + selector: 'app-edit-entry', + imports: [ + ButtonModule, + ChipModule, + DatePickerModule, + FloatLabelModule, + InputGroupAddonModule, + InputGroupModule, + InputNumberModule, + InputTextModule, + MultiSelectModule, + ReactiveFormsModule, + RequiredMarkerComponent, + SelectModule, + SkeletonModule, + ], + templateUrl: './edit-car.component.html', + styleUrl: './edit-car.component.scss' +}) +export class EditCarComponent implements OnInit { + private readonly carClient = inject(CarClient); + private readonly routingService = inject(RoutingService); + private readonly destroyRef = inject(DestroyRef); + private readonly messageService = inject(MessageService); + + protected readonly id = input(undefined); + + protected readonly today = new Date(); + + protected readonly formFieldNames = { + name: 'name', + } as const; + + protected readonly formGroup = new FormGroup({ + [this.formFieldNames.name]: new FormControl({ value: null, disabled: true }, [Validators.required]), + }); + + protected readonly isCarDataLoaded = signal(false); + + ngOnInit(): void { + this.loadEntryDetailsAndEnableControls(); + } + + private loadEntryDetailsAndEnableControls() { + const carId = this.id(); + + if (carId === undefined || carId === null) { + this.enableFormControls(); + this.isCarDataLoaded.set(true); + return; + } + + this.carClient + .getSingle(carId) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error) => this.handleGetError(error)), + tap((car) => { + this.formGroup.patchValue({ + [this.formFieldNames.name]: car.name, + }); + }), + tap(() => { + this.enableFormControls(); + this.isCarDataLoaded.set(true); + }), + ) + .subscribe(); + + } + + private enableFormControls(): void { + for (const controlName of Object.values(this.formFieldNames)) { + const control = this.formGroup.get(controlName); + if (control) { + control.enable(); + } else { + console.warn(`Form control '${controlName}' not found.`); + } + } + } + + async navigateToOverviewPage(): Promise { + await this.routingService.navigateToCars(); + } + + onSubmit(): void { + if (this.formGroup.invalid) { + this.formGroup.markAllAsTouched(); + return; + } + + var carId = this.id(); + if (carId === undefined || carId === null) { + this.createCar(); + return; + } + + this.updateCar(carId); + } + + private getFormData() { + return { + name: this.formGroup.controls[this.formFieldNames.name].value!, + }; + } + + createCar() { + var request: CreateCarRequest = this.getFormData(); + this.carClient.create(request) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error) => this.handleCreateOrUpdateError(error, false)), + switchMap(() => this.routingService.navigateToCars()) + ) + .subscribe(); + } + + updateCar(id: string) { + var request: UpdateCarRequest = this.getFormData(); + this.carClient.update(id, request) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error) => this.handleCreateOrUpdateError(error, true)), + switchMap(() => this.routingService.navigateToCars()) + ) + .subscribe(); + } + + private handleGetError(error: unknown): Observable { + if (!(error instanceof HttpErrorResponse)) { + return throwError(() => error); + } + + switch (true) { + case error.status >= 500 && error.status <= 599: + this.messageService.add({ + severity: 'error', + summary: 'Serverfehler', + detail: + 'Beim Abrufen des Autos ist ein Fehler aufgetreten. Bitte versuche es erneut.', + }); + break; + default: + console.error(error); + this.messageService.add({ + severity: 'error', + summary: 'Unerwarteter Fehler', + detail: + 'Beim Abrufen des Autos hat der Server eine unerwartete Antwort zurückgegeben.', + }); + break; + } + + return EMPTY; + } + + private handleCreateOrUpdateError(error: unknown, isUpdate: boolean): Observable { + if (!(error instanceof HttpErrorResponse)) { + return throwError(() => error); + } + + const action = isUpdate ? 'Aktualisieren' : 'Erstellen'; + + switch (true) { + case error.status >= 500 && error.status <= 599: + this.messageService.add({ + severity: 'error', + summary: 'Serverfehler', + detail: + `Beim ${action} des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.`, + }); + break; + case error.status === 400: + this.messageService.add({ + severity: 'error', + summary: 'Clientfehler', + detail: + 'Die Anwendung scheint falsche Daten an den Server zu senden.', + }); + break; + default: + console.error(error); + this.messageService.add({ + severity: 'error', + summary: 'Unerwarteter Fehler', + detail: + `Beim ${action} des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.`, + }); + break; + } + + return EMPTY; + } +} diff --git a/src/Vegasco-Web/src/app/modules/entries/edit-entry/edit-entry.component.ts b/src/Vegasco-Web/src/app/modules/entries/edit-entry/edit-entry.component.ts index 3cde557..7a8cb4a 100644 --- a/src/Vegasco-Web/src/app/modules/entries/edit-entry/edit-entry.component.ts +++ b/src/Vegasco-Web/src/app/modules/entries/edit-entry/edit-entry.component.ts @@ -169,6 +169,11 @@ export class EditEntryComponent implements OnInit { } onSubmit(): void { + if (this.formGroup.invalid) { + this.formGroup.markAllAsTouched(); + return; + } + var entryId = this.id(); if (entryId === undefined || entryId === null) { this.createEntry(); diff --git a/src/Vegasco-Web/src/app/services/routing.service.ts b/src/Vegasco-Web/src/app/services/routing.service.ts index 2c4ae34..bc6a961 100644 --- a/src/Vegasco-Web/src/app/services/routing.service.ts +++ b/src/Vegasco-Web/src/app/services/routing.service.ts @@ -18,4 +18,16 @@ export class RoutingService { async navigateToCreateEntry(): Promise { await this.router.navigate(['entries', 'create']); } + + async navigateToCars(): Promise { + await this.router.navigateByUrl('/cars'); + } + + async navigateToEditCar(entryId: string): Promise { + await this.router.navigate(['cars', 'edit', entryId]); + } + + async navigateToCreateCar(): Promise { + await this.router.navigate(['cars', 'create']); + } } \ No newline at end of file