Compare commits

...

4 Commits

Author SHA1 Message Date
1c8e02b3fa Add error handling
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 18:56:49 +02:00
feadab4dff Sort entries both on the backend and frontend 2025-06-19 18:56:40 +02:00
41c342bb0f Add more accurate loading skeletons 2025-06-19 18:56:24 +02:00
2e3000c3fc Add loading entry data when updating an entry 2025-06-19 18:49:04 +02:00
6 changed files with 161 additions and 39 deletions

View File

@@ -3,7 +3,7 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Launch (Chrome)", "name": "Launch Web (Chrome)",
"type": "chrome", "type": "chrome",
"request": "launch", "request": "launch",
"preLaunchTask": "npm: start", "preLaunchTask": "npm: start",

View File

@@ -1,5 +1,14 @@
@if (isLoading()) { @if (isLoading()) {
<p-skeleton height="4rem" styleClass="mb-2" /> <div class="flex flex-col gap-6">
<p-skeleton height="3.5rem" />
<p-skeleton height="3.5rem" />
<p-skeleton height="3.5rem" />
<p-skeleton height="3.5rem" />
<div class="flex flex-row gap-4">
<p-skeleton height="3.5rem" width="10rem" />
<p-skeleton height="3.5rem" width="10rem" />
</div>
</div>
} @else { } @else {
<form [formGroup]="formGroup" class="flex flex-col gap-4" (ngSubmit)="onSubmit()"> <form [formGroup]="formGroup" class="flex flex-col gap-4" (ngSubmit)="onSubmit()">

View File

@@ -1,7 +1,7 @@
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { Component, computed, DestroyRef, inject, input, Signal } from '@angular/core'; import { Component, computed, DestroyRef, inject, input, OnInit, signal, Signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { CarClient } from '@vegasco-web/api/cars/car-client'; import { CarClient } from '@vegasco-web/api/cars/car-client';
import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client'; import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
import { RoutingService } from '@vegasco-web/services/routing.service'; import { RoutingService } from '@vegasco-web/services/routing.service';
@@ -17,7 +17,7 @@ import { InputTextModule } from 'primeng/inputtext';
import { MultiSelectModule } from 'primeng/multiselect'; import { MultiSelectModule } from 'primeng/multiselect';
import { SelectModule } from 'primeng/select'; import { SelectModule } from 'primeng/select';
import { SkeletonModule } from 'primeng/skeleton'; import { SkeletonModule } from 'primeng/skeleton';
import { catchError, EMPTY, map, Observable, switchMap, throwError } from 'rxjs'; import { catchError, combineLatest, EMPTY, filter, map, Observable, switchMap, tap, throwError } from 'rxjs';
import { RequiredMarkerComponent } from './components/required-marker.component'; import { RequiredMarkerComponent } from './components/required-marker.component';
@Component({ @Component({
@@ -40,15 +40,14 @@ import { RequiredMarkerComponent } from './components/required-marker.component'
templateUrl: './edit-entry.component.html', templateUrl: './edit-entry.component.html',
styleUrl: './edit-entry.component.scss' styleUrl: './edit-entry.component.scss'
}) })
export class EditEntryComponent { export class EditEntryComponent implements OnInit {
private readonly formBuilder = inject(FormBuilder);
private readonly carClient = inject(CarClient); private readonly carClient = inject(CarClient);
private readonly consumptionClient = inject(ConsumptionClient); private readonly consumptionClient = inject(ConsumptionClient);
private readonly routingService = inject(RoutingService); private readonly routingService = inject(RoutingService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly messageService = inject(MessageService); private readonly messageService = inject(MessageService);
protected readonly entryId = input<string | undefined>(undefined); protected readonly id = input<string | undefined>(undefined);
protected readonly formFieldNames = { protected readonly formFieldNames = {
car: 'car', car: 'car',
@@ -57,34 +56,82 @@ export class EditEntryComponent {
amount: 'amount', amount: 'amount',
} as const; } as const;
protected readonly formGroup = this.formBuilder.group({ protected readonly formGroup = new FormGroup({
[this.formFieldNames.car]: [<Car | null>null, Validators.required], [this.formFieldNames.car]: new FormControl<Car | null>({ value: null, disabled: true }, [Validators.required]),
[this.formFieldNames.date]: [<Date>new Date(), Validators.required], [this.formFieldNames.date]: new FormControl<Date>({ value: new Date(), disabled: true }, [Validators.required]),
[this.formFieldNames.mileage]: [ [this.formFieldNames.mileage]: new FormControl<number | null>({ value: null, disabled: true }, [Validators.required, Validators.min(1)]),
<number | null>null, [this.formFieldNames.amount]: new FormControl<number | null>({ value: null, disabled: true }, [Validators.required, Validators.min(1)]),
[Validators.required, Validators.min(1)],
],
[this.formFieldNames.amount]: [
<number | null>null,
[Validators.required, Validators.min(1)],
],
}); });
private readonly cars$: Observable<Car[]>;
protected readonly cars: Signal<Car[] | undefined>; protected readonly cars: Signal<Car[] | undefined>;
private readonly isEntryDataLoaded = signal(false);
protected readonly isLoading = computed(() => { protected readonly isLoading = computed(() => {
return this.cars() === undefined; var cars = this.cars();
}) var isEntryDataLoaded = this.isEntryDataLoaded();
return cars === undefined || !isEntryDataLoaded;
});
constructor() { constructor() {
this.cars = toSignal( this.cars$ = this.carClient
this.carClient
.getAll() .getAll()
.pipe( .pipe(
takeUntilDestroyed(), takeUntilDestroyed(),
map(response => response.cars) map(response => response.cars)
),
); );
this.cars = toSignal(this.cars$);
}
ngOnInit(): void {
this.loadEntryDetailsAndEnableControls();
}
private loadEntryDetailsAndEnableControls() {
const entryId = this.id();
if (entryId === undefined || entryId === null) {
this.enableFormControls();
this.isEntryDataLoaded.set(true);
return;
}
const consumption$ = this.consumptionClient
.getSingle(entryId);
combineLatest([
consumption$, this.cars$
])
.pipe(
filter(([_, cars]) => cars !== undefined),
takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleGetError(error)),
tap(([consumption, cars]) => {
this.formGroup.patchValue({
[this.formFieldNames.car]: cars!.find(c => c.id === consumption.carId) ?? null,
[this.formFieldNames.date]: new Date(consumption.dateTime),
[this.formFieldNames.mileage]: consumption.distance,
[this.formFieldNames.amount]: consumption.amount,
});
}),
tap(() => {
this.enableFormControls();
this.isEntryDataLoaded.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<void> { async navigateToOverviewPage(): Promise<void> {
@@ -92,7 +139,7 @@ export class EditEntryComponent {
} }
onSubmit(): void { onSubmit(): void {
var entryId = this.entryId(); var entryId = this.id();
if (entryId === undefined || entryId === null) { if (entryId === undefined || entryId === null) {
this.createEntry(); this.createEntry();
return; return;
@@ -118,7 +165,7 @@ export class EditEntryComponent {
this.consumptionClient.create(request) this.consumptionClient.create(request)
.pipe( .pipe(
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleError(error)), catchError((error) => this.handleCreateOrUpdateError(error)),
switchMap(() => this.routingService.navigateToEntries()) switchMap(() => this.routingService.navigateToEntries())
) )
.subscribe(); .subscribe();
@@ -129,13 +176,41 @@ export class EditEntryComponent {
this.consumptionClient.update(id, request) this.consumptionClient.update(id, request)
.pipe( .pipe(
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
catchError((error) => this.handleError(error)), catchError((error) => this.handleCreateOrUpdateError(error)),
switchMap(() => this.routingService.navigateToEntries()) switchMap(() => this.routingService.navigateToEntries())
) )
.subscribe(); .subscribe();
} }
private handleError(error: unknown): Observable<never> { private handleGetError(error: unknown): Observable<never> {
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 Erstellen des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.',
});
break;
default:
console.error(error);
this.messageService.add({
severity: 'error',
summary: 'Unerwarteter Fehler',
detail:
'Beim Erstellen des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.',
});
break;
}
return EMPTY;
}
private handleCreateOrUpdateError(error: unknown): Observable<never> {
if (!(error instanceof HttpErrorResponse)) { if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error); return throwError(() => error);
} }

View File

@@ -11,10 +11,13 @@ import { SelectModule } from 'primeng/select';
import { SkeletonModule } from 'primeng/skeleton'; import { SkeletonModule } from 'primeng/skeleton';
import { import {
BehaviorSubject, BehaviorSubject,
catchError,
combineLatest, combineLatest,
EMPTY,
map, map,
Observable, Observable,
startWith startWith,
throwError
} from 'rxjs'; } from 'rxjs';
import { EntriesOverviewService } from './services/entries-overview.service'; import { EntriesOverviewService } from './services/entries-overview.service';
import { EntryCardComponent } from './components/entry-card/entry-card.component'; import { EntryCardComponent } from './components/entry-card/entry-card.component';
@@ -22,6 +25,7 @@ import {
matAddSharp, matAddSharp,
} from '@ng-icons/material-icons/sharp'; } from '@ng-icons/material-icons/sharp';
import { NgIconComponent, provideIcons } from '@ng-icons/core'; import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { HttpErrorResponse } from '@angular/common/http';
@Component({ @Component({
selector: 'app-entries', selector: 'app-entries',
@@ -62,7 +66,10 @@ export class EntriesComponent {
constructor() { constructor() {
const entries = this.entriesOverviewService.getEntries() const entries = this.entriesOverviewService.getEntries()
.pipe(takeUntilDestroyed()); .pipe(
takeUntilDestroyed(),
catchError((error) => this.handleGetEntriesError(error))
);
this.consumptionEntries$ = combineLatest([ this.consumptionEntries$ = combineLatest([
entries, entries,
@@ -97,4 +104,32 @@ export class EntriesComponent {
detail: 'Der Eintrag wurde erfolgreich gelöscht.', detail: 'Der Eintrag wurde erfolgreich gelöscht.',
}); });
} }
private handleGetEntriesError(error: unknown): Observable<never> {
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;
}
} }

View File

@@ -34,7 +34,9 @@ export class EntriesOverviewService {
return combineLatest([this.cachedCars$!, entries$]) return combineLatest([this.cachedCars$!, entries$])
.pipe( .pipe(
map(([cars, entries]) => { map(([cars, entries]) => {
return entries.map((entry): Consumption => ({ return entries
.sort((a, b) => b.dateTime.localeCompare(a.dateTime))
.map((entry): Consumption => ({
...entry, ...entry,
car: cars.find(car => car.id === entry.carId)! car: cars.find(car => car.id === entry.carId)!
})); }));

View File

@@ -41,6 +41,7 @@ public static class GetConsumptions
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<ResponseDto> consumptions = await dbContext.Consumptions List<ResponseDto> consumptions = await dbContext.Consumptions
.OrderByDescending(x => x.DateTime)
.Select(x => .Select(x =>
new ResponseDto(x.Id.Value, x.DateTime, x.Distance, x.Amount, x.IgnoreInCalculation, x.CarId.Value)) new ResponseDto(x.Id.Value, x.DateTime, x.Distance, x.Amount, x.IgnoreInCalculation, x.CarId.Value))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);