From ffa501e89f8ba5a096f71884affca96e72e75b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernhard=20M=C3=BCller?= Date: Thu, 23 Jul 2026 15:03:05 +0200 Subject: [PATCH] feat(frontend): add producer and consumer invite pages with navigation --- eeg_frontend/src/app/app.routes.ts | 12 + .../dashboard-layout/dashboard-nav-items.ts | 4 +- .../consumer-invites/consumer-invites.ts | 147 ++++++++++ .../producer-invites/producer-invites.ts | 266 ++++++++++++++++++ 4 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 eeg_frontend/src/app/pages/consumer-invites/consumer-invites.ts create mode 100644 eeg_frontend/src/app/pages/producer-invites/producer-invites.ts diff --git a/eeg_frontend/src/app/app.routes.ts b/eeg_frontend/src/app/app.routes.ts index 6a966ec..662c156 100644 --- a/eeg_frontend/src/app/app.routes.ts +++ b/eeg_frontend/src/app/app.routes.ts @@ -20,6 +20,8 @@ import {AdminTariffComponent} from './pages/admin-tariff/admin-tariff'; import {UserTariffComponent} from './pages/user-tariff/user-tariff'; import {ConsumptionDashboardComponent} from './pages/consumption-dashboard/consumption-dashboard'; import {UploadMeteringDataComponent} from './pages/upload-metering-data/upload-metering-data'; +import {ProducerInvitesComponent} from './pages/producer-invites/producer-invites'; +import {ConsumerInvitesComponent} from './pages/consumer-invites/consumer-invites'; export const routes: Routes = [ { path: '', component: LandingPage }, @@ -84,6 +86,16 @@ export const routes: Routes = [ component: ConsumptionDashboardComponent, canActivate: [roleGuard(['MEMBER'])] }, + { + path: 'producer-invites', + component: ProducerInvitesComponent, + canActivate: [roleGuard(['MEMBER'])] + }, + { + path: 'consumer-invites', + component: ConsumerInvitesComponent, + canActivate: [roleGuard(['MEMBER'])] + }, { path: 'profile', component: ProfileComponent, diff --git a/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts b/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts index c31a108..d1982e7 100644 --- a/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts +++ b/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts @@ -16,5 +16,7 @@ export const NAV_ITEMS: NavItem[] = [ {label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']}, {label: 'Tarife verwalten', path: '/dashboard/tariffs', roles: ['ADMIN']}, {label: 'Messdaten-Upload', path: '/dashboard/upload-metering-data', roles: ['ADMIN']}, - {label: 'Meine Tarife', path: '/dashboard/my-tariffs', roles: ['MEMBER']} + {label: 'Meine Tarife', path: '/dashboard/my-tariffs', roles: ['MEMBER']}, + {label: 'Meine Einladungen', path: '/dashboard/producer-invites', roles: ['MEMBER']}, + {label: 'Offene Einladungen', path: '/dashboard/consumer-invites', roles: ['MEMBER']} ]; diff --git a/eeg_frontend/src/app/pages/consumer-invites/consumer-invites.ts b/eeg_frontend/src/app/pages/consumer-invites/consumer-invites.ts new file mode 100644 index 0000000..a870d4c --- /dev/null +++ b/eeg_frontend/src/app/pages/consumer-invites/consumer-invites.ts @@ -0,0 +1,147 @@ +import {Component, OnInit, inject} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTableModule } from '@angular/material/table'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; + +interface TariffInvite { + id: string; + producerUserId: string; + consumerUserId: string; + energyCommunityId: string; + code: string; + note: string; + status: string; + expiresAt: string; + acceptedAt: string; + createdAt: string; +} + +@Component({ + selector: 'app-consumer-invites', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatTableModule, + MatSnackBarModule, + MatProgressSpinnerModule + ], + template: ` +
+

Offene Einladungen

+ + + + Ausstehende Einladungen + Sie wurden eingeladen, einen Spezial-Tarif zu erhalten + + + @if (isLoading) { +
+ +
+ } @else if (invites.length === 0) { +

Keine offenen Einladungen vorhanden.

+ } @else { +
+ @for (invite of invites; track invite.id) { +
+
+
+

Einladung von Producer

+

Code: {{ invite.code }}

+ @if (invite.note) { +

"{{ invite.note }}"

+ } +

Gültig bis: {{ invite.expiresAt | date:'dd.MM.yyyy HH:mm' }}

+
+
+ + +
+
+
+ } +
+ } +
+
+
+ ` +}) +export class ConsumerInvitesComponent implements OnInit { + private http = inject(HttpClient); + private snackBar = inject(MatSnackBar); + + private apiUrl = `${environment.apiUrl}/api`; + + invites: TariffInvite[] = []; + isLoading = false; + acceptingId: string | null = null; + + ngOnInit() { + this.loadInvites(); + } + + loadInvites() { + this.isLoading = true; + + this.http.get(`${this.apiUrl}/tariffs/invites/pending`) + .subscribe({ + next: (invites) => { + this.invites = invites; + this.isLoading = false; + }, + error: () => { + this.isLoading = false; + this.snackBar.open('Fehler beim Laden der Einladungen', 'Schließen', { duration: 3000 }); + } + }); + } + + acceptInvite(invite: TariffInvite) { + this.acceptingId = invite.id; + + this.http.post(`${this.apiUrl}/tariffs/invites/${invite.id}/accept`, {}) + .subscribe({ + next: () => { + this.snackBar.open('Einladung angenommen! Der Producer kann jetzt einen Spezial-Tarif für Sie erstellen.', 'Schließen', { duration: 5000 }); + this.loadInvites(); + }, + error: (err) => { + this.acceptingId = null; + const message = err.error?.message || 'Fehler beim Annehmen der Einladung'; + this.snackBar.open(message, 'Schließen', { duration: 5000 }); + } + }); + } + + rejectInvite(invite: TariffInvite) { + // For now, we just remove it from the local list + // In a real implementation, we might want to add a reject endpoint + this.snackBar.open('Einladung abgelehnt', 'Schließen', { duration: 3000 }); + this.invites = this.invites.filter(i => i.id !== invite.id); + } +} diff --git a/eeg_frontend/src/app/pages/producer-invites/producer-invites.ts b/eeg_frontend/src/app/pages/producer-invites/producer-invites.ts new file mode 100644 index 0000000..6b8126e --- /dev/null +++ b/eeg_frontend/src/app/pages/producer-invites/producer-invites.ts @@ -0,0 +1,266 @@ +import {Component, OnInit, inject} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTableModule } from '@angular/material/table'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { DashboardService, AdminStats } from '../../services/dashboard'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { AuthService } from '../../services/auth'; + +interface CommunityMember { + userId: string; + firstName: string; + lastName: string; + email: string; +} + +interface TariffInvite { + id: string; + producerUserId: string; + consumerUserId: string; + energyCommunityId: string; + code: string; + note: string; + status: string; + expiresAt: string; + acceptedAt: string; + createdAt: string; +} + +interface EnergyCommunity { + id: string; + name: string; +} + +@Component({ + selector: 'app-producer-invites', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatCardModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatSelectModule, + MatIconModule, + MatTableModule, + MatSnackBarModule, + MatProgressSpinnerModule + ], + template: ` +
+

Meine Einladungen

+ + + + + Neue Einladung erstellen + Laden Sie einen Consumer ein, einen Spezial-Tarif zu erhalten + + + @if (communities.length === 0) { +

Sie sind noch keiner Energiegemeinschaft beigetreten.

+ } @else { +
+ + Energiegemeinschaft + + @for (community of communities; track community.id) { + {{ community.name }} + } + + + + @if (selectedCommunityId) { + + Consumer auswählen + + @for (member of members; track member.userId) { + {{ member.firstName }} {{ member.lastName }} + } + + + + + Notiz (optional) + + + + + } +
+ } +
+
+ + + + + Gesendete Einladungen + + + @if (invites.length === 0) { +

Noch keine Einladungen gesendet.

+ } @else { + + + + + + + + + + + + + + + + + + + + + + + +
Consumer{{ invite.consumerUserId }}Code + {{ invite.code }} + Status + {{ getStatusLabel(invite.status) }} + Gültig bis{{ invite.expiresAt | date:'dd.MM.yyyy HH:mm' }}
+ } +
+
+
+ ` +}) +export class ProducerInvitesComponent implements OnInit { + private http = inject(HttpClient); + private snackBar = inject(MatSnackBar); + private authService = inject(AuthService); + + private apiUrl = `${environment.apiUrl}/api`; + + communities: EnergyCommunity[] = []; + members: CommunityMember[] = []; + invites: TariffInvite[] = []; + + selectedCommunityId: string = ''; + selectedConsumerId: string = ''; + inviteNote: string = ''; + + isLoading = false; + + displayedColumns = ['consumer', 'code', 'status', 'expiresAt']; + + ngOnInit() { + this.loadCommunities(); + } + + loadCommunities() { + this.http.get(`${this.apiUrl}/community/admin/communities`) + .subscribe({ + next: (communities) => this.communities = communities, + error: () => this.snackBar.open('Fehler beim Laden der Energiegemeinschaften', 'Schließen', { duration: 3000 }) + }); + } + + loadMembers() { + if (!this.selectedCommunityId) return; + + this.http.get(`${this.apiUrl}/community/admin/communities/${this.selectedCommunityId}/members`) + .subscribe({ + next: (members) => { + const currentUserId = this.authService.getCurrentUserId(); + this.members = members.filter(m => m.userId !== currentUserId); + }, + error: () => this.snackBar.open('Fehler beim Laden der Mitglieder', 'Schließen', { duration: 3000 }) + }); + + this.loadInvites(); + } + + loadInvites() { + if (!this.selectedCommunityId) return; + + this.http.get(`${this.apiUrl}/tariffs/invites/community/${this.selectedCommunityId}`) + .subscribe({ + next: (invites) => this.invites = invites, + error: () => this.snackBar.open('Fehler beim Laden der Einladungen', 'Schließen', { duration: 3000 }) + }); + } + + createInvite() { + if (!this.selectedConsumerId || !this.selectedCommunityId) return; + + this.isLoading = true; + + const request = { + consumerUserId: this.selectedConsumerId, + energyCommunityId: this.selectedCommunityId, + note: this.inviteNote || null + }; + + this.http.post(`${this.apiUrl}/tariffs/invites`, request) + .subscribe({ + next: (invite) => { + this.isLoading = false; + this.snackBar.open('Einladung erfolgreich gesendet!', 'Schließen', { duration: 3000 }); + this.inviteNote = ''; + this.selectedConsumerId = ''; + this.loadInvites(); + }, + error: (err) => { + this.isLoading = false; + const message = err.error?.message || 'Fehler beim Erstellen der Einladung'; + this.snackBar.open(message, 'Schließen', { duration: 5000 }); + } + }); + } + + getStatusClass(status: string): string { + switch (status) { + case 'PENDING': return 'text-yellow-600 bg-yellow-100 px-2 py-1 rounded'; + case 'ACCEPTED': return 'text-green-600 bg-green-100 px-2 py-1 rounded'; + case 'EXPIRED': return 'text-red-600 bg-red-100 px-2 py-1 rounded'; + default: return 'text-slate-600 bg-slate-100 px-2 py-1 rounded'; + } + } + + getStatusLabel(status: string): string { + switch (status) { + case 'PENDING': return 'Ausstehend'; + case 'ACCEPTED': return 'Angenommen'; + case 'EXPIRED': return 'Abgelaufen'; + default: return status; + } + } +} + +interface TariffInviteRequest { + consumerUserId: string; + energyCommunityId: string; + note: string | null; +}