feat(cashbox): book a catalog penalty directly as a transaction

Adds a catalog picker to the member-booking form in the cashbox (prefills
amount/note/type, stays editable) and a "Buchen" button on each penalty
catalog entry that jumps to the cashbox with that entry preselected via a
penaltyId query param. No backend changes — reuses the existing POST
/transactions flow, the catalog only supplies starting values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-01 18:50:58 +02:00
parent 9664187049
commit 5dae4362b2
6 changed files with 154 additions and 7 deletions

View File

@@ -20,6 +20,18 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<form [formGroup]="playerForm" (ngSubmit)="submitPlayerBooking()"> <form [formGroup]="playerForm" (ngSubmit)="submitPlayerBooking()">
@if (penalties().length > 0) {
<mat-form-field appearance="outline" class="wide">
<mat-label>Aus Strafenkatalog übernehmen (optional)</mat-label>
<mat-select (selectionChange)="onPenaltySelect($event.value)">
@for (penalty of penalties(); track penalty.id) {
<mat-option [value]="penalty.id"
>{{ penalty.description }} · {{ penalty.amount | currency: 'EUR' }}</mat-option
>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="wide"> <mat-form-field appearance="outline" class="wide">
<mat-label>Mitglieder</mat-label> <mat-label>Mitglieder</mat-label>
<mat-select formControlName="playerIds" multiple> <mat-select formControlName="playerIds" multiple>

View File

@@ -2,7 +2,9 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { AuthStore } from '../../../core/auth/auth-store'; import { AuthStore } from '../../../core/auth/auth-store';
import { PenaltyApi } from '../../../core/team/penalty-api';
import { TeamStore } from '../../../core/team/team-store'; import { TeamStore } from '../../../core/team/team-store';
import { TransactionsApi } from '../../../core/team/transactions-api'; import { TransactionsApi } from '../../../core/team/transactions-api';
import { Cashbox } from './cashbox'; import { Cashbox } from './cashbox';
@@ -54,11 +56,17 @@ describe('Cashbox', () => {
}, },
]; ];
async function setup(roleId = 2, confirm = true) { const penalties = [
{ id: 101, description: 'Zu spät zum Training', amount: 5 },
{ id: 102, description: 'Handy vergessen', amount: 2.5 },
];
async function setup(roleId = 2, confirm = true, penaltyIdParam: string | null = null) {
const createPlayerTransactions = vi.fn(() => of([])); const createPlayerTransactions = vi.fn(() => of([]));
const createTeamWalletTransaction = vi.fn(() => of(activities[0])); const createTeamWalletTransaction = vi.fn(() => of(activities[0]));
const reverseTransaction = vi.fn(() => of(activities[0])); const reverseTransaction = vi.fn(() => of(activities[0]));
const loadTeamTransactions = vi.fn(() => of(activities)); const loadTeamTransactions = vi.fn(() => of(activities));
const loadPenalties = vi.fn(() => of(penalties));
const refreshTeam = vi.fn(); const refreshTeam = vi.fn();
const dialog = { const dialog = {
open: vi.fn(() => ({ afterClosed: () => of(confirm) })), open: vi.fn(() => ({ afterClosed: () => of(confirm) })),
@@ -92,6 +100,17 @@ describe('Cashbox', () => {
reverseTransaction, reverseTransaction,
}, },
}, },
{ provide: PenaltyApi, useValue: { loadPenalties } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap(
penaltyIdParam ? { penaltyId: penaltyIdParam } : {},
),
},
},
},
{ provide: MatDialog, useValue: dialog }, { provide: MatDialog, useValue: dialog },
], ],
}).compileComponents(); }).compileComponents();
@@ -166,4 +185,33 @@ describe('Cashbox', () => {
expect(fixture.nativeElement.querySelector('[data-testid="player-booking"]')).toBeNull(); expect(fixture.nativeElement.querySelector('[data-testid="player-booking"]')).toBeNull();
expect(fixture.nativeElement.querySelector('[data-testid="reverse-booking"]')).toBeNull(); expect(fixture.nativeElement.querySelector('[data-testid="reverse-booking"]')).toBeNull();
}); });
it('prefills amount, note and type from a manually selected catalog entry, and stays editable', async () => {
const { component } = await setup();
component['onPenaltySelect'](102);
expect(component['playerForm'].getRawValue()).toEqual(
expect.objectContaining({ amount: 2.5, note: 'Handy vergessen', type: 11 }),
);
component['playerForm'].patchValue({ amount: 7 });
expect(component['playerForm'].getRawValue().amount).toBe(7);
});
it('prefills the booking form automatically from a penaltyId query param', async () => {
const { component } = await setup(2, true, '101');
expect(component['playerForm'].getRawValue()).toEqual(
expect.objectContaining({ amount: 5, note: 'Zu spät zum Training', type: 11 }),
);
});
it('ignores an unknown penaltyId query param without error', async () => {
const { component } = await setup(2, true, '999');
expect(component['playerForm'].getRawValue()).toEqual(
expect.objectContaining({ amount: 0, note: '', type: 11 }),
);
});
}); });

View File

@@ -2,6 +2,7 @@ import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de'; import localeDe from '@angular/common/locales/de';
import { Component, LOCALE_ID, computed, effect, inject, signal } from '@angular/core'; import { Component, LOCALE_ID, computed, effect, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -13,8 +14,10 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthStore } from '../../../core/auth/auth-store'; import { AuthStore } from '../../../core/auth/auth-store';
import { PenaltyApi } from '../../../core/team/penalty-api';
import { TeamStore } from '../../../core/team/team-store'; import { TeamStore } from '../../../core/team/team-store';
import { TransactionsApi } from '../../../core/team/transactions-api'; import { TransactionsApi } from '../../../core/team/transactions-api';
import { Penalty } from '../../../models/penalty.model';
import { import {
CreatePlayerTransaction, CreatePlayerTransaction,
CreateTeamWalletTransaction, CreateTeamWalletTransaction,
@@ -52,13 +55,20 @@ export class Cashbox {
private readonly authStore = inject(AuthStore); private readonly authStore = inject(AuthStore);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly penaltyApi = inject(PenaltyApi);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
private readonly teamStore = inject(TeamStore); private readonly teamStore = inject(TeamStore);
private readonly transactionsApi = inject(TransactionsApi); private readonly transactionsApi = inject(TransactionsApi);
private loadedTeamId: number | null = null; private loadedTeamId: number | null = null;
private pendingPenaltyId: number | null = Number(
this.route.snapshot.queryParamMap.get('penaltyId'),
) || null;
protected readonly team = this.teamStore.team; protected readonly team = this.teamStore.team;
protected readonly activities = signal<TeamActivity[]>([]); protected readonly activities = signal<TeamActivity[]>([]);
protected readonly penalties = signal<Penalty[]>([]);
protected readonly loading = signal(false); protected readonly loading = signal(false);
protected readonly saving = signal(false); protected readonly saving = signal(false);
protected readonly playerTransactionTypes = [ protected readonly playerTransactionTypes = [
@@ -109,8 +119,33 @@ export class Cashbox {
if (teamId && teamId !== this.loadedTeamId) { if (teamId && teamId !== this.loadedTeamId) {
this.loadedTeamId = teamId; this.loadedTeamId = teamId;
this.loadActivities(teamId); this.loadActivities(teamId);
this.loadPenalties(teamId);
} }
}); });
effect(() => {
if (this.pendingPenaltyId === null) return;
const penalty = this.penalties().find((p) => p.id === this.pendingPenaltyId);
if (!penalty) return;
this.pendingPenaltyId = null;
this.applyPenaltyPreset(penalty);
void this.router.navigate([], { queryParams: {}, replaceUrl: true });
});
}
protected onPenaltySelect(penaltyId: number): void {
const penalty = this.penalties().find((p) => p.id === penaltyId);
if (penalty) this.applyPenaltyPreset(penalty);
}
private applyPenaltyPreset(penalty: Penalty): void {
this.playerForm.patchValue({ amount: penalty.amount, note: penalty.description, type: 11 });
}
private loadPenalties(teamId: number): void {
this.penaltyApi.loadPenalties(teamId).subscribe({
next: (penalties) => this.penalties.set(penalties),
error: () => this.penalties.set([]),
});
} }
protected submitPlayerBooking(): void { protected submitPlayerBooking(): void {

View File

@@ -122,8 +122,18 @@
<span>{{ penalty.description }}</span> <span>{{ penalty.description }}</span>
<strong>{{ penalty.amount | currency: 'EUR' }}</strong> <strong>{{ penalty.amount | currency: 'EUR' }}</strong>
</div> </div>
@if (canManage()) {
<div class="penalty-actions"> <div class="penalty-actions">
@if (canBook()) {
<button
mat-button
type="button"
(click)="bookPenalty(penalty)"
[attr.aria-label]="'Strafe ' + penalty.description + ' buchen'"
>
<mat-icon>add_card</mat-icon>Buchen
</button>
}
@if (canManage()) {
<button <button
mat-button mat-button
type="button" type="button"
@@ -142,8 +152,8 @@
> >
<mat-icon>delete</mat-icon>Löschen <mat-icon>delete</mat-icon>Löschen
</button> </button>
</div>
} }
</div>
} }
</mat-card> </mat-card>
} }

View File

@@ -2,7 +2,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { provideRouter } from '@angular/router'; import { Router, provideRouter } from '@angular/router';
import { Subject, of, throwError } from 'rxjs'; import { Subject, of, throwError } from 'rxjs';
import { AuthStore } from '../../../../core/auth/auth-store'; import { AuthStore } from '../../../../core/auth/auth-store';
import { PenaltyApi } from '../../../../core/team/penalty-api'; import { PenaltyApi } from '../../../../core/team/penalty-api';
@@ -254,6 +254,29 @@ describe('Penalties', () => {
expect(dialog.open).not.toHaveBeenCalled(); expect(dialog.open).not.toHaveBeenCalled();
}); });
it('shows a "Buchen" button to a 2. Kassenwart who cannot manage the catalog, and navigates to the cashbox with the penalty preselected', () => {
team.players[0].teamRole.id = 2;
create();
expect(text()).not.toContain('Bearbeiten');
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate');
button('Buchen').click();
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'cashbox'], {
queryParams: { penaltyId: 1 },
});
team.players[0].teamRole.id = 3;
});
it('hides the "Buchen" button from a reader without booking rights', () => {
currentUser.set({ id: 7, role: { id: 2 } });
create();
expect(text()).not.toContain('Buchen');
});
it('shows a load error, retries, and distinguishes an empty search result', () => { it('shows a load error, retries, and distinguishes an empty search result', () => {
loadPenalties loadPenalties
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 }))) .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })))

View File

@@ -11,7 +11,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { EMPTY, Observable, catchError, finalize, switchMap, take, tap } from 'rxjs'; import { EMPTY, Observable, catchError, finalize, switchMap, take, tap } from 'rxjs';
import { AuthStore } from '../../../../core/auth/auth-store'; import { AuthStore } from '../../../../core/auth/auth-store';
import { PenaltyApi } from '../../../../core/team/penalty-api'; import { PenaltyApi } from '../../../../core/team/penalty-api';
@@ -44,6 +44,7 @@ export class Penalties {
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly penaltyApi = inject(PenaltyApi); private readonly penaltyApi = inject(PenaltyApi);
private readonly router = inject(Router);
private readonly teamStore = inject(TeamStore); private readonly teamStore = inject(TeamStore);
private loadedTeamId: number | null = null; private loadedTeamId: number | null = null;
@@ -93,6 +94,16 @@ export class Penalties {
) ?? false ) ?? false
); );
}); });
protected readonly canBook = computed(() => {
const user = this.authStore.currentUser();
if (user?.role?.id === 1) return true;
return (
this.team()?.players?.some(
(player) =>
player.active && player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 2,
) ?? false
);
});
protected readonly filteredPenalties = computed(() => { protected readonly filteredPenalties = computed(() => {
const query = this.search().trim().toLocaleLowerCase('de'); const query = this.search().trim().toLocaleLowerCase('de');
return this.penalties().filter((penalty) => return this.penalties().filter((penalty) =>
@@ -195,6 +206,14 @@ export class Penalties {
}); });
} }
protected bookPenalty(penalty: Penalty): void {
const teamId = this.team()?.id;
if (!teamId) return;
void this.router.navigate(['/team', teamId, 'cashbox'], {
queryParams: { penaltyId: penalty.id },
});
}
protected retryLoad(): void { protected retryLoad(): void {
const teamId = this.team()?.id; const teamId = this.team()?.id;
if (teamId) this.load(teamId); if (teamId) this.load(teamId);