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-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>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user