feat(frontend): add tab navigation and table view to AdminMembershipsComponent

- Add three tabs: Ausstehende Anträge / Aktive Mitgliedschaften / Alle
- Card view for pending tab (existing approve/reject behavior preserved)
- Table view for active and all tabs (Name, Email, Community, Zählpunkt, Priorität, Status)
- getStatusLabel() for German status display (Ausstehend/Aktiv/Inaktiv)
- getStatusBadgeClass() for color-coded status badges
- Client-side filtering for instant tab switching
- Add comprehensive tests (19 test cases)
- Add getAllUsers() to AdminIAMService (fixes compilation for admin-user-approval)
- Fix deleteUserTariff missing userId argument in user-tariff component
This commit is contained in:
Bernhard Müller 2026-07-23 14:46:27 +02:00
parent dbd39643aa
commit ef933ed0ea
5 changed files with 684 additions and 40 deletions

View File

@ -0,0 +1,255 @@
/**
* OpenAPI definition
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/* tslint:disable:no-unused-variable member-ordering */
import { Inject, Injectable, Optional } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams,
HttpResponse, HttpEvent, HttpContext
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { OpenApiHttpParams, QueryParamStyle } from '../query.params';
// @ts-ignore
import { UserProfileResponse } from '../model/userProfileResponse';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS } from '../variables';
import { Configuration } from '../configuration';
import { BaseService } from '../api.base.service';
@Injectable({
providedIn: 'root'
})
export class AdminIAMService extends BaseService {
constructor(protected httpClient: HttpClient, @Optional() @Inject(BASE_PATH) basePath: string|string[], @Optional() configuration?: Configuration) {
super(basePath, configuration);
}
/**
* User freigeben
* Setzt enabled=true, status=APPROVED und triggert die finale EDA-Anmeldung.
* @endpoint post /api/admin/users/{userId}/approve
* @param userId
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
* @param reportProgress flag to report request and response progress.
* @param options additional options
*/
public approveUser(userId: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<any>;
public approveUser(userId: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<HttpResponse<any>>;
public approveUser(userId: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<HttpEvent<any>>;
public approveUser(userId: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<any> {
if (userId === null || userId === undefined) {
throw new Error('Required parameter userId was null or undefined when calling approveUser.');
}
let localVarHeaders = this.defaultHeaders;
const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([
]);
if (localVarHttpHeaderAcceptSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected);
}
const localVarHttpContext: HttpContext = options?.context ?? new HttpContext();
const localVarTransferCache: boolean = options?.transferCache ?? true;
let responseType_: 'text' | 'json' | 'blob' = 'json';
if (localVarHttpHeaderAcceptSelected) {
if (localVarHttpHeaderAcceptSelected.startsWith('text')) {
responseType_ = 'text';
} else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) {
responseType_ = 'json';
} else {
responseType_ = 'blob';
}
}
let localVarPath = `/api/admin/users/${this.configuration.encodeParam({name: "userId", value: userId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: "uuid"})}/approve`;
const { basePath, withCredentials } = this.configuration;
return this.httpClient.request<any>('post', `${basePath}${localVarPath}`,
{
context: localVarHttpContext,
responseType: <any>responseType_,
...(withCredentials ? { withCredentials } : {}),
headers: localVarHeaders,
observe: observe,
...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}),
reportProgress: reportProgress
}
);
}
/**
* Lädt alle wartenden User
* Zeigt User im Status PENDING inkl. der Ergebnisse des EDA-Netz-Checks.
* @endpoint get /api/admin/users/pending
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
* @param reportProgress flag to report request and response progress.
* @param options additional options
*/
public getPendingUsers(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable<Array<UserProfileResponse>>;
public getPendingUsers(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable<HttpResponse<Array<UserProfileResponse>>>;
public getPendingUsers(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable<HttpEvent<Array<UserProfileResponse>>>;
public getPendingUsers(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable<any> {
let localVarHeaders = this.defaultHeaders;
const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([
'application/json'
]);
if (localVarHttpHeaderAcceptSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected);
}
const localVarHttpContext: HttpContext = options?.context ?? new HttpContext();
const localVarTransferCache: boolean = options?.transferCache ?? true;
let responseType_: 'text' | 'json' | 'blob' = 'json';
if (localVarHttpHeaderAcceptSelected) {
if (localVarHttpHeaderAcceptSelected.startsWith('text')) {
responseType_ = 'text';
} else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) {
responseType_ = 'json';
} else {
responseType_ = 'blob';
}
}
let localVarPath = `/api/admin/users/pending`;
const { basePath, withCredentials } = this.configuration;
return this.httpClient.request<Array<UserProfileResponse>>('get', `${basePath}${localVarPath}`,
{
context: localVarHttpContext,
responseType: <any>responseType_,
...(withCredentials ? { withCredentials } : {}),
headers: localVarHeaders,
observe: observe,
...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}),
reportProgress: reportProgress
}
);
}
/**
* Lädt alle User
* Zeigt alle registrierten Benutzer.
* @endpoint get /api/admin/users
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
* @param reportProgress flag to report request and response progress.
* @param options additional options
*/
public getAllUsers(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable<Array<UserProfileResponse>>;
public getAllUsers(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable<HttpResponse<Array<UserProfileResponse>>>;
public getAllUsers(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable<HttpEvent<Array<UserProfileResponse>>>;
public getAllUsers(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable<any> {
let localVarHeaders = this.defaultHeaders;
const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([
'application/json'
]);
if (localVarHttpHeaderAcceptSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected);
}
const localVarHttpContext: HttpContext = options?.context ?? new HttpContext();
const localVarTransferCache: boolean = options?.transferCache ?? true;
let responseType_: 'text' | 'json' | 'blob' = 'json';
if (localVarHttpHeaderAcceptSelected) {
if (localVarHttpHeaderAcceptSelected.startsWith('text')) {
responseType_ = 'text';
} else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) {
responseType_ = 'json';
} else {
responseType_ = 'blob';
}
}
let localVarPath = `/api/admin/users`;
const { basePath, withCredentials } = this.configuration;
return this.httpClient.request<Array<UserProfileResponse>>('get', `${basePath}${localVarPath}`,
{
context: localVarHttpContext,
responseType: <any>responseType_,
...(withCredentials ? { withCredentials } : {}),
headers: localVarHeaders,
observe: observe,
...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}),
reportProgress: reportProgress
}
);
}
/**
* User ablehnen
* Lehnt den Antrag ab (z.B. weil Zählpunkt nicht ins Netz passt).
* @endpoint post /api/admin/users/{userId}/reject
* @param userId
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
* @param reportProgress flag to report request and response progress.
* @param options additional options
*/
public rejectUser(userId: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<any>;
public rejectUser(userId: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<HttpResponse<any>>;
public rejectUser(userId: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<HttpEvent<any>>;
public rejectUser(userId: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<any> {
if (userId === null || userId === undefined) {
throw new Error('Required parameter userId was null or undefined when calling rejectUser.');
}
let localVarHeaders = this.defaultHeaders;
const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([
]);
if (localVarHttpHeaderAcceptSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected);
}
const localVarHttpContext: HttpContext = options?.context ?? new HttpContext();
const localVarTransferCache: boolean = options?.transferCache ?? true;
let responseType_: 'text' | 'json' | 'blob' = 'json';
if (localVarHttpHeaderAcceptSelected) {
if (localVarHttpHeaderAcceptSelected.startsWith('text')) {
responseType_ = 'text';
} else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) {
responseType_ = 'json';
} else {
responseType_ = 'blob';
}
}
let localVarPath = `/api/admin/users/${this.configuration.encodeParam({name: "userId", value: userId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: "uuid"})}/reject`;
const { basePath, withCredentials } = this.configuration;
return this.httpClient.request<any>('post', `${basePath}${localVarPath}`,
{
context: localVarHttpContext,
responseType: <any>responseType_,
...(withCredentials ? { withCredentials } : {}),
headers: localVarHeaders,
observe: observe,
...(localVarTransferCache !== undefined ? { transferCache: localVarTransferCache } : {}),
reportProgress: reportProgress
}
);
}
}

View File

@ -1,12 +1,48 @@
<div class="max-w-4xl mx-auto p-4 md:p-6">
<h1 class="text-2xl font-bold mb-6">Mitgliedschaftsanträge</h1>
<div class="max-w-6xl mx-auto p-4 md:p-6">
<h1 class="text-2xl font-bold mb-6">Mitgliedschaften verwalten</h1>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 mb-6">
<nav class="flex gap-4" role="tablist">
<button role="tab"
[attr.aria-selected]="activeTab() === 'pending'"
(click)="switchTab('pending')"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
[class]="activeTab() === 'pending'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'">
Ausstehende Anträge
</button>
<button role="tab"
[attr.aria-selected]="activeTab() === 'active'"
(click)="switchTab('active')"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
[class]="activeTab() === 'active'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'">
Aktive Mitgliedschaften
</button>
<button role="tab"
[attr.aria-selected]="activeTab() === 'all'"
(click)="switchTab('all')"
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
[class]="activeTab() === 'all'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'">
Alle
</button>
</nav>
</div>
@if (isLoading()) {
<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 Anträge...</div>
<div>Lade Daten...</div>
</div>
} @else if (pendingMemberships().length === 0) {
} @else {
<!-- Pending Tab: Card View -->
@if (activeTab() === 'pending') {
@if (pendingMemberships().length === 0) {
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<p class="text-gray-600">Keine wartenden Mitgliedschaftsanträge vorhanden.</p>
</div>
@ -43,4 +79,54 @@
}
</div>
}
}
<!-- Active / All Tab: Table View -->
@if (activeTab() === 'active' || activeTab() === 'all') {
@let displayMemberships = activeTab() === 'all' ? allMemberships() : allMemberships().filter(m => m.status === 'ACTIVE');
@if (displayMemberships.length === 0) {
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<p class="text-gray-600">
@if (activeTab() === 'active') {
Keine aktiven Mitgliedschaften vorhanden.
} @else {
Keine Mitgliedschaften vorhanden.
}
</p>
</div>
} @else {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Community</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Zählpunkt</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Priorität</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@for (membership of displayMemberships; track membership.id) {
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-900">{{ membership.firstName }} {{ membership.lastName }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ membership.userEmail }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ membership.communityName }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ membership.atNumber }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ membership.priorityLevel }}</td>
<td class="px-4 py-3 text-sm">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
[class]="getStatusBadgeClass(membership.status)">
{{ getStatusLabel(membership.status) }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
}
</div>

View File

@ -0,0 +1,247 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { AdminMembershipsComponent } from './admin-memberships';
import { PendingMembershipResponse } from '../../services/membership';
describe('AdminMembershipsComponent', () => {
let component: AdminMembershipsComponent;
let fixture: ComponentFixture<AdminMembershipsComponent>;
let httpMock: HttpTestingController;
const mockPendingMembership: PendingMembershipResponse = {
id: '1',
userEmail: 'max@test.at',
firstName: 'Max',
lastName: 'Mustermann',
atNumber: 'AT0000000000000000001',
communityName: 'Test Community',
priorityLevel: 1,
status: 'PENDING',
};
const mockActiveMembership: PendingMembershipResponse = {
id: '2',
userEmail: 'anna@test.at',
firstName: 'Anna',
lastName: 'Schmidt',
atNumber: 'AT0000000000000000002',
communityName: 'Active Community',
priorityLevel: 2,
status: 'ACTIVE',
};
const mockInactiveMembership: PendingMembershipResponse = {
id: '3',
userEmail: 'tom@test.at',
firstName: 'Tom',
lastName: 'Berger',
atNumber: 'AT0000000000000000003',
communityName: 'Inactive Community',
priorityLevel: 3,
status: 'INACTIVE',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminMembershipsComponent],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
fixture = TestBed.createComponent(AdminMembershipsComponent);
component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should create', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
req.flush([]);
expect(component).toBeTruthy();
});
it('should default activeTab to pending', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
req.flush([]);
expect(component.activeTab()).toBe('pending');
});
it('should initialize allMemberships as empty array', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
req.flush([]);
expect(component.allMemberships()).toEqual([]);
});
it('should load pending memberships on init', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
expect(req.request.method).toBe('GET');
req.flush([mockPendingMembership]);
expect(component.pendingMemberships().length).toBe(1);
expect(component.pendingMemberships()[0].firstName).toBe('Max');
});
it('should load all memberships when switching to active tab', () => {
fixture.detectChanges();
const pendingReq = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
pendingReq.flush([]);
component.switchTab('active');
const allReq = httpMock.expectOne(
(r) => r.url.includes('/admin/memberships') && !r.url.includes('/pending'),
);
expect(allReq.request.method).toBe('GET');
allReq.flush([mockPendingMembership, mockActiveMembership, mockInactiveMembership]);
expect(component.allMemberships().length).toBe(3);
});
it('should load all memberships when switching to all tab', () => {
fixture.detectChanges();
const pendingReq = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
pendingReq.flush([]);
component.switchTab('all');
const allReq = httpMock.expectOne(
(r) => r.url.includes('/admin/memberships') && !r.url.includes('/pending'),
);
expect(allReq.request.method).toBe('GET');
allReq.flush([mockPendingMembership, mockActiveMembership]);
expect(component.allMemberships().length).toBe(2);
});
it('should switch activeTab signal', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
req.flush([]);
component.switchTab('active');
const activeReq = httpMock.expectOne(
(r) => r.url.includes('/admin/memberships') && !r.url.includes('/pending'),
);
activeReq.flush([]);
expect(component.activeTab()).toBe('active');
component.switchTab('pending');
expect(component.activeTab()).toBe('pending');
component.switchTab('all');
const allReq = httpMock.expectOne(
(r) => r.url.includes('/admin/memberships') && !r.url.includes('/pending'),
);
allReq.flush([]);
expect(component.activeTab()).toBe('all');
});
describe('getStatusLabel', () => {
it('should return "Ausstehend" for PENDING', () => {
expect(component.getStatusLabel('PENDING')).toBe('Ausstehend');
});
it('should return "Aktiv" for ACTIVE', () => {
expect(component.getStatusLabel('ACTIVE')).toBe('Aktiv');
});
it('should return "Inaktiv" for INACTIVE', () => {
expect(component.getStatusLabel('INACTIVE')).toBe('Inaktiv');
});
it('should return the original status for unknown values', () => {
expect(component.getStatusLabel('UNKNOWN')).toBe('UNKNOWN');
});
});
describe('getStatusBadgeClass', () => {
it('should return yellow badge for PENDING', () => {
expect(component.getStatusBadgeClass('PENDING')).toContain('yellow');
});
it('should return green badge for ACTIVE', () => {
expect(component.getStatusBadgeClass('ACTIVE')).toContain('green');
});
it('should return red badge for INACTIVE', () => {
expect(component.getStatusBadgeClass('INACTIVE')).toContain('red');
});
});
describe('template', () => {
it('should render three tabs', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
req.flush([]);
fixture.detectChanges();
const tabs = fixture.nativeElement.querySelectorAll('[role="tab"]');
expect(tabs.length).toBe(3);
expect(tabs[0].textContent).toContain('Ausstehende Anträge');
expect(tabs[1].textContent).toContain('Aktive Mitgliedschaften');
expect(tabs[2].textContent).toContain('Alle');
});
it('should show pending tab as active by default', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
req.flush([]);
fixture.detectChanges();
const activeTab = fixture.nativeElement.querySelector('[role="tab"][aria-selected="true"]');
expect(activeTab?.textContent).toContain('Ausstehende Anträge');
});
it('should show card view for pending tab', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
req.flush([mockPendingMembership]);
fixture.detectChanges();
const cards = fixture.nativeElement.querySelectorAll('.bg-white.border');
expect(cards.length).toBe(1);
});
it('should show table view for active tab', () => {
fixture.detectChanges();
const pendingReq = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
pendingReq.flush([]);
component.switchTab('active');
const allReq = httpMock.expectOne(
(r) => r.url.includes('/admin/memberships') && !r.url.includes('/pending'),
);
allReq.flush([mockActiveMembership]);
fixture.detectChanges();
const table = fixture.nativeElement.querySelector('table');
expect(table).toBeTruthy();
const rows = fixture.nativeElement.querySelectorAll('tbody tr');
expect(rows.length).toBe(1);
});
it('should show table view for all tab', () => {
fixture.detectChanges();
const pendingReq = httpMock.expectOne((r) => r.url.includes('/admin/memberships/pending'));
pendingReq.flush([]);
component.switchTab('all');
const allReq = httpMock.expectOne(
(r) => r.url.includes('/admin/memberships') && !r.url.includes('/pending'),
);
allReq.flush([mockPendingMembership, mockActiveMembership]);
fixture.detectChanges();
const table = fixture.nativeElement.querySelector('table');
expect(table).toBeTruthy();
const rows = fixture.nativeElement.querySelectorAll('tbody tr');
expect(rows.length).toBe(2);
});
});
});

View File

@ -1,17 +1,21 @@
import {Component, inject, OnInit, signal} from '@angular/core';
import {MembershipService, PendingMembershipResponse} from '../../services/membership';
import {ToastService} from '../../services/toast';
import { Component, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MembershipService, PendingMembershipResponse } from '../../services/membership';
import { ToastService } from '../../services/toast';
@Component({
selector: 'app-admin-memberships',
standalone: true,
templateUrl: './admin-memberships.html'
templateUrl: './admin-memberships.html',
})
export class AdminMembershipsComponent implements OnInit {
private membershipService = inject(MembershipService);
private toastService = inject(ToastService);
private destroyRef = inject(DestroyRef);
activeTab = signal<'pending' | 'active' | 'all'>('pending');
pendingMemberships = signal<PendingMembershipResponse[]>([]);
allMemberships = signal<PendingMembershipResponse[]>([]);
isLoading = signal(true);
isProcessing = signal<string | null>(null);
@ -19,8 +23,16 @@ export class AdminMembershipsComponent implements OnInit {
this.loadPendingMemberships();
}
switchTab(tab: 'pending' | 'active' | 'all') {
this.activeTab.set(tab);
if ((tab === 'active' || tab === 'all') && this.allMemberships().length === 0) {
this.loadAllMemberships();
}
}
loadPendingMemberships() {
this.membershipService.getPendingMemberships().subscribe({
this.isLoading.set(true);
this.membershipService.getPendingMemberships().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: memberships => {
this.pendingMemberships.set(memberships);
this.isLoading.set(false);
@ -32,6 +44,46 @@ export class AdminMembershipsComponent implements OnInit {
});
}
loadAllMemberships() {
this.isLoading.set(true);
this.membershipService.getAllMemberships().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: memberships => {
this.allMemberships.set(memberships);
this.isLoading.set(false);
},
error: () => {
this.isLoading.set(false);
this.toastService.error('Fehler beim Laden aller Mitgliedschaften.');
}
});
}
getStatusLabel(status: string): string {
switch (status) {
case 'PENDING':
return 'Ausstehend';
case 'ACTIVE':
return 'Aktiv';
case 'INACTIVE':
return 'Inaktiv';
default:
return status;
}
}
getStatusBadgeClass(status: string): string {
switch (status) {
case 'PENDING':
return 'bg-yellow-100 text-yellow-800';
case 'ACTIVE':
return 'bg-green-100 text-green-800';
case 'INACTIVE':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
approveMembership(id: string) {
if (this.isProcessing()) return;
this.isProcessing.set(id);

View File

@ -5,6 +5,7 @@ import {UserTariffControllerService, TariffControllerService, UserTariffRequest,
import {MeteringPointService, MeteringPoint} from '../../services/metering-point';
import {MembershipService, MembershipResponse} from '../../services/membership';
import {ToastService} from '../../services/toast';
import {AuthService} from '../../services/auth';
@Component({
selector: 'app-user-tariff',
@ -19,6 +20,7 @@ export class UserTariffComponent implements OnInit, OnDestroy {
private meteringPointService = inject(MeteringPointService);
private membershipService = inject(MembershipService);
private toastService = inject(ToastService);
private authService = inject(AuthService);
private fb = inject(FormBuilder);
memberships = signal<MembershipResponse[]>([]);
@ -187,7 +189,9 @@ export class UserTariffComponent implements OnInit, OnDestroy {
onDelete(tariff: UserTariffResponse) {
if (!confirm('Möchtest du diesen Tarif wirklich löschen?')) return;
this.userTariffService.deleteUserTariff(tariff.id!).subscribe({
const userId = this.authService.getCurrentUserId();
if (!userId) return;
this.userTariffService.deleteUserTariff(tariff.id!, userId).subscribe({
next: () => {
this.toastService.success('Tarif erfolgreich gelöscht.');
this.loadUserTariffs(this.selectedCommunityId());