From 5dae4362b2f6af93eefa77a8e8dfaebfecca5c80 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 18:50:58 +0200 Subject: [PATCH] feat(cashbox): book a catalog penalty directly as a transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/features/team/cashbox/cashbox.html | 12 +++++ .../app/features/team/cashbox/cashbox.spec.ts | 50 ++++++++++++++++++- .../src/app/features/team/cashbox/cashbox.ts | 35 +++++++++++++ .../team/more/penalties/penalties.html | 18 +++++-- .../team/more/penalties/penalties.spec.ts | 25 +++++++++- .../features/team/more/penalties/penalties.ts | 21 +++++++- 6 files changed, 154 insertions(+), 7 deletions(-) diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html index 4c6c5d3..3b5cfa1 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html @@ -20,6 +20,18 @@
+ @if (penalties().length > 0) { + + Aus Strafenkatalog übernehmen (optional) + + @for (penalty of penalties(); track penalty.id) { + {{ penalty.description }} · {{ penalty.amount | currency: 'EUR' }} + } + + + } Mitglieder diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts index 3488928..2bc4226 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts @@ -2,7 +2,9 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { AuthStore } from '../../../core/auth/auth-store'; +import { PenaltyApi } from '../../../core/team/penalty-api'; import { TeamStore } from '../../../core/team/team-store'; import { TransactionsApi } from '../../../core/team/transactions-api'; 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 createTeamWalletTransaction = vi.fn(() => of(activities[0])); const reverseTransaction = vi.fn(() => of(activities[0])); const loadTeamTransactions = vi.fn(() => of(activities)); + const loadPenalties = vi.fn(() => of(penalties)); const refreshTeam = vi.fn(); const dialog = { open: vi.fn(() => ({ afterClosed: () => of(confirm) })), @@ -92,6 +100,17 @@ describe('Cashbox', () => { reverseTransaction, }, }, + { provide: PenaltyApi, useValue: { loadPenalties } }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap( + penaltyIdParam ? { penaltyId: penaltyIdParam } : {}, + ), + }, + }, + }, { provide: MatDialog, useValue: dialog }, ], }).compileComponents(); @@ -166,4 +185,33 @@ describe('Cashbox', () => { expect(fixture.nativeElement.querySelector('[data-testid="player-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 }), + ); + }); }); diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts index 80c00c3..17facda 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts @@ -2,6 +2,7 @@ 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 { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -13,8 +14,10 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { AuthStore } from '../../../core/auth/auth-store'; +import { PenaltyApi } from '../../../core/team/penalty-api'; import { TeamStore } from '../../../core/team/team-store'; import { TransactionsApi } from '../../../core/team/transactions-api'; +import { Penalty } from '../../../models/penalty.model'; import { CreatePlayerTransaction, CreateTeamWalletTransaction, @@ -52,13 +55,20 @@ export class Cashbox { private readonly authStore = inject(AuthStore); private readonly dialog = inject(MatDialog); 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 teamStore = inject(TeamStore); private readonly transactionsApi = inject(TransactionsApi); 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 activities = signal([]); + protected readonly penalties = signal([]); protected readonly loading = signal(false); protected readonly saving = signal(false); protected readonly playerTransactionTypes = [ @@ -109,8 +119,33 @@ export class Cashbox { if (teamId && teamId !== this.loadedTeamId) { this.loadedTeamId = 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 { diff --git a/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.html b/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.html index f868461..c92c65f 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.html +++ b/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.html @@ -122,8 +122,18 @@ {{ penalty.description }} {{ penalty.amount | currency: 'EUR' }} - @if (canManage()) { -
+
+ @if (canBook()) { + + } + @if (canManage()) { -
- } + } +
} } diff --git a/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.spec.ts index 3e591c2..f08436c 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.spec.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.spec.ts @@ -2,7 +2,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; -import { provideRouter } from '@angular/router'; +import { Router, provideRouter } from '@angular/router'; import { Subject, of, throwError } from 'rxjs'; import { AuthStore } from '../../../../core/auth/auth-store'; import { PenaltyApi } from '../../../../core/team/penalty-api'; @@ -254,6 +254,29 @@ describe('Penalties', () => { 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', () => { loadPenalties .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 }))) diff --git a/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.ts b/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.ts index 08d3c30..d35aed7 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/more/penalties/penalties.ts @@ -11,7 +11,7 @@ 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 { RouterLink } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { EMPTY, Observable, catchError, finalize, switchMap, take, tap } from 'rxjs'; import { AuthStore } from '../../../../core/auth/auth-store'; import { PenaltyApi } from '../../../../core/team/penalty-api'; @@ -44,6 +44,7 @@ export class Penalties { private readonly dialog = inject(MatDialog); private readonly formBuilder = inject(FormBuilder); private readonly penaltyApi = inject(PenaltyApi); + private readonly router = inject(Router); private readonly teamStore = inject(TeamStore); private loadedTeamId: number | null = null; @@ -93,6 +94,16 @@ export class Penalties { ) ?? 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(() => { const query = this.search().trim().toLocaleLowerCase('de'); 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 { const teamId = this.team()?.id; if (teamId) this.load(teamId);