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:
@@ -76,4 +76,20 @@ describe('TeamsApi', () => {
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush([]);
|
||||
});
|
||||
|
||||
it('sets a player active/inactive', () => {
|
||||
service.setPlayerActive(5, 7, false).subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/players/7/active`);
|
||||
expect(request.request.method).toBe('PATCH');
|
||||
expect(request.request.body).toEqual({ active: false });
|
||||
request.flush({ id: 7, active: false });
|
||||
});
|
||||
|
||||
it('changes a player team-role', () => {
|
||||
service.setPlayerTeamRole(5, 7, 4).subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/players/7/team-role`);
|
||||
expect(request.request.method).toBe('PATCH');
|
||||
expect(request.request.body).toEqual({ teamRoleId: 4 });
|
||||
request.flush({ id: 7, teamRole: { id: 4 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,20 @@ export class TeamsApi {
|
||||
return this.http.put<Player>(`${environment.apiUrl}teams/${teamId}/players`, player);
|
||||
}
|
||||
|
||||
setPlayerActive(teamId: number, playerId: number, active: boolean): Observable<Player> {
|
||||
return this.http.patch<Player>(
|
||||
`${environment.apiUrl}teams/${teamId}/players/${playerId}/active`,
|
||||
{ active },
|
||||
);
|
||||
}
|
||||
|
||||
setPlayerTeamRole(teamId: number, playerId: number, teamRoleId: number): Observable<Player> {
|
||||
return this.http.patch<Player>(
|
||||
`${environment.apiUrl}teams/${teamId}/players/${playerId}/team-role`,
|
||||
{ teamRoleId },
|
||||
);
|
||||
}
|
||||
|
||||
loadPlayerTransactions(teamId: number, playerId: number): Observable<PlayerTransaction[]> {
|
||||
return this.http.get<PlayerTransaction[]>(
|
||||
`${environment.apiUrl}teams/${teamId}/players/${playerId}/transactions`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user