feat(frontend): add producer and consumer invite pages with navigation
This commit is contained in:
parent
4fdc2d5f7a
commit
ffa501e89f
@ -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,
|
||||
|
||||
@ -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']}
|
||||
];
|
||||
|
||||
147
eeg_frontend/src/app/pages/consumer-invites/consumer-invites.ts
Normal file
147
eeg_frontend/src/app/pages/consumer-invites/consumer-invites.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
266
eeg_frontend/src/app/pages/producer-invites/producer-invites.ts
Normal file
266
eeg_frontend/src/app/pages/producer-invites/producer-invites.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user