Merge branch 'mimocode/curious-nebula'

This commit is contained in:
Bernhard Müller 2026-07-23 14:33:24 +02:00
commit dbd39643aa
3 changed files with 439 additions and 68 deletions

View File

@ -4,6 +4,36 @@
<p class="text-gray-600 mt-2">Prüfung der ausstehenden Anmeldungen und EDA-Netz-Checks.</p> <p class="text-gray-600 mt-2">Prüfung der ausstehenden Anmeldungen und EDA-Netz-Checks.</p>
</div> </div>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 mb-6">
<nav class="flex gap-8" aria-label="Tabs">
<button
role="tab"
[attr.aria-selected]="activeTab() === 'pending'"
[class]="
activeTab() === 'pending'
? 'border-b-2 border-indigo-500 text-indigo-600 py-3 px-1 text-sm font-medium'
: 'border-b-2 border-transparent text-gray-500 hover:text-gray-700 py-3 px-1 text-sm font-medium'
"
(click)="switchTab('pending')"
>
Ausstehende Anträge
</button>
<button
role="tab"
[attr.aria-selected]="activeTab() === 'all'"
[class]="
activeTab() === 'all'
? 'border-b-2 border-indigo-500 text-indigo-600 py-3 px-1 text-sm font-medium'
: 'border-b-2 border-transparent text-gray-500 hover:text-gray-700 py-3 px-1 text-sm font-medium'
"
(click)="switchTab('all')"
>
Alle Benutzer
</button>
</nav>
</div>
@if (errorMessage()) { @if (errorMessage()) {
<div class="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6" role="alert"> <div class="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6" role="alert">
{{ errorMessage() }} {{ errorMessage() }}
@ -12,55 +42,67 @@
@if (isLoading()) { @if (isLoading()) {
<div class="flex items-center justify-center py-12"> <div class="flex items-center justify-center py-12">
<div class="text-gray-500 text-lg">Lade ausstehende Anträge...</div> <div class="text-gray-500 text-lg">Lade Daten...</div>
</div> </div>
} }
@if (!isLoading() && pendingUsers().length === 0) { @if (!isLoading() && activeTab() === 'pending') {
@if (pendingUsers().length === 0) {
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center"> <div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-gray-600 text-lg">Aktuell gibt es keine neuen User im Status PENDING.</p> <p class="text-gray-600 text-lg">Aktuell gibt es keine neuen User im Status PENDING.</p>
</div> </div>
} } @else {
@if (!isLoading() && pendingUsers().length > 0) {
<div class="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden"> <div class="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-left border-collapse"> <table class="w-full text-left border-collapse">
<thead> <thead>
<tr class="bg-gray-50 border-b border-gray-200"> <tr class="bg-gray-50 border-b border-gray-200">
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">Benutzer</th> <th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">Status</th> Benutzer
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">Status</th> </th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider text-right">Aktionen</th> <th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">
Status
</th>
<th
class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider text-right"
>
Aktionen
</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200">
@for (user of pendingUsers(); track user.id) { @for (user of pendingUsers(); track user.id) {
<tr class="hover:bg-gray-50 transition-colors"> <tr class="hover:bg-gray-50 transition-colors">
<td class="p-4"> <td class="p-4">
<div class="font-medium text-gray-900">{{ user.firstName }} {{ user.lastName }}</div> <div class="font-medium text-gray-900">
{{ user.firstName }} {{ user.lastName }}
</div>
<div class="text-sm text-gray-500">{{ user.email }}</div> <div class="text-sm text-gray-500">{{ user.email }}</div>
</td> </td>
<td class="p-4"> <td class="p-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"> <span
{{ user.status }} [class]="
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ' +
getStatusBadgeClass(user.status || 'PENDING')
"
>
{{ getStatusLabel(user.status || 'PENDING') }}
</span> </span>
</td> </td>
<td class="p-4 flex justify-end gap-3"> <td class="p-4 flex justify-end gap-3">
<button (click)="approveUser(user.id)" <button
class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition"> (click)="approveUser(user.id)"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition"
>
Freigeben Freigeben
</button> </button>
<button
<button (click)="rejectUser(user)" (click)="rejectUser(user)"
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition"> class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition"
>
Ablehnen Ablehnen
</button> </button>
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
@ -68,4 +110,58 @@
</div> </div>
</div> </div>
} }
}
@if (!isLoading() && activeTab() === 'all') {
@if (allUsers().length === 0) {
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-gray-600 text-lg">Keine Benutzer vorhanden.</p>
</div>
} @else {
<div class="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">
Benutzer
</th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">
Status
</th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">
Rolle
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@for (user of allUsers(); track user.id) {
<tr class="hover:bg-gray-50 transition-colors">
<td class="p-4">
<div class="font-medium text-gray-900">
{{ user.firstName }} {{ user.lastName }}
</div>
<div class="text-sm text-gray-500">{{ user.email }}</div>
</td>
<td class="p-4">
<span
[class]="
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ' +
getStatusBadgeClass(user.status || 'UNKNOWN')
"
>
{{ getStatusLabel(user.status || 'UNKNOWN') }}
</span>
</td>
<td class="p-4 text-sm text-gray-700">
{{ user.participantType === 'COMPANY' ? 'Unternehmen' : 'Privatperson' }}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div> </div>

View File

@ -1,22 +1,230 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { vi } from 'vitest';
import { AdminUserApprovalComponent } from './admin-user-approval'; import { AdminUserApprovalComponent } from './admin-user-approval';
import { UserProfileResponse } from '../../api';
describe('AdminUserApproval', () => { describe('AdminUserApprovalComponent', () => {
let component: AdminUserApprovalComponent; let component: AdminUserApprovalComponent;
let fixture: ComponentFixture<AdminUserApprovalComponent>; let fixture: ComponentFixture<AdminUserApprovalComponent>;
let httpMock: HttpTestingController;
const mockPendingUser: UserProfileResponse = {
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
email: 'max@test.at',
status: 'PENDING',
participantType: UserProfileResponse.ParticipantTypeEnum.Private,
};
const mockApprovedUser: UserProfileResponse = {
id: '2',
firstName: 'Anna',
lastName: 'Schmidt',
email: 'anna@test.at',
status: 'APPROVED',
participantType: UserProfileResponse.ParticipantTypeEnum.Company,
};
const mockRejectedUser: UserProfileResponse = {
id: '3',
firstName: 'Tom',
lastName: 'Berger',
email: 'tom@test.at',
status: 'REJECTED',
participantType: UserProfileResponse.ParticipantTypeEnum.Private,
};
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [AdminUserApprovalComponent], imports: [AdminUserApprovalComponent],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(AdminUserApprovalComponent); fixture = TestBed.createComponent(AdminUserApprovalComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
}); });
it('should create', () => { it('should create', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([]);
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it('should default activeTab to pending', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([]);
expect(component.activeTab()).toBe('pending');
});
it('should initialize allUsers as empty array', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([]);
expect(component.allUsers()).toEqual([]);
});
it('should load pending users on init', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
expect(req.request.method).toBe('GET');
req.flush([mockPendingUser]);
expect(component.pendingUsers().length).toBe(1);
expect(component.pendingUsers()[0].firstName).toBe('Max');
});
it('should load all users when switching to all tab', () => {
fixture.detectChanges();
const pendingReq = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
pendingReq.flush([]);
component.switchTab('all');
const allReq = httpMock.expectOne(
(r) => r.url.includes('/admin/users') && !r.url.includes('/pending'),
);
expect(allReq.request.method).toBe('GET');
allReq.flush([mockPendingUser, mockApprovedUser, mockRejectedUser]);
expect(component.allUsers().length).toBe(3);
});
it('should switch activeTab signal', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([]);
component.switchTab('all');
const allReq = httpMock.expectOne(
(r) => r.url.includes('/admin/users') && !r.url.includes('/pending'),
);
allReq.flush([]);
expect(component.activeTab()).toBe('all');
component.switchTab('pending');
expect(component.activeTab()).toBe('pending');
});
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 "Freigeschaltet" for APPROVED', () => {
expect(component.getStatusLabel('APPROVED')).toBe('Freigeschaltet');
});
it('should return "Inaktiv" for INACTIVE', () => {
expect(component.getStatusLabel('INACTIVE')).toBe('Inaktiv');
});
it('should return "Abgelehnt" for REJECTED', () => {
expect(component.getStatusLabel('REJECTED')).toBe('Abgelehnt');
});
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 APPROVED', () => {
expect(component.getStatusBadgeClass('APPROVED')).toContain('green');
});
it('should return green badge for ACTIVE', () => {
expect(component.getStatusBadgeClass('ACTIVE')).toContain('green');
});
it('should return red badge for REJECTED', () => {
expect(component.getStatusBadgeClass('REJECTED')).toContain('red');
});
it('should return red badge for INACTIVE', () => {
expect(component.getStatusBadgeClass('INACTIVE')).toContain('red');
});
});
it('should approve user and remove from pending list', () => {
fixture.detectChanges();
const pendingReq = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
pendingReq.flush([mockPendingUser]);
vi.spyOn(window, 'confirm').mockReturnValue(true);
component.approveUser(mockPendingUser.id);
const approveReq = httpMock.expectOne((r) => r.url.includes('/approve'));
approveReq.flush({});
expect(component.pendingUsers().length).toBe(0);
});
it('should reject user and remove from pending list', () => {
fixture.detectChanges();
const pendingReq = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
pendingReq.flush([mockPendingUser]);
vi.spyOn(window, 'confirm').mockReturnValue(true);
component.rejectUser(mockPendingUser);
const rejectReq = httpMock.expectOne((r) => r.url.includes('/reject'));
rejectReq.flush({});
expect(component.pendingUsers().length).toBe(0);
});
describe('template', () => {
it('should render tab navigation', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([]);
fixture.detectChanges();
const tabs = fixture.nativeElement.querySelectorAll('[role="tab"]');
expect(tabs.length).toBe(2);
expect(tabs[0].textContent).toContain('Ausstehende Anträge');
expect(tabs[1].textContent).toContain('Alle Benutzer');
});
it('should show pending tab as active by default', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/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 only one Status column header (no duplicate)', () => {
fixture.detectChanges();
const req = httpMock.expectOne((r) => r.url.includes('/admin/users/pending'));
req.flush([mockPendingUser]);
fixture.detectChanges();
const allHeaders = fixture.nativeElement.querySelectorAll('th');
let statusCount = 0;
for (let i = 0; i < allHeaders.length; i++) {
if (allHeaders[i].textContent?.trim() === 'Status') {
statusCount++;
}
}
expect(statusCount).toBe(1);
});
});
}); });

View File

@ -8,11 +8,12 @@ import {ToastService} from '../../services/toast';
standalone: true, standalone: true,
imports: [], imports: [],
templateUrl: './admin-user-approval.html', templateUrl: './admin-user-approval.html',
styleUrls: ['./admin-user-approval.scss'] styleUrls: ['./admin-user-approval.scss'],
}) })
export class AdminUserApprovalComponent implements OnInit { export class AdminUserApprovalComponent implements OnInit {
// Signals für reaktives State-Management (perfekt für den Zoneless-Modus) public readonly activeTab = signal<'pending' | 'all'>('pending');
public readonly pendingUsers = signal<UserProfileResponse[]>([]); public readonly pendingUsers = signal<UserProfileResponse[]>([]);
public readonly allUsers = signal<UserProfileResponse[]>([]);
public readonly isLoading = signal<boolean>(true); public readonly isLoading = signal<boolean>(true);
public readonly errorMessage = signal<string>(''); public readonly errorMessage = signal<string>('');
@ -24,11 +25,53 @@ export class AdminUserApprovalComponent implements OnInit {
this.loadPendingUsers(); this.loadPendingUsers();
} }
public switchTab(tab: 'pending' | 'all'): void {
this.activeTab.set(tab);
if (tab === 'all' && this.allUsers().length === 0) {
this.loadAllUsers();
}
}
public getStatusLabel(status: string): string {
switch (status) {
case 'PENDING':
return 'Ausstehend';
case 'ACTIVE':
return 'Aktiv';
case 'APPROVED':
return 'Freigeschaltet';
case 'INACTIVE':
return 'Inaktiv';
case 'REJECTED':
return 'Abgelehnt';
default:
return status;
}
}
public 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 'APPROVED':
return 'bg-green-100 text-green-800';
case 'REJECTED':
return 'bg-red-100 text-red-800';
case 'INACTIVE':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
private loadPendingUsers(): void { private loadPendingUsers(): void {
this.isLoading.set(true); this.isLoading.set(true);
this.errorMessage.set(''); this.errorMessage.set('');
this.adminService.getPendingUsers() this.adminService
.getPendingUsers()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: (users: UserProfileResponse[]) => { next: (users: UserProfileResponse[]) => {
@ -38,7 +81,26 @@ export class AdminUserApprovalComponent implements OnInit {
error: () => { error: () => {
this.errorMessage.set('Fehler beim Laden der Daten aus dem EDA-Netz-Check.'); this.errorMessage.set('Fehler beim Laden der Daten aus dem EDA-Netz-Check.');
this.isLoading.set(false); this.isLoading.set(false);
},
});
} }
private loadAllUsers(): void {
this.isLoading.set(true);
this.errorMessage.set('');
this.adminService
.getAllUsers()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (users: UserProfileResponse[]) => {
this.allUsers.set(users);
this.isLoading.set(false);
},
error: () => {
this.errorMessage.set('Fehler beim Laden aller Benutzer.');
this.isLoading.set(false);
},
}); });
} }
@ -46,29 +108,34 @@ export class AdminUserApprovalComponent implements OnInit {
if (!userId) return; if (!userId) return;
this.errorMessage.set(''); this.errorMessage.set('');
this.adminService.approveUser(userId) this.adminService
.approveUser(userId)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
this.pendingUsers.update(users => users.filter(user => user.id !== userId)); this.pendingUsers.update((users) => users.filter((user) => user.id !== userId));
}, },
error: () => { error: () => {
this.errorMessage.set('Der User konnte nicht freigegeben werden.'); this.errorMessage.set('Der User konnte nicht freigegeben werden.');
} },
}); });
} }
public rejectUser(user: UserProfileResponse): void { public rejectUser(user: UserProfileResponse): void {
if (!user.id) return; if (!user.id) return;
if (confirm(`Möchtest du die Registrierung von ${user.firstName} ${user.lastName} wirklich ablehnen?`)) { if (
confirm(
`Möchtest du die Registrierung von ${user.firstName} ${user.lastName} wirklich ablehnen?`,
)
) {
this.adminService.rejectUser(user.id).subscribe({ this.adminService.rejectUser(user.id).subscribe({
next: () => { next: () => {
this.pendingUsers.update(users => users.filter(u => u.id !== user.id)); this.pendingUsers.update((users) => users.filter((u) => u.id !== user.id));
}, },
error: (err) => { error: (err) => {
this.toastService.error(err.error?.message || 'Der User konnte nicht abgelehnt werden.'); this.toastService.error(err.error?.message || 'Der User konnte nicht abgelehnt werden.');
} },
}); });
} }
} }