feat: build admin user management UI

This commit is contained in:
Bastian Wagner
2026-08-01 10:01:25 +02:00
parent d0ec000bff
commit 3ae0fd2000
6 changed files with 1256 additions and 9 deletions

View File

@@ -1,13 +1,215 @@
import { Component } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { filter, finalize, of, switchMap, take } from 'rxjs';
import { AuthStore } from '../../core/auth/auth-store';
import { AdminUsersApi } from '../../core/users/admin-users-api';
import { UsersApi } from '../../core/users/users-api';
import {
AdminUserDirectorySummary,
AdminUserStatusId,
UserDirectoryPage,
UserDirectoryRecord,
} from '../../models/user-directory.model';
import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
import { PlayerAssignments } from './player-assignments';
import { UserEdit, UserEditValue } from './user-edit';
@Component({
selector: 'app-users',
template: `
<header>
<p class="eyebrow">Organisation</p>
<h1>Benutzer</h1>
<p>Benutzerverzeichnis wird vorbereitet.</p>
</header>
`,
imports: [
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
PlayerAssignments,
UserEdit,
],
templateUrl: './users.html',
styleUrl: './users.scss',
})
export class Users {}
export class Users {
private readonly usersApi = inject(UsersApi);
private readonly adminUsersApi = inject(AdminUsersApi);
private readonly authStore = inject(AuthStore);
private readonly dialog = inject(MatDialog);
protected readonly isAdmin = this.authStore.isGlobalAdmin;
protected readonly currentUser = this.authStore.currentUser;
protected readonly directory = signal<UserDirectoryPage | null>(null);
protected readonly loading = signal(true);
protected readonly loadError = signal<string | null>(null);
protected readonly mutationError = signal<string | null>(null);
protected readonly searchDraft = signal('');
protected readonly search = signal('');
protected readonly page = signal(1);
protected readonly limit = 20;
protected readonly editingUserId = signal<number | null>(null);
protected readonly assignmentUserId = signal<number | null>(null);
protected readonly pendingUserId = signal<number | null>(null);
constructor() {
this.loadDirectory();
}
protected loadDirectory(): void {
this.loading.set(true);
this.loadError.set(null);
const search = this.search();
this.usersApi
.loadDirectory({ page: this.page(), limit: this.limit, ...(search ? { search } : {}) })
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (directory) => this.directory.set(directory),
error: (error: HttpErrorResponse) => {
this.directory.set(null);
this.loadError.set(this.errorMessage(error, 'Benutzer konnten nicht geladen werden.'));
},
});
}
protected submitSearch(): void {
this.search.set(this.searchDraft().trim());
this.page.set(1);
this.loadDirectory();
}
protected previousPage(): void {
if (this.page() <= 1 || this.loading()) return;
this.page.update((value) => value - 1);
this.loadDirectory();
}
protected nextPage(): void {
if (!this.directory()?.hasNextPage || this.loading()) return;
this.page.update((value) => value + 1);
this.loadDirectory();
}
protected adminDetails(user: UserDirectoryRecord): AdminUserDirectorySummary | null {
if (!this.isAdmin()) return null;
return 'email' in user && 'role' in user ? user : null;
}
protected fullName(user: UserDirectoryRecord): string {
return [user.firstName, user.lastName].filter(Boolean).join(' ') || `Benutzer ${user.id}`;
}
protected initials(user: UserDirectoryRecord): string {
const value = `${user.firstName?.charAt(0) ?? ''}${user.lastName?.charAt(0) ?? ''}`.trim();
return value || '?';
}
protected statusName(statusId?: number): string {
return statusId === 2 ? 'Inaktiv' : 'Aktiv';
}
protected roleName(roleId?: number): string {
return roleId === 1 ? 'Administrator' : 'Benutzer';
}
protected teamRoleName(name?: string): string {
return (
{
player: 'Spieler',
scnd_treasurer: '2. Kassenwart',
captain: 'Kapitän',
treasurer: 'Kassenwart',
coach: 'Trainer',
}[name ?? ''] ?? 'Spieler'
);
}
protected toggleEdit(userId: number): void {
this.assignmentUserId.set(null);
this.editingUserId.update((value) => (value === userId ? null : userId));
this.mutationError.set(null);
}
protected toggleAssignments(userId: number): void {
this.editingUserId.set(null);
this.assignmentUserId.update((value) => (value === userId ? null : userId));
this.mutationError.set(null);
}
protected saveEdit(user: AdminUserDirectorySummary, value: UserEditValue): void {
if (this.pendingUserId() !== null) return;
this.pendingUserId.set(user.id);
this.mutationError.set(null);
const profileRequest = this.adminUsersApi.updateProfile(user.id, {
firstName: value.firstName,
lastName: value.lastName,
});
const roleId = user.role?.id;
profileRequest
.pipe(
switchMap(() =>
roleId === value.role ? of(user) : this.adminUsersApi.updateRole(user.id, { role: value.role }),
),
finalize(() => this.pendingUserId.set(null)),
)
.subscribe({
next: () => {
this.editingUserId.set(null);
this.loadDirectory();
},
error: (error: HttpErrorResponse) => {
this.mutationError.set(this.errorMessage(error, 'Änderung fehlgeschlagen.'));
this.loadDirectory();
},
});
}
protected changeStatus(user: AdminUserDirectorySummary): void {
if (this.isSelf(user) || this.pendingUserId() !== null) return;
const isActive = user.status?.id !== 2;
const status: AdminUserStatusId = isActive ? 2 : 1;
this.dialog
.open(ConfirmDialog, {
data: {
title: isActive ? 'Benutzer deaktivieren?' : 'Benutzer aktivieren?',
message: `${this.fullName(user)} wird ${isActive ? 'deaktiviert' : 'aktiviert'}.`,
confirmLabel: isActive ? 'Deaktivieren' : 'Aktivieren',
},
restoreFocus: true,
})
.afterClosed()
.pipe(filter(Boolean), take(1))
.subscribe(() => this.updateStatus(user.id, status));
}
protected isSelf(user: UserDirectoryRecord): boolean {
return user.id === this.currentUser()?.id;
}
protected assignmentsChanged(): void {
this.loadDirectory();
}
private updateStatus(userId: number, status: AdminUserStatusId): void {
this.pendingUserId.set(userId);
this.mutationError.set(null);
this.adminUsersApi
.updateStatus(userId, { status })
.pipe(finalize(() => this.pendingUserId.set(null)))
.subscribe({
next: () => this.loadDirectory(),
error: (error: HttpErrorResponse) =>
this.mutationError.set(this.errorMessage(error, 'Status konnte nicht geändert werden.')),
});
}
private errorMessage(error: HttpErrorResponse, fallback: string): string {
const detail = typeof error.error?.message === 'string' ? error.error.message : '';
if (error.status === 403) return `Keine Berechtigung. ${detail}`.trim();
return detail ? `${fallback} ${detail}` : fallback;
}
}