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:
parent
05ec18d550
commit
838fdb2826
@ -4,13 +4,14 @@ import {routes} from './app.routes';
|
||||
import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
|
||||
import {BASE_PATH} from './api';
|
||||
import {jwtInterceptor} from './interceptors/jwt.interceptor';
|
||||
import {errorInterceptor} from './interceptors/error.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withFetch(),
|
||||
withInterceptors([jwtInterceptor])),
|
||||
withInterceptors([jwtInterceptor, errorInterceptor])),
|
||||
{ provide: BASE_PATH, useValue: 'http://localhost:8080' }
|
||||
]
|
||||
};
|
||||
|
||||
@ -4,3 +4,4 @@
|
||||
<main class="min-h-[calc(100vh-4rem)] bg-gray-50">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-toast></app-toast>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import {Component, signal} from '@angular/core';
|
||||
import {NavigationEnd, Router, RouterOutlet} from '@angular/router';
|
||||
import {HeaderComponent} from './layout/header/header';
|
||||
import {ToastComponent} from './components/toast/toast';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, HeaderComponent],
|
||||
imports: [RouterOutlet, HeaderComponent, ToastComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
|
||||
44
eeg_frontend/src/app/components/toast/toast.ts
Normal file
44
eeg_frontend/src/app/components/toast/toast.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
31
eeg_frontend/src/app/interceptors/error.interceptor.ts
Normal file
31
eeg_frontend/src/app/interceptors/error.interceptor.ts
Normal 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);
|
||||
})
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,15 @@
|
||||
<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">
|
||||
<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">
|
||||
@ -17,6 +26,7 @@
|
||||
<a [routerLink]="item.path"
|
||||
routerLinkActive="bg-emerald-50 text-emerald-700 font-medium"
|
||||
[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">
|
||||
{{ item.label }}
|
||||
</a>
|
||||
@ -26,15 +36,23 @@
|
||||
|
||||
<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">
|
||||
<h1 class="text-lg font-semibold text-slate-800">
|
||||
<header class="h-16 bg-white border-b border-slate-200 flex items-center px-4 md:px-8 justify-between shrink-0">
|
||||
<!-- 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' }}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-500">
|
||||
Rolle: <span class="font-semibold text-gray-700">{{ authService.currentUserRole() }}</span>
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 hidden sm:inline">
|
||||
Rolle: <span class="font-semibold text-gray-700">{{ authService.currentUserRole() }}</span>
|
||||
</span>
|
||||
<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">
|
||||
Abmelden
|
||||
@ -42,7 +60,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-auto p-8">
|
||||
<div class="flex-1 overflow-auto p-4 md:p-8">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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 {AuthService} from '../../services/auth';
|
||||
import {NAV_ITEMS} from './dashboard-nav-items';
|
||||
@ -14,11 +14,21 @@ export class DashboardLayout {
|
||||
authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
isMobileMenuOpen = signal(false);
|
||||
|
||||
visibleNavItems = computed(() => {
|
||||
const role = this.authService.currentUserRole();
|
||||
return NAV_ITEMS.filter(item => role !== null && item.roles.includes(role));
|
||||
});
|
||||
|
||||
toggleMobileMenu() {
|
||||
this.isMobileMenuOpen.update(v => !v);
|
||||
}
|
||||
|
||||
closeMobileMenu() {
|
||||
this.isMobileMenuOpen.set(false);
|
||||
}
|
||||
|
||||
onLogout() {
|
||||
this.authService.logout();
|
||||
this.router.navigate(['/']);
|
||||
|
||||
@ -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>
|
||||
|
||||
@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()) {
|
||||
<!-- 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 -->
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Persönliche Daten</h2>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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') {
|
||||
<form [formGroup]="profileForm" (ngSubmit)="updateProfile()">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Organisationsname</label>
|
||||
<input type="text" [(ngModel)]="editOrganizationName"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Vorname *</label>
|
||||
<input type="text" formControlName="firstName"
|
||||
[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>
|
||||
<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">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
|
||||
<div class="text-gray-900">{{ profile()!.email }}</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
|
||||
<div class="text-gray-900">{{ profile()!.email }}</div>
|
||||
</div>
|
||||
|
||||
<button (click)="updateProfile()" [disabled]="isSaving()"
|
||||
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' }}
|
||||
</button>
|
||||
<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">
|
||||
{{ isSaving() ? 'Speichere...' : 'Profil speichern' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Passwort ändern -->
|
||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Passwort ändern</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Aktuelles Passwort</label>
|
||||
<input type="password" [(ngModel)]="currentPassword"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<form [formGroup]="passwordForm" (ngSubmit)="changePassword()">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Aktuelles Passwort *</label>
|
||||
<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>
|
||||
<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()"
|
||||
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' }}
|
||||
</button>
|
||||
<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">
|
||||
{{ isSaving() ? 'Ändere...' : 'Passwort ändern' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- E-Mail ändern -->
|
||||
<div class="bg-white shadow rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">E-Mail-Adresse ändern</h2>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Neue E-Mail-Adresse</label>
|
||||
<input type="email" [(ngModel)]="newEmail" placeholder="neu@example.com"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<form [formGroup]="emailForm" (ngSubmit)="changeEmail()">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Neue E-Mail-Adresse *</label>
|
||||
<input type="email" formControlName="newEmail" placeholder="neu@example.com"
|
||||
[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()"
|
||||
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' }}
|
||||
</button>
|
||||
<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">
|
||||
{{ isSaving() ? 'Ändere...' : 'E-Mail ändern' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -1,32 +1,39 @@
|
||||
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 {ToastService} from '../../services/toast';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
imports: [ReactiveFormsModule],
|
||||
templateUrl: './profile.html'
|
||||
})
|
||||
export class ProfileComponent implements OnInit {
|
||||
private userService = inject(UserService);
|
||||
private fb = inject(FormBuilder);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
profile = signal<UserProfile | null>(null);
|
||||
isLoading = signal(true);
|
||||
isSaving = signal(false);
|
||||
successMessage = signal('');
|
||||
errorMessage = signal('');
|
||||
|
||||
editFirstName = '';
|
||||
editLastName = '';
|
||||
editOrganizationName = '';
|
||||
editParticipantType = 'PRIVATE';
|
||||
profileForm: FormGroup = this.fb.group({
|
||||
firstName: ['', [Validators.required, Validators.minLength(2)]],
|
||||
lastName: ['', [Validators.required, Validators.minLength(2)]],
|
||||
organizationName: [''],
|
||||
participantType: ['PRIVATE']
|
||||
});
|
||||
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
newPasswordConfirm = '';
|
||||
passwordForm: FormGroup = this.fb.group({
|
||||
currentPassword: ['', [Validators.required]],
|
||||
newPassword: ['', [Validators.required, Validators.minLength(8)]],
|
||||
newPasswordConfirm: ['', [Validators.required]]
|
||||
});
|
||||
|
||||
newEmail = '';
|
||||
emailForm: FormGroup = this.fb.group({
|
||||
newEmail: ['', [Validators.required, Validators.email]]
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.loadProfile();
|
||||
@ -36,10 +43,12 @@ export class ProfileComponent implements OnInit {
|
||||
this.userService.getProfile().subscribe({
|
||||
next: profile => {
|
||||
this.profile.set(profile);
|
||||
this.editFirstName = profile.firstName || '';
|
||||
this.editLastName = profile.lastName || '';
|
||||
this.editOrganizationName = profile.organizationName || '';
|
||||
this.editParticipantType = profile.participantType;
|
||||
this.profileForm.patchValue({
|
||||
firstName: profile.firstName || '',
|
||||
lastName: profile.lastName || '',
|
||||
organizationName: profile.organizationName || '',
|
||||
participantType: profile.participantType
|
||||
});
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: () => this.isLoading.set(false)
|
||||
@ -47,76 +56,69 @@ export class ProfileComponent implements OnInit {
|
||||
}
|
||||
|
||||
updateProfile() {
|
||||
if (this.isSaving()) return;
|
||||
if (this.isSaving() || this.profileForm.invalid) return;
|
||||
this.isSaving.set(true);
|
||||
this.successMessage.set('');
|
||||
this.errorMessage.set('');
|
||||
|
||||
const request: UpdateProfileRequest = {
|
||||
firstName: this.editFirstName,
|
||||
lastName: this.editLastName,
|
||||
organizationName: this.editOrganizationName || undefined,
|
||||
participantType: this.editParticipantType as 'PRIVATE' | 'COMPANY'
|
||||
};
|
||||
const request: UpdateProfileRequest = this.profileForm.value;
|
||||
|
||||
this.userService.updateProfile(request).subscribe({
|
||||
next: profile => {
|
||||
this.profile.set(profile);
|
||||
this.isSaving.set(false);
|
||||
this.successMessage.set('Profil erfolgreich aktualisiert.');
|
||||
this.toastService.success('Profil erfolgreich aktualisiert.');
|
||||
},
|
||||
error: (err) => {
|
||||
this.isSaving.set(false);
|
||||
this.errorMessage.set(err.error?.message || 'Fehler beim Aktualisieren.');
|
||||
this.toastService.error(err.error?.message || 'Fehler beim Aktualisieren.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
changePassword() {
|
||||
if (this.isSaving() || !this.currentPassword || !this.newPassword) return;
|
||||
if (this.newPassword !== this.newPasswordConfirm) {
|
||||
this.errorMessage.set('Passwörter stimmen nicht überein.');
|
||||
if (this.isSaving() || this.passwordForm.invalid) return;
|
||||
|
||||
if (this.passwordForm.value.newPassword !== this.passwordForm.value.newPasswordConfirm) {
|
||||
this.toastService.warning('Passwörter stimmen nicht überein.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSaving.set(true);
|
||||
this.successMessage.set('');
|
||||
this.errorMessage.set('');
|
||||
|
||||
this.userService.changePassword({
|
||||
currentPassword: this.currentPassword,
|
||||
newPassword: this.newPassword
|
||||
currentPassword: this.passwordForm.value.currentPassword,
|
||||
newPassword: this.passwordForm.value.newPassword
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.isSaving.set(false);
|
||||
this.successMessage.set('Passwort erfolgreich geändert.');
|
||||
this.currentPassword = '';
|
||||
this.newPassword = '';
|
||||
this.newPasswordConfirm = '';
|
||||
this.toastService.success('Passwort erfolgreich geändert.');
|
||||
this.passwordForm.reset();
|
||||
},
|
||||
error: (err) => {
|
||||
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() {
|
||||
if (this.isSaving() || !this.newEmail) return;
|
||||
if (this.isSaving() || this.emailForm.invalid) return;
|
||||
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: () => {
|
||||
this.isSaving.set(false);
|
||||
this.successMessage.set('E-Mail-Adresse geändert. Bitte neue Adresse verifizieren.');
|
||||
this.newEmail = '';
|
||||
this.toastService.success('E-Mail-Adresse geändert. Bitte neue Adresse verifizieren.');
|
||||
this.emailForm.reset();
|
||||
},
|
||||
error: (err) => {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
50
eeg_frontend/src/app/services/toast.ts
Normal file
50
eeg_frontend/src/app/services/toast.ts
Normal 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([]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user