feat(frontend): add producer and consumer invite pages with navigation

This commit is contained in:
Bernhard Müller 2026-07-23 15:03:05 +02:00
parent 4fdc2d5f7a
commit ffa501e89f
4 changed files with 428 additions and 1 deletions

View File

@ -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,

View File

@ -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']}
];

View File

@ -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: `
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Offene Einladungen</h1>
<mat-card>
<mat-card-header>
<mat-card-title>Ausstehende Einladungen</mat-card-title>
<mat-card-subtitle>Sie wurden eingeladen, einen Spezial-Tarif zu erhalten</mat-card-subtitle>
</mat-card-header>
<mat-card-content class="mt-4">
@if (isLoading) {
<div class="flex justify-center py-8">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else if (invites.length === 0) {
<p class="text-slate-500 text-center py-8">Keine offenen Einladungen vorhanden.</p>
} @else {
<div class="space-y-4">
@for (invite of invites; track invite.id) {
<div class="border border-slate-200 rounded-lg p-4">
<div class="flex justify-between items-start">
<div>
<p class="font-medium text-slate-800">Einladung von Producer</p>
<p class="text-sm text-slate-500 mt-1">Code: <code class="bg-slate-100 px-2 py-1 rounded">{{ invite.code }}</code></p>
@if (invite.note) {
<p class="text-sm text-slate-600 mt-2 italic">"{{ invite.note }}"</p>
}
<p class="text-xs text-slate-400 mt-2">Gültig bis: {{ invite.expiresAt | date:'dd.MM.yyyy HH:mm' }}</p>
</div>
<div class="flex gap-2">
<button mat-raised-button color="primary" (click)="acceptInvite(invite)"
[disabled]="acceptingId === invite.id">
@if (acceptingId === invite.id) {
<mat-spinner diameter="20"></mat-spinner>
} @else {
<ng-container>
<mat-icon>check</mat-icon>
Annehmen
</ng-container>
}
</button>
<button mat-stroked-button (click)="rejectInvite(invite)"
[disabled]="acceptingId === invite.id">
<mat-icon>close</mat-icon>
Ablehnen
</button>
</div>
</div>
</div>
}
</div>
}
</mat-card-content>
</mat-card>
</div>
`
})
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<TariffInvite[]>(`${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<TariffInvite>(`${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);
}
}

View File

@ -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: `
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Meine Einladungen</h1>
<!-- Neue Einladung erstellen -->
<mat-card>
<mat-card-header>
<mat-card-title>Neue Einladung erstellen</mat-card-title>
<mat-card-subtitle>Laden Sie einen Consumer ein, einen Spezial-Tarif zu erhalten</mat-card-subtitle>
</mat-card-header>
<mat-card-content class="mt-4">
@if (communities.length === 0) {
<p class="text-slate-500">Sie sind noch keiner Energiegemeinschaft beigetreten.</p>
} @else {
<div class="space-y-4">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Energiegemeinschaft</mat-label>
<mat-select [(ngModel)]="selectedCommunityId" (selectionChange)="loadMembers()">
@for (community of communities; track community.id) {
<mat-option [value]="community.id">{{ community.name }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (selectedCommunityId) {
<mat-form-field appearance="outline" class="w-full">
<mat-label>Consumer auswählen</mat-label>
<mat-select [(ngModel)]="selectedConsumerId">
@for (member of members; track member.userId) {
<mat-option [value]="member.userId">{{ member.firstName }} {{ member.lastName }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Notiz (optional)</mat-label>
<textarea matInput [(ngModel)]="inviteNote" rows="3"
placeholder="z.B. Ich biete Ihnen einen Spezial-Tarif von 15 Cent/kWh an..."></textarea>
</mat-form-field>
<button mat-raised-button color="primary" (click)="createInvite()"
[disabled]="!selectedConsumerId || isLoading">
@if (isLoading) {
<mat-spinner diameter="20"></mat-spinner>
} @else {
<ng-container>
<mat-icon>send</mat-icon>
Einladung senden
</ng-container>
}
</button>
}
</div>
}
</mat-card-content>
</mat-card>
<!-- Gesendete Einladungen -->
<mat-card>
<mat-card-header>
<mat-card-title>Gesendete Einladungen</mat-card-title>
</mat-card-header>
<mat-card-content class="mt-4">
@if (invites.length === 0) {
<p class="text-slate-500">Noch keine Einladungen gesendet.</p>
} @else {
<table mat-table [dataSource]="invites" class="w-full">
<ng-container matColumnDef="consumer">
<th mat-header-cell *matHeaderCellDef>Consumer</th>
<td mat-cell *matCellDef="let invite">{{ invite.consumerUserId }}</td>
</ng-container>
<ng-container matColumnDef="code">
<th mat-header-cell *matHeaderCellDef>Code</th>
<td mat-cell *matCellDef="let invite">
<code class="bg-slate-100 px-2 py-1 rounded">{{ invite.code }}</code>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let invite">
<span [class]="getStatusClass(invite.status)">{{ getStatusLabel(invite.status) }}</span>
</td>
</ng-container>
<ng-container matColumnDef="expiresAt">
<th mat-header-cell *matHeaderCellDef>Gültig bis</th>
<td mat-cell *matCellDef="let invite">{{ invite.expiresAt | date:'dd.MM.yyyy HH:mm' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
}
</mat-card-content>
</mat-card>
</div>
`
})
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<EnergyCommunity[]>(`${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<CommunityMember[]>(`${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<TariffInvite[]>(`${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<TariffInviteRequest>(`${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;
}