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:
@@ -20,6 +20,18 @@
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<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-label>Mitglieder</mat-label>
|
||||
<mat-select formControlName="playerIds" multiple>
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TeamActivity[]>([]);
|
||||
protected readonly penalties = signal<Penalty[]>([]);
|
||||
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 {
|
||||
|
||||
@@ -122,8 +122,18 @@
|
||||
<span>{{ penalty.description }}</span>
|
||||
<strong>{{ penalty.amount | currency: 'EUR' }}</strong>
|
||||
</div>
|
||||
@if (canManage()) {
|
||||
<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
|
||||
mat-button
|
||||
type="button"
|
||||
@@ -142,8 +152,8 @@
|
||||
>
|
||||
<mat-icon>delete</mat-icon>Löschen
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@@ -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 })))
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user