feat(frontend): add responsive design, form validation, toast service and error handling

- Responsive navigation with mobile hamburger menu
- Reactive forms with real-time validation on profile page
- Toast service for success/error/warning messages
- HTTP error interceptor for 401/403/500 handling
- Loading states with spinner animations
This commit is contained in:
Bernhard Müller 2026-07-21 12:08:39 +02:00
parent 05ec18d550
commit 838fdb2826
10 changed files with 299 additions and 132 deletions

View File

@ -4,13 +4,14 @@ import {routes} from './app.routes';
import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http'; import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
import {BASE_PATH} from './api'; import {BASE_PATH} from './api';
import {jwtInterceptor} from './interceptors/jwt.interceptor'; import {jwtInterceptor} from './interceptors/jwt.interceptor';
import {errorInterceptor} from './interceptors/error.interceptor';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes), provideRouter(routes),
provideHttpClient(withFetch(), provideHttpClient(withFetch(),
withInterceptors([jwtInterceptor])), withInterceptors([jwtInterceptor, errorInterceptor])),
{ provide: BASE_PATH, useValue: 'http://localhost:8080' } { provide: BASE_PATH, useValue: 'http://localhost:8080' }
] ]
}; };

View File

@ -4,3 +4,4 @@
<main class="min-h-[calc(100vh-4rem)] bg-gray-50"> <main class="min-h-[calc(100vh-4rem)] bg-gray-50">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<app-toast></app-toast>

View File

@ -1,10 +1,11 @@
import {Component, signal} from '@angular/core'; import {Component, signal} from '@angular/core';
import {NavigationEnd, Router, RouterOutlet} from '@angular/router'; import {NavigationEnd, Router, RouterOutlet} from '@angular/router';
import {HeaderComponent} from './layout/header/header'; import {HeaderComponent} from './layout/header/header';
import {ToastComponent} from './components/toast/toast';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, HeaderComponent], imports: [RouterOutlet, HeaderComponent, ToastComponent],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
}) })

View File

@ -0,0 +1,44 @@
import { Component, inject } from '@angular/core';
import { ToastService } from '../../services/toast';
@Component({
selector: 'app-toast',
standalone: true,
template: `
<div class="fixed bottom-4 right-4 z-50 space-y-2">
@for (toast of toastService.toasts$(); track toast.id) {
<div
class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium max-w-sm animate-slide-in"
[class]="getToastClass(toast.type)">
{{ toast.message }}
<button
(click)="toastService.dismiss(toast.id)"
class="ml-2 text-white opacity-70 hover:opacity-100">
×
</button>
</div>
}
</div>
`,
styles: [`
@keyframes slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
`]
})
export class ToastComponent {
toastService = inject(ToastService);
getToastClass(type: string): string {
switch (type) {
case 'success': return 'bg-green-500';
case 'error': return 'bg-red-500';
case 'warning': return 'bg-yellow-500';
default: return 'bg-blue-500';
}
}
}

View File

@ -0,0 +1,31 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../services/auth';
import { ToastService } from '../services/toast';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const authService = inject(AuthService);
const toastService = inject(ToastService);
return next(req).pipe(
catchError(error => {
if (error.status === 401) {
// Unauthorized - auto logout
authService.logout();
router.navigate(['/login']);
toastService.error('Sitzung abgelaufen. Bitte erneut einloggen.');
} else if (error.status === 403) {
toastService.error('Keine Berechtigung für diese Aktion.');
} else if (error.status === 404) {
toastService.error('Ressource nicht gefunden.');
} else if (error.status >= 500) {
toastService.error('Ein Serverfehler ist aufgetreten. Bitte versuchen Sie es später erneut.');
}
return throwError(() => error);
})
);
};

View File

@ -1,6 +1,15 @@
<div class="flex h-screen bg-slate-50"> <div class="flex h-screen bg-slate-50">
<aside class="w-64 bg-white border-r border-slate-200 flex flex-col z-10 shrink-0"> <!-- Mobile Overlay -->
@if (isMobileMenuOpen()) {
<div class="fixed inset-0 bg-black bg-opacity-50 z-20 md:hidden"
(click)="closeMobileMenu()"></div>
}
<!-- Sidebar -->
<aside [class]="isMobileMenuOpen()
? 'fixed inset-y-0 left-0 w-64 bg-white border-r border-slate-200 flex flex-col z-30 transform transition-transform duration-200 ease-in-out'
: 'fixed inset-y-0 left-0 w-64 bg-white border-r border-slate-200 flex flex-col z-30 transform transition-transform duration-200 ease-in-out -translate-x-full md:translate-x-0 md:static md:z-10'">
<div class="h-16 flex items-center px-6 border-b border-slate-200 shrink-0"> <div class="h-16 flex items-center px-6 border-b border-slate-200 shrink-0">
<a routerLink="/" class="flex items-center space-x-2 group"> <a routerLink="/" class="flex items-center space-x-2 group">
<svg class="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@ -17,6 +26,7 @@
<a [routerLink]="item.path" <a [routerLink]="item.path"
routerLinkActive="bg-emerald-50 text-emerald-700 font-medium" routerLinkActive="bg-emerald-50 text-emerald-700 font-medium"
[routerLinkActiveOptions]="{exact: item.path === '/dashboard'}" [routerLinkActiveOptions]="{exact: item.path === '/dashboard'}"
(click)="closeMobileMenu()"
class="block px-4 py-2.5 rounded-lg text-slate-600 hover:bg-slate-50 hover:text-slate-900 transition-colors"> class="block px-4 py-2.5 rounded-lg text-slate-600 hover:bg-slate-50 hover:text-slate-900 transition-colors">
{{ item.label }} {{ item.label }}
</a> </a>
@ -26,15 +36,23 @@
<main class="flex-1 flex flex-col min-w-0 overflow-hidden"> <main class="flex-1 flex flex-col min-w-0 overflow-hidden">
<header class="h-16 bg-white border-b border-slate-200 flex items-center px-8 justify-between shrink-0"> <header class="h-16 bg-white border-b border-slate-200 flex items-center px-4 md:px-8 justify-between shrink-0">
<h1 class="text-lg font-semibold text-slate-800"> <!-- Mobile Menu Button -->
<button (click)="toggleMobileMenu()"
class="md:hidden p-2 rounded-md text-slate-600 hover:text-slate-900 hover:bg-slate-100">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<h1 class="text-lg font-semibold text-slate-800 hidden md:block">
{{ authService.currentUserRole() === 'ADMIN' ? 'Verwaltung' : 'Mein Bereich' }} {{ authService.currentUserRole() === 'ADMIN' ? 'Verwaltung' : 'Mein Bereich' }}
</h1> </h1>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span class="text-sm text-gray-500"> <span class="text-sm text-gray-500 hidden sm:inline">
Rolle: <span class="font-semibold text-gray-700">{{ authService.currentUserRole() }}</span> Rolle: <span class="font-semibold text-gray-700">{{ authService.currentUserRole() }}</span>
</span> </span>
<button (click)="onLogout()" <button (click)="onLogout()"
class="text-sm font-medium text-red-600 hover:text-red-700 border border-red-200 hover:border-red-300 px-3 py-1.5 rounded-md transition duration-150"> class="text-sm font-medium text-red-600 hover:text-red-700 border border-red-200 hover:border-red-300 px-3 py-1.5 rounded-md transition duration-150">
Abmelden Abmelden
@ -42,7 +60,7 @@
</div> </div>
</header> </header>
<div class="flex-1 overflow-auto p-8"> <div class="flex-1 overflow-auto p-4 md:p-8">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div> </div>

View File

@ -1,4 +1,4 @@
import {Component, computed, inject} from '@angular/core'; import {Component, computed, inject, signal} from '@angular/core';
import {Router, RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router'; import {Router, RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
import {AuthService} from '../../services/auth'; import {AuthService} from '../../services/auth';
import {NAV_ITEMS} from './dashboard-nav-items'; import {NAV_ITEMS} from './dashboard-nav-items';
@ -14,11 +14,21 @@ export class DashboardLayout {
authService = inject(AuthService); authService = inject(AuthService);
private router = inject(Router); private router = inject(Router);
isMobileMenuOpen = signal(false);
visibleNavItems = computed(() => { visibleNavItems = computed(() => {
const role = this.authService.currentUserRole(); const role = this.authService.currentUserRole();
return NAV_ITEMS.filter(item => role !== null && item.roles.includes(role)); return NAV_ITEMS.filter(item => role !== null && item.roles.includes(role));
}); });
toggleMobileMenu() {
this.isMobileMenuOpen.update(v => !v);
}
closeMobileMenu() {
this.isMobileMenuOpen.set(false);
}
onLogout() { onLogout() {
this.authService.logout(); this.authService.logout();
this.router.navigate(['/']); this.router.navigate(['/']);

View File

@ -1,106 +1,115 @@
<div class="max-w-2xl mx-auto p-6"> <div class="max-w-2xl mx-auto p-4 md:p-6">
<h1 class="text-2xl font-bold mb-6">Mein Profil</h1> <h1 class="text-2xl font-bold mb-6">Mein Profil</h1>
@if (isLoading()) { @if (isLoading()) {
<div class="text-center py-8 text-gray-500">Lade Profil...</div> <div class="text-center py-8 text-gray-500">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mb-2"></div>
<div>Lade Profil...</div>
</div>
} @else if (profile()) { } @else if (profile()) {
<!-- Erfolg/Fehler-Meldungen -->
@if (successMessage()) {
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ successMessage() }}
</div>
}
@if (errorMessage()) {
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ errorMessage() }}
</div>
}
<!-- Profil-Informationen --> <!-- Profil-Informationen -->
<div class="bg-white shadow rounded-lg p-6 mb-6"> <div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">Persönliche Daten</h2> <h2 class="text-lg font-semibold mb-4">Persönliche Daten</h2>
<div class="grid grid-cols-2 gap-4"> <form [formGroup]="profileForm" (ngSubmit)="updateProfile()">
<div> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Vorname</label>
<input type="text" [(ngModel)]="editFirstName"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nachname</label>
<input type="text" [(ngModel)]="editLastName"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Teilnehmertyp</label>
<select [(ngModel)]="editParticipantType"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="PRIVATE">Privatperson</option>
<option value="COMPANY">Juristische Person</option>
</select>
</div>
@if (editParticipantType === 'COMPANY') {
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Organisationsname</label> <label class="block text-sm font-medium text-gray-700 mb-1">Vorname *</label>
<input type="text" [(ngModel)]="editOrganizationName" <input type="text" formControlName="firstName"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> [class]="isFieldInvalid(profileForm, 'firstName') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
@if (isFieldInvalid(profileForm, 'firstName')) {
<p class="text-red-500 text-xs mt-1">Vorname ist erforderlich (min. 2 Zeichen)</p>
}
</div> </div>
} <div>
</div> <label class="block text-sm font-medium text-gray-700 mb-1">Nachname *</label>
<input type="text" formControlName="lastName"
[class]="isFieldInvalid(profileForm, 'lastName') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
@if (isFieldInvalid(profileForm, 'lastName')) {
<p class="text-red-500 text-xs mt-1">Nachname ist erforderlich (min. 2 Zeichen)</p>
}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Teilnehmertyp</label>
<select formControlName="participantType"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="PRIVATE">Privatperson</option>
<option value="COMPANY">Juristische Person</option>
</select>
</div>
@if (profileForm.get('participantType')?.value === 'COMPANY') {
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Organisationsname</label>
<input type="text" formControlName="organizationName"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
}
</div>
<div class="mt-4"> <div class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-1">E-Mail</label> <label class="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<div class="text-gray-900">{{ profile()!.email }}</div> <div class="text-gray-900">{{ profile()!.email }}</div>
</div> </div>
<button (click)="updateProfile()" [disabled]="isSaving()" <button type="submit" [disabled]="isSaving() || profileForm.invalid"
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"> class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ isSaving() ? 'Speichere...' : 'Profil speichern' }} {{ isSaving() ? 'Speichere...' : 'Profil speichern' }}
</button> </button>
</form>
</div> </div>
<!-- Passwort ändern --> <!-- Passwort ändern -->
<div class="bg-white shadow rounded-lg p-6 mb-6"> <div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">Passwort ändern</h2> <h2 class="text-lg font-semibold mb-4">Passwort ändern</h2>
<div class="space-y-4"> <form [formGroup]="passwordForm" (ngSubmit)="changePassword()">
<div> <div class="space-y-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Aktuelles Passwort</label> <div>
<input type="password" [(ngModel)]="currentPassword" <label class="block text-sm font-medium text-gray-700 mb-1">Aktuelles Passwort *</label>
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> <input type="password" formControlName="currentPassword"
[class]="isFieldInvalid(passwordForm, 'currentPassword') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Neues Passwort *</label>
<input type="password" formControlName="newPassword"
[class]="isFieldInvalid(passwordForm, 'newPassword') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
@if (isFieldInvalid(passwordForm, 'newPassword')) {
<p class="text-red-500 text-xs mt-1">Passwort muss mindestens 8 Zeichen lang sein</p>
}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Neues Passwort bestätigen *</label>
<input type="password" formControlName="newPasswordConfirm"
[class]="isFieldInvalid(passwordForm, 'newPasswordConfirm') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
</div>
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Neues Passwort</label>
<input type="password" [(ngModel)]="newPassword"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Neues Passwort bestätigen</label>
<input type="password" [(ngModel)]="newPasswordConfirm"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<button (click)="changePassword()" [disabled]="isSaving()" <button type="submit" [disabled]="isSaving() || passwordForm.invalid"
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"> class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ isSaving() ? 'Ändere...' : 'Passwort ändern' }} {{ isSaving() ? 'Ändere...' : 'Passwort ändern' }}
</button> </button>
</form>
</div> </div>
<!-- E-Mail ändern --> <!-- E-Mail ändern -->
<div class="bg-white shadow rounded-lg p-6"> <div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">E-Mail-Adresse ändern</h2> <h2 class="text-lg font-semibold mb-4">E-Mail-Adresse ändern</h2>
<div> <form [formGroup]="emailForm" (ngSubmit)="changeEmail()">
<label class="block text-sm font-medium text-gray-700 mb-1">Neue E-Mail-Adresse</label> <div>
<input type="email" [(ngModel)]="newEmail" placeholder="neu@example.com" <label class="block text-sm font-medium text-gray-700 mb-1">Neue E-Mail-Adresse *</label>
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" /> <input type="email" formControlName="newEmail" placeholder="neu@example.com"
</div> [class]="isFieldInvalid(emailForm, 'newEmail') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
@if (isFieldInvalid(emailForm, 'newEmail')) {
<p class="text-red-500 text-xs mt-1">Bitte geben Sie eine gültige E-Mail-Adresse ein</p>
}
</div>
<button (click)="changeEmail()" [disabled]="isSaving()" <button type="submit" [disabled]="isSaving() || emailForm.invalid"
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"> class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ isSaving() ? 'Ändere...' : 'E-Mail ändern' }} {{ isSaving() ? 'Ändere...' : 'E-Mail ändern' }}
</button> </button>
</form>
</div> </div>
} }
</div> </div>

View File

@ -1,32 +1,39 @@
import {Component, inject, OnInit, signal} from '@angular/core'; import {Component, inject, OnInit, signal} from '@angular/core';
import {FormsModule} from '@angular/forms'; import {ReactiveFormsModule, FormBuilder, FormGroup, Validators} from '@angular/forms';
import {UserService, UserProfile, UpdateProfileRequest} from '../../services/user'; import {UserService, UserProfile, UpdateProfileRequest} from '../../services/user';
import {ToastService} from '../../services/toast';
@Component({ @Component({
selector: 'app-profile', selector: 'app-profile',
standalone: true, standalone: true,
imports: [FormsModule], imports: [ReactiveFormsModule],
templateUrl: './profile.html' templateUrl: './profile.html'
}) })
export class ProfileComponent implements OnInit { export class ProfileComponent implements OnInit {
private userService = inject(UserService); private userService = inject(UserService);
private fb = inject(FormBuilder);
private toastService = inject(ToastService);
profile = signal<UserProfile | null>(null); profile = signal<UserProfile | null>(null);
isLoading = signal(true); isLoading = signal(true);
isSaving = signal(false); isSaving = signal(false);
successMessage = signal('');
errorMessage = signal('');
editFirstName = ''; profileForm: FormGroup = this.fb.group({
editLastName = ''; firstName: ['', [Validators.required, Validators.minLength(2)]],
editOrganizationName = ''; lastName: ['', [Validators.required, Validators.minLength(2)]],
editParticipantType = 'PRIVATE'; organizationName: [''],
participantType: ['PRIVATE']
});
currentPassword = ''; passwordForm: FormGroup = this.fb.group({
newPassword = ''; currentPassword: ['', [Validators.required]],
newPasswordConfirm = ''; newPassword: ['', [Validators.required, Validators.minLength(8)]],
newPasswordConfirm: ['', [Validators.required]]
});
newEmail = ''; emailForm: FormGroup = this.fb.group({
newEmail: ['', [Validators.required, Validators.email]]
});
ngOnInit() { ngOnInit() {
this.loadProfile(); this.loadProfile();
@ -36,10 +43,12 @@ export class ProfileComponent implements OnInit {
this.userService.getProfile().subscribe({ this.userService.getProfile().subscribe({
next: profile => { next: profile => {
this.profile.set(profile); this.profile.set(profile);
this.editFirstName = profile.firstName || ''; this.profileForm.patchValue({
this.editLastName = profile.lastName || ''; firstName: profile.firstName || '',
this.editOrganizationName = profile.organizationName || ''; lastName: profile.lastName || '',
this.editParticipantType = profile.participantType; organizationName: profile.organizationName || '',
participantType: profile.participantType
});
this.isLoading.set(false); this.isLoading.set(false);
}, },
error: () => this.isLoading.set(false) error: () => this.isLoading.set(false)
@ -47,76 +56,69 @@ export class ProfileComponent implements OnInit {
} }
updateProfile() { updateProfile() {
if (this.isSaving()) return; if (this.isSaving() || this.profileForm.invalid) return;
this.isSaving.set(true); this.isSaving.set(true);
this.successMessage.set('');
this.errorMessage.set('');
const request: UpdateProfileRequest = { const request: UpdateProfileRequest = this.profileForm.value;
firstName: this.editFirstName,
lastName: this.editLastName,
organizationName: this.editOrganizationName || undefined,
participantType: this.editParticipantType as 'PRIVATE' | 'COMPANY'
};
this.userService.updateProfile(request).subscribe({ this.userService.updateProfile(request).subscribe({
next: profile => { next: profile => {
this.profile.set(profile); this.profile.set(profile);
this.isSaving.set(false); this.isSaving.set(false);
this.successMessage.set('Profil erfolgreich aktualisiert.'); this.toastService.success('Profil erfolgreich aktualisiert.');
}, },
error: (err) => { error: (err) => {
this.isSaving.set(false); this.isSaving.set(false);
this.errorMessage.set(err.error?.message || 'Fehler beim Aktualisieren.'); this.toastService.error(err.error?.message || 'Fehler beim Aktualisieren.');
} }
}); });
} }
changePassword() { changePassword() {
if (this.isSaving() || !this.currentPassword || !this.newPassword) return; if (this.isSaving() || this.passwordForm.invalid) return;
if (this.newPassword !== this.newPasswordConfirm) {
this.errorMessage.set('Passwörter stimmen nicht überein.'); if (this.passwordForm.value.newPassword !== this.passwordForm.value.newPasswordConfirm) {
this.toastService.warning('Passwörter stimmen nicht überein.');
return; return;
} }
this.isSaving.set(true); this.isSaving.set(true);
this.successMessage.set('');
this.errorMessage.set('');
this.userService.changePassword({ this.userService.changePassword({
currentPassword: this.currentPassword, currentPassword: this.passwordForm.value.currentPassword,
newPassword: this.newPassword newPassword: this.passwordForm.value.newPassword
}).subscribe({ }).subscribe({
next: () => { next: () => {
this.isSaving.set(false); this.isSaving.set(false);
this.successMessage.set('Passwort erfolgreich geändert.'); this.toastService.success('Passwort erfolgreich geändert.');
this.currentPassword = ''; this.passwordForm.reset();
this.newPassword = '';
this.newPasswordConfirm = '';
}, },
error: (err) => { error: (err) => {
this.isSaving.set(false); this.isSaving.set(false);
this.errorMessage.set(err.error?.message || 'Fehler beim Ändern des Passworts.'); this.toastService.error(err.error?.message || 'Fehler beim Ändern des Passworts.');
} }
}); });
} }
changeEmail() { changeEmail() {
if (this.isSaving() || !this.newEmail) return; if (this.isSaving() || this.emailForm.invalid) return;
this.isSaving.set(true); this.isSaving.set(true);
this.successMessage.set('');
this.errorMessage.set('');
this.userService.changeEmail({newEmail: this.newEmail}).subscribe({ this.userService.changeEmail({newEmail: this.emailForm.value.newEmail}).subscribe({
next: () => { next: () => {
this.isSaving.set(false); this.isSaving.set(false);
this.successMessage.set('E-Mail-Adresse geändert. Bitte neue Adresse verifizieren.'); this.toastService.success('E-Mail-Adresse geändert. Bitte neue Adresse verifizieren.');
this.newEmail = ''; this.emailForm.reset();
}, },
error: (err) => { error: (err) => {
this.isSaving.set(false); this.isSaving.set(false);
this.errorMessage.set(err.error?.message || 'Fehler beim Ändern der E-Mail.'); this.toastService.error(err.error?.message || 'Fehler beim Ändern der E-Mail.');
} }
}); });
} }
isFieldInvalid(form: FormGroup, fieldName: string): boolean {
const field = form.get(fieldName);
return !!(field && field.invalid && (field.dirty || field.touched));
}
} }

View File

@ -0,0 +1,50 @@
import { Injectable, signal } from '@angular/core';
export interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'warning' | 'info';
}
@Injectable({ providedIn: 'root' })
export class ToastService {
private toasts = signal<Toast[]>([]);
private nextId = 0;
get toasts$() {
return this.toasts.asReadonly();
}
show(message: string, type: Toast['type'] = 'info', duration = 3000) {
const id = this.nextId++;
const toast: Toast = { id, message, type };
this.toasts.update(toasts => [...toasts, toast]);
if (duration > 0) {
setTimeout(() => this.dismiss(id), duration);
}
return id;
}
success(message: string, duration = 3000) {
return this.show(message, 'success', duration);
}
error(message: string, duration = 5000) {
return this.show(message, 'error', duration);
}
warning(message: string, duration = 4000) {
return this.show(message, 'warning', duration);
}
dismiss(id: number) {
this.toasts.update(toasts => toasts.filter(t => t.id !== id));
}
clear() {
this.toasts.set([]);
}
}