feat(teams): manage player active status and team-role with treasurer safeguard

Team managers (captain and above) can now deactivate/reactivate a player and
change their team-role from the player detail page. Deactivation zeroes the
open balance via an auditable adjustment transaction instead of overwriting
the balance field, and both actions are blocked if they would leave a team
without an active treasurer. Also hardens the existing PUT teams/:id/players
endpoint down to profile-only fields, fixing a typo bug and closing a gap
where any authenticated user could mutate a player's active/role/balance in
any team.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-01 15:42:09 +02:00
parent 2424a8c025
commit 3551641a85
16 changed files with 830 additions and 35 deletions

View File

@@ -10,6 +10,30 @@
</div>
<strong [class.negative]="player.balance < 0">{{ player.balance | currency: 'EUR' }}</strong>
</header>
@if (canManage()) {
<div class="manage-actions">
<button mat-button type="button" [disabled]="savingActive()" (click)="changeActive()">
{{ player.active ? 'Deaktivieren' : 'Aktivieren' }}
</button>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Rolle</mat-label>
<mat-select
[value]="player.teamRole?.id ?? 1"
[disabled]="savingRole()"
(selectionChange)="changeTeamRole($event.value)"
>
<mat-option [value]="1">Spieler</mat-option>
<mat-option [value]="2">2. Kassenwart</mat-option>
<mat-option [value]="3">Kapitän</mat-option>
<mat-option [value]="4">Kassenwart</mat-option>
<mat-option [value]="5">Trainer</mat-option>
</mat-select>
</mat-form-field>
</div>
}
@if (mutationError(); as error) {
<p class="error">{{ error }}</p>
}
<h2>Buchungsverlauf</h2>
@if (loading()) {
<div class="state"><mat-spinner diameter="32" /></div>

View File

@@ -47,6 +47,16 @@ header span {
.negative {
color: var(--mat-sys-error);
}
.manage-actions {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
margin: -1rem 0 1.5rem;
}
.error {
color: var(--mat-sys-error);
}
.state {
min-height: 180px;
display: grid;

View File

@@ -2,14 +2,24 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { Subject } from 'rxjs';
import { PlayerDetail } from './player-detail';
import { AuthStore } from '../../../core/auth/auth-store';
import { TeamStore } from '../../../core/team/team-store';
import { environment } from '../../../../environments/environment';
describe('PlayerDetail', () => {
it('renders the selected player and transaction history', async () => {
const team = {
const teamsApiUrl = `${environment.apiUrl}teams/5`;
let httpMock: HttpTestingController;
let refreshTeam: ReturnType<typeof vi.fn>;
let currentUser: ReturnType<typeof signal>;
let closeDialog: Subject<boolean>;
let dialog: { open: ReturnType<typeof vi.fn> };
function makeTeam(playerOverrides: Partial<Record<string, unknown>> = {}) {
return {
id: 5,
name: 'Team A',
alias: 'team-a',
@@ -22,16 +32,27 @@ describe('PlayerDetail', () => {
balance: -12,
active: true,
teamRole: { id: 1, name: 'player' },
...playerOverrides,
},
],
};
}
async function create(team: ReturnType<typeof makeTeam>, user: unknown) {
refreshTeam = vi.fn();
currentUser = signal(user);
closeDialog = new Subject<boolean>();
dialog = { open: vi.fn(() => ({ afterClosed: () => closeDialog.asObservable() })) };
await TestBed.configureTestingModule({
imports: [PlayerDetail],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: TeamStore, useValue: { team: signal(team), loading: signal(false) } },
{ provide: TeamStore, useValue: { team: signal(team), loading: signal(false), refreshTeam } },
{ provide: AuthStore, useValue: { currentUser } },
{ provide: MatDialog, useValue: dialog },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ playerId: '7' }) } },
@@ -40,15 +61,91 @@ describe('PlayerDetail', () => {
}).compileComponents();
const fixture = TestBed.createComponent(PlayerDetail);
fixture.detectChanges();
TestBed.inject(HttpTestingController)
.expectOne(`${environment.apiUrl}teams/5/players/7/transactions`)
.flush([
{ id: 1, date: '2026-07-31', amount: 12, note: 'Beitrag', type: { id: 11, name: 'fine' } },
]);
httpMock = TestBed.inject(HttpTestingController);
httpMock.expectOne(`${teamsApiUrl}/players/7/transactions`).flush([]);
await fixture.whenStable();
fixture.detectChanges();
return fixture;
}
afterEach(() => httpMock.verify());
it('renders the selected player and transaction history', async () => {
const fixture = await create(
makeTeam({ balance: -12 }),
{ id: 99, role: { id: 2 } },
);
expect(fixture.nativeElement.textContent).toContain('Alex Muster');
expect(fixture.nativeElement.textContent).toContain('Beitrag');
expect(fixture.nativeElement.textContent).toContain('-12,00');
});
it('hides the manage controls for a user without team-manager rights', async () => {
const fixture = await create(makeTeam(), { id: 99, role: { id: 2 } });
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
b.textContent?.includes('Deaktivieren'),
);
expect(button).toBeUndefined();
});
it('shows the manage controls for a global admin', async () => {
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
b.textContent?.includes('Deaktivieren'),
);
expect(button).toBeDefined();
});
it('shows the manage controls for a captain-and-above team member', async () => {
const fixture = await create(
makeTeam({ user: { id: 42 }, teamRole: { id: 3, name: 'captain' } }),
{ id: 42, role: { id: 2 } },
);
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
b.textContent?.includes('Deaktivieren'),
);
expect(button).toBeDefined();
});
it('deactivates the player on confirm and refreshes the team', async () => {
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
b.textContent?.includes('Deaktivieren'),
) as HTMLButtonElement;
button.click();
expect(dialog.open).toHaveBeenCalled();
expect(dialog.open.mock.calls[0][1].data.message).toContain('Alex Muster');
httpMock.expectNone(`${teamsApiUrl}/players/7/active`);
closeDialog.next(true);
const request = httpMock.expectOne(`${teamsApiUrl}/players/7/active`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ active: false });
request.flush({ id: 7, active: false });
expect(refreshTeam).toHaveBeenCalled();
});
it('does not call the API when the confirmation dialog is dismissed', async () => {
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
b.textContent?.includes('Deaktivieren'),
) as HTMLButtonElement;
button.click();
closeDialog.next(false);
httpMock.expectNone(`${teamsApiUrl}/players/7/active`);
expect(refreshTeam).not.toHaveBeenCalled();
});
it('changes the team-role and refreshes the team', async () => {
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
fixture.componentInstance['changeTeamRole'](4);
const request = httpMock.expectOne(`${teamsApiUrl}/players/7/team-role`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ teamRoleId: 4 });
request.flush({ id: 7, teamRole: { id: 4 } });
expect(refreshTeam).toHaveBeenCalled();
});
});

View File

@@ -1,15 +1,22 @@
import { HttpErrorResponse } from '@angular/common/http';
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { Component, LOCALE_ID, computed, effect, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { filter, finalize, take } from 'rxjs';
import { AuthStore } from '../../../core/auth/auth-store';
import { TeamStore } from '../../../core/team/team-store';
import { TeamsApi } from '../../../core/team/teams-api';
import { PlayerTransaction } from '../../../models/transaction.model';
import { signedTransactionAmount } from '../../../models/transaction-amount';
import { ConfirmDialog } from '../../../shared/confirm-dialog/confirm-dialog';
registerLocaleData(localeDe);
@@ -21,8 +28,10 @@ registerLocaleData(localeDe);
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatProgressSpinnerModule,
MatSelectModule,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './player-detail.html',
@@ -32,6 +41,8 @@ export class PlayerDetail {
private readonly route = inject(ActivatedRoute);
private readonly teamsApi = inject(TeamsApi);
private readonly teamStore = inject(TeamStore);
private readonly authStore = inject(AuthStore);
private readonly dialog = inject(MatDialog);
private readonly playerId = Number(this.route.snapshot.paramMap.get('playerId'));
private readonly loadedKey = signal<string | null>(null);
protected readonly team = this.teamStore.team;
@@ -40,6 +51,19 @@ export class PlayerDetail {
);
protected readonly transactions = signal<PlayerTransaction[]>([]);
protected readonly loading = signal(true);
protected readonly savingActive = signal(false);
protected readonly savingRole = signal(false);
protected readonly mutationError = signal<string | null>(null);
protected readonly canManage = computed(() => {
const user = this.authStore.currentUser();
if (user?.role?.id === 1) return true;
return (
this.team()?.players?.some(
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 3,
) ?? false
);
});
constructor() {
effect(() => {
@@ -67,4 +91,57 @@ export class PlayerDetail {
protected displayAmount(transaction: PlayerTransaction): number {
return signedTransactionAmount(transaction.amount, transaction.type);
}
protected changeActive(): void {
const team = this.team();
const player = this.player();
if (!team || !player || this.savingActive()) return;
const isActive = player.active;
this.dialog
.open(ConfirmDialog, {
data: {
title: isActive ? 'Spieler deaktivieren?' : 'Spieler aktivieren?',
message: `${player.firstName} ${player.lastName} wird ${isActive ? 'deaktiviert' : 'aktiviert'}.`,
confirmLabel: isActive ? 'Deaktivieren' : 'Aktivieren',
},
restoreFocus: true,
})
.afterClosed()
.pipe(filter(Boolean), take(1))
.subscribe(() => {
this.savingActive.set(true);
this.mutationError.set(null);
this.teamsApi
.setPlayerActive(team.id, player.id, !isActive)
.pipe(finalize(() => this.savingActive.set(false)))
.subscribe({
next: () => this.teamStore.refreshTeam(),
error: (error: HttpErrorResponse) =>
this.mutationError.set(this.errorMessage(error, 'Status konnte nicht geändert werden.')),
});
});
}
protected changeTeamRole(teamRoleId: number): void {
const team = this.team();
const player = this.player();
if (!team || !player || this.savingRole() || player.teamRole?.id === teamRoleId) return;
this.savingRole.set(true);
this.mutationError.set(null);
this.teamsApi
.setPlayerTeamRole(team.id, player.id, teamRoleId)
.pipe(finalize(() => this.savingRole.set(false)))
.subscribe({
next: () => this.teamStore.refreshTeam(),
error: (error: HttpErrorResponse) =>
this.mutationError.set(this.errorMessage(error, 'Rolle 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();
if (error.status === 409) return detail || fallback;
return detail ? `${fallback} ${detail}` : fallback;
}
}