Compare commits

...

5 Commits

Author SHA1 Message Date
Bastian Wagner
369d556a8b icon 2026-08-01 19:20:19 +02:00
Bastian Wagner
727ff0b1af icon 2026-08-01 19:17:25 +02:00
Bastian Wagner
c1238929ef docs: add implementation plan for cashbox KPI charts
Approved plan for the three overview charts (balance history, monthly
income/expense, top-10 outstanding players): new backend aggregation
endpoint plus a chart.js-based frontend integration on the existing
overview page.
2026-08-01 19:13:56 +02:00
Bastian Wagner
b6f311b11b docs: add design spec for cashbox KPI charts on team overview
Brainstormed with the user: three charts on the existing overview page
(balance history, monthly income/expense, top-10 outstanding players),
Chart.js as dependency-free charting lib, new backend aggregation
endpoint since none of the existing endpoints group transactions by
time or category.
2026-08-01 19:05:00 +02:00
Bastian Wagner
5dae4362b2 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>
2026-08-01 18:50:58 +02:00
20 changed files with 376 additions and 17 deletions

View File

@@ -0,0 +1,67 @@
# Kasse-KPI-Charts Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add three KPI charts (Kassenstand-Verlauf, Einnahmen/Ausgaben pro Monat, Top-10 offene Beiträge) to the existing team `Übersicht` page.
**Architecture:** A new read-only backend aggregation endpoint (`GET /teams/:id/overview/stats`) derives all three datasets at request time from existing `Transaction`/`TeamWalletTransaction` data — no new tables. The frontend renders them with a small reusable `ChartCanvas` component wrapping `chart.js` directly (no Angular chart wrapper), following the existing `switchMap`/`catchError` loading pattern already used for `activities` in `overview.ts`.
**Tech Stack:** NestJS 9, TypeORM 0.3, Jest (backend); Angular 21, Angular Material, signals/RxJS, chart.js (new dependency), Vitest (frontend).
Design spec: `docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md`.
## Global Constraints
- Default/only time window: last 12 months, no picker.
- "Ist-Kasse" rule: only `Transaction` type `payment` + all `TeamWalletTransaction` (`credit`, `expense`) count. `fine`/`levy`/`fee` are excluded from every chart in this feature.
- Amounts are stored positive in the DB; sign convention when summing must match `setBalance()`: `expense` (`type.id` 14) subtracts, `payment`/`credit` adds.
- `balanceHistory`'s last entry must equal `team.balance` (sanity check, cover in a test).
- Chart library is `chart.js` only — do not add `ng2-charts`/`ngx-charts` (Angular 21 peer-dependency risk).
- `topOutstanding` returns at most 10 active players (`active === true`, `balance < 0`), sorted by debt descending, with a link to the existing `members` route for the full list — do not render all players in the chart.
- No new endpoints/UI outside the `Übersicht` page (no new route/tab).
- No dark-mode-specific chart theming (app is light-only today).
---
### Task 1: Backend stats endpoint
**Files:**
- Modify: `myteamwallet_backend/src/teams/teams.controller.ts`
- Modify: `myteamwallet_backend/src/teams/teams.service.ts`
- Test: `myteamwallet_backend/src/teams/teams.service.spec.ts`
- Test: `myteamwallet_backend/src/teams/teams.controller.spec.ts`
- [ ] Write failing tests for a new `getOverviewStats(teamId)` service method covering: `balanceHistory` monthly bucketing over 12 months with carry-forward for months without movement and correct sign handling (expense subtracted, payment/credit added, matching `setBalance()`); `monthlyFlow` grouping where `payment`+`credit` sum into `income` and `expense` sums into `expense`, with `fine`/`levy`/`fee` excluded entirely; `topOutstanding` limited to 10 active players with `balance < 0`, sorted by debt descending; and the sanity check `balanceHistory.at(-1).balance === team.balance`.
- [ ] Write a failing controller test for `GET :id/overview/stats` asserting it carries the same `@UseGuards(AuthGuard('jwt'), RolesGuard)` / `@Roles([RoleEnum.user, RoleEnum.admin])` as the existing `:id/overview` route and delegates to `service.getOverviewStats(id)`.
- [ ] Run the focused backend tests and confirm they fail for the expected reason (method/route missing).
- [ ] Implement `getOverviewStats` in `teams.service.ts`: load the team with `relations: ['players', 'players.transactions', 'transactions']` (same pattern as `getTeamTransactions`), build one date-sorted list from `team.transactions` (TeamWalletTransaction) plus each player's `transactions` filtered to `type.name === 'payment'`, derive `balanceHistory` (12 monthly points, cumulative sum with the sign rule above, carry-forward on empty months), `monthlyFlow` (same 12 months, `payment`/`credit``income`, `expense``expense`), and `topOutstanding` (active players, `balance < 0`, sorted, sliced to 10, mapped to `{ playerId, playerName: firstName + ' ' + lastName, balance: outstanding as positive number }`). Add the `GET ':id/overview/stats'` route to `teams.controller.ts` next to `:id/overview`, delegating to the new service method.
- [ ] Run the focused backend tests plus `npm run build` in `myteamwallet_backend`; commit the backend slice.
### Task 2: Frontend chart infrastructure and overview integration
**Files:**
- Modify: `myteamwallet_frontend_modern/package.json` (add `chart.js`)
- Create: `myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.ts` (+ `.html`/`.scss`/`.spec.ts`)
- Create: `myteamwallet_frontend_modern/src/app/core/team/team-stats-api.ts` (+ `.spec.ts`)
- Create: `myteamwallet_frontend_modern/src/app/models/team-stats.model.ts`
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts`
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.html`
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.scss`
- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts`
- [ ] Install `chart.js` in `myteamwallet_frontend_modern` (no Angular wrapper package).
- [ ] Write failing tests for `TeamStatsApi.loadStats(teamId)` (GET `teams/:id/overview/stats`, mirrors `TransactionsApi.loadTeamTransactions`) and the `TeamOverviewStats` model shape.
- [ ] Write failing tests for the `ChartCanvas` shared component: it creates a `Chart.js` instance from `type`/`data`/`options` inputs, updates the instance when those inputs change, and destroys it on `ngOnDestroy`.
- [ ] Write failing tests in `overview.spec.ts` for the three new chart cards: spinner while `loadingStats()` is true, empty-state per card when its dataset is an empty array, data reaching `ChartCanvas` once `TeamStatsApi.loadStats` resolves, silent empty-state (no thrown error) when the request errors, and a working `routerLink` from the Top-10 card to the team's `members` route.
- [ ] Run the focused frontend tests and confirm they fail for the expected reason.
- [ ] Implement `TeamOverviewStats` model and `TeamStatsApi` service.
- [ ] Implement `ChartCanvas`: a `<canvas>`-backed component with `type`/`data`/`options` inputs that manages the `Chart` instance lifecycle via `effect()` and `ngOnDestroy`.
- [ ] Implement the `Overview` changes: a `stats`/`loadingStats` signal pair fed by `teamStatsApi.loadStats(id)` through the same route-param `switchMap` + `catchError(() => of(null))` pattern already used for `activities`; three new `mat-card` sections between the balance-grid and the activity list (Kassenstand-Verlauf line chart, Einnahmen/Ausgaben grouped bar chart, Top-10-Schuldner horizontal bar chart with a "Alle Spieler ansehen" link to `members`), each with its own loading/empty state.
- [ ] Run the focused frontend tests, the full frontend test suite, and the TypeScript checks; commit the frontend slice.
### Task 3: Integration and review
- [ ] Run the full backend test suite and build, the full frontend test suite and typecheck, and `git diff --check`.
- [ ] Manually verify against a running instance: a team with transaction history renders all three charts with correct values; a team with no financial movements shows empty-states, not errors or blank canvases.
- [ ] Request a read-only code review of the full diff range; fix Critical/Important findings and re-verify.
- [ ] Run the branch-finishing workflow and preserve the worktree until the user chooses integration.

View File

@@ -0,0 +1,145 @@
# Kasse-KPIs als Graphen auf der Team-Übersicht
Status: approved
Datum: 2026-08-01
## Kontext
Frontend: Angular 21 (`myteamwallet_frontend_modern`), Angular Material als Design-System,
`LOCALE_ID: 'de-DE'`. Backend: NestJS (`myteamwallet_backend`, basierend auf
`nestjs-boilerplate`) mit TypeORM-Entities.
Die bestehende Team-Übersicht (`features/team/overview/overview.ts` + `.html`) zeigt aktuell nur
zwei Kennzahlen-Kacheln (Teamkasse-Saldo, offene Beiträge, aus `GET /teams/:id/overview` bzw.
`teams.service.ts#getOverview`) sowie eine Liste der letzten 10 Aktivitäten
(`TransactionsApi.loadTeamTransactions`). Es gibt weder eine Chart-Bibliothek im Frontend noch
einen Backend-Endpoint, der Transaktionen zeitlich oder kategorisch aggregiert — `Transaction`
und `TeamWalletTransaction` liefern nur flache Listen; `team.balance`/`player.balance` sind reine
Laufsummen ohne historische Zwischenstände.
Datenmodell (relevant für Aggregation):
- `Transaction` (Spieler-Ebene, `transaction-type.enum.ts`): `payment` (id 0), `credit` (id 1),
`fine` (11), `levy` (12), `fee` (13). Nur `payment` verändert laut
`transaction.entity.ts#setBalance()` zusätzlich `team.balance` — Strafen/Beiträge (`fine`,
`levy`, `fee`) erhöhen nur die Schuld des Spielers (`player.balance`), bis sie bezahlt werden.
- `TeamWalletTransaction` (Team-Ebene, `team-wallet-transaction.enum.ts`): `credit` (1),
`expense` (14) — verändern `team.balance` direkt.
Ziel: auf der Übersicht drei KPI-Graphen ergänzen, damit Trainer/Kassenwarte den Kassenverlauf
auf einen Blick erfassen, ohne die volle Aktivitätsliste durchsuchen zu müssen.
## Entscheidungen aus dem Brainstorming
- **KPIs**: Kassenstand-Verlauf über Zeit, Einnahmen vs. Ausgaben pro Monat, offene Beiträge je
Spieler (Top 10). Keine Kategorie-Verteilung (Strafen/Beiträge/Ausgaben-Anteile) in diesem Zug.
- **Platzierung**: direkt auf der bestehenden Übersicht-Seite, kein neuer Tab/Bereich.
- **Zeitraum**: feste laufende Saison, letzte 12 Monate — kein Zeitraum-Umschalter in diesem Zug.
- **Chart-Bibliothek**: `chart.js` direkt (kein `ng2-charts`/`ngx-charts`-Wrapper), um
Peer-Dependency-Risiken mit dem sehr neuen Angular 21 zu vermeiden — Chart.js hat keine
Angular-Abhängigkeit.
- **Einnahmen-Logik**: „Ist-Kasse" — nur tatsächliche Zahlungsbewegungen zählen als
Einnahme/Ausgabe (Spieler-`payment` + Team-Wallet-`credit`/`expense`). Verhängte, aber noch
nicht bezahlte `fine`/`levy`/`fee` zählen **nicht** mit — konsistent mit dem
Kassenstand-Verlauf, der denselben Datenausschnitt nutzt.
- **Offene-Beiträge-Chart**: nur Top 10 Schuldner (höchste negative `player.balance`, nur aktive
Spieler), mit Link zur bestehenden Mitgliederverwaltung (`team/:id/members`) für die
vollständige Liste.
## Architektur / Komponenten
### 1. Backend: neuer Aggregations-Endpoint
Neue Route `GET /teams/:id/overview/stats` in `teams.controller.ts`, Logik in
`teams.service.ts` (neue Methode `getOverviewStats(teamId)`, analog zu `getOverview`).
Antwortform:
```ts
interface TeamOverviewStats {
balanceHistory: { month: string /* 'YYYY-MM' */; balance: number }[]; // 12 Einträge
monthlyFlow: { month: string; income: number; expense: number }[]; // 12 Einträge
topOutstanding: { playerId: number; playerName: string; balance: number }[]; // max. 10
}
```
Berechnung:
- Relevante Rohdaten: alle `Transaction` vom Typ `payment` des Teams + alle
`TeamWalletTransaction` des Teams, jeweils mit `date` und `amount`, aufsteigend sortiert.
(Wiederverwendung der bestehenden Relationen `team.players.transactions` /
`team.transactions`, wie in `getTeamTransactions` bereits geladen — Filterung auf `payment`
ergänzen.)
- `balanceHistory`: kumulative Summe der Rohdaten bilden, pro Kalendermonat der letzten 12 Monate
den Stand am Monatsende übernehmen; Monate ohne Bewegung übernehmen den letzten bekannten
Stand. Vorzeichen wie in den bestehenden `setBalance()`-Methoden: `amount` ist in der DB stets
positiv gespeichert, `expense` (`TeamWalletTransaction`, `type.id` 14) wird beim Aufsummieren
abgezogen, `payment`/`credit` addiert. Der letzte Wert der Reihe muss `team.balance`
entsprechen (Sanity-Check im Unit-Test).
- `monthlyFlow`: dieselben Rohdaten nach Monat gruppieren; `payment` und `credit` (positiver
Betrag) fließen in `income`, `expense` in `expense` (als positive Summe ausgewiesen, nicht
negativ).
- `topOutstanding`: aktive Spieler (`player.active`) mit `balance < 0` laden (gleiche
Player-Relation wie `getOverview`), nach `balance` aufsteigend (= höchste Schuld zuerst)
sortieren, auf 10 begrenzen, `balance` als positiver `outstanding`-Betrag ausgeben.
Kein neues TypeORM-Entity, keine neue Tabelle — reine Ableitung aus bestehenden Daten zur
Laufzeit (Datenvolumen pro Team ist klein genug, keine Materialisierung nötig).
### 2. Frontend: Chart-Integration
- Neue Dependency: `chart.js` (`npm install chart.js`, kein zusätzlicher Angular-Wrapper).
- Neue wiederverwendbare Komponente `shared/chart-canvas/chart-canvas.ts` (+ `.html`/`.scss`):
kapselt ein `<canvas>`-Element und den Chart.js-Instanz-Lifecycle. Inputs: `type` (`'line'` |
`'bar'`), `data`, `options` (Chart.js-native Typen). Erstellt die `Chart`-Instanz in
`afterNextRender`/`ngAfterViewInit`, aktualisiert sie über `effect()` bei Input-Änderungen,
zerstört sie in `ngOnDestroy`. Wird von allen drei KPI-Charts mit unterschiedlicher Config
genutzt — kein chart-spezifischer Code dupliziert sich.
- Neuer `TeamStatsApi`-Service (`core/team/team-stats-api.ts`, analog zu
`core/team/transactions-api.ts`) mit `loadStats(teamId): Observable<TeamOverviewStats>`, neues
Model `TeamOverviewStats` in `models/`.
### 3. UI: `overview.ts` / `overview.html`
- `Overview`-Component bekommt ein zusätzliches `stats`-Signal + `loadingStats`-Signal, gefüllt
über denselben `switchMap`-auf-Route-Param-Pattern wie `activities`
(`teamStatsApi.loadStats(id).pipe(catchError(() => of(null)))`).
- Neue Sektion zwischen Balance-Kacheln und Aktivitätsliste, drei `mat-card`s:
1. Liniendiagramm „Kassenstand-Verlauf" (`balanceHistory`).
2. Gruppiertes Balkendiagramm „Einnahmen & Ausgaben" (`monthlyFlow`, zwei Serien).
3. Horizontales Balkendiagramm „Offene Beiträge (Top 10)" (`topOutstanding`), darunter ein
Link/Button „Alle Spieler ansehen" → `routerLink` zu `members` innerhalb des Team-Kontexts.
- Jede Chart-Karte hat einen eigenen Ladezustand (`mat-spinner`, wie bei der Aktivitätsliste) und
einen Empty-State bei leeren Arrays (z. B. neues Team ohne Bewegungen) statt eines leeren
Canvas.
- Chart-Farben orientieren sich an der bestehenden `balance-card`/Material-Palette (Grün für
positiv/Einnahmen, Rot-Ton für negativ/Ausgaben) — App hat aktuell nur ein Light-Theme
(`color-scheme: light` in `styles.scss`), kein Dark-Mode-Handling nötig.
## Fehlerbehandlung
Fehler beim Laden der Stats führen zu einem stillen Empty-State pro Chart-Karte (kein globaler
Fehlerblock, keine Snackbar) — konsistent mit dem bestehenden Umgang bei `activities`
(`catchError(() => of([]))`). Der Rest der Übersicht-Seite (Balance-Kacheln, Aktivitätsliste)
bleibt unabhängig vom Erfolg des Stats-Requests voll funktionsfähig.
## Testing
- Backend: neuer Jest-Unit-Test-Block für `getOverviewStats` in `teams.service.spec.ts`
prüft Monatsgruppierung, Ist-Kasse-Filterung (fine/levy/fee werden ignoriert), Top-10-Sortierung
und den Sanity-Check `balanceHistory.at(-1).balance === team.balance`.
- Backend: Controller-Test für die neue Route (Auth-Guard greift, Response-Form) in
`teams.controller.spec.ts`, analog zu bestehenden Tests für `/overview`.
- Frontend: Erweiterung von `overview.spec.ts` um Fälle mit gemocktem `TeamStatsApi`
(Loading-, Empty- und Daten-Zustand pro Chart-Karte).
- Manuelle Verifikation: Team mit realistischer Transaktionshistorie lokal aufrufen, alle drei
Charts visuell prüfen (inkl. Team ohne jegliche Bewegungen → Empty-States statt Fehler).
## Out of Scope
- Zeitraum-Umschalter / freie Datumsauswahl für die Charts.
- Kategorie-Verteilungs-Chart (Anteile Strafen/Beiträge/Ausgaben).
- Dark-Mode-spezifisches Chart-Theming (App hat aktuell kein Dark-Theme).
- Anzeige aller Spieler im Offene-Beiträge-Chart (nur Top 10 + Link auf bestehende
Mitgliederverwaltung).
- Persistierung/Materialisierung historischer Kassenstände (Berechnung erfolgt zur Laufzeit aus
bestehenden Transaktionsdaten).

View File

@@ -7,7 +7,7 @@
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/icons/icon.png",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -8,49 +8,49 @@
"start_url": "./",
"icons": [
{
"src": "icons/icon-72x72.png",
"src": "icons/icon.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"src": "icons/icon.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"src": "icons/icon.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"src": "icons/icon.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"src": "icons/icon.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-192x192.png",
"src": "icons/icon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"src": "icons/icon.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"src": "icons/icon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"

View File

@@ -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>

View File

@@ -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 }),
);
});
});

View File

@@ -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 {

View File

@@ -122,8 +122,18 @@
<span>{{ penalty.description }}</span>
<strong>{{ penalty.amount | currency: 'EUR' }}</strong>
</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
mat-button
type="button"
@@ -142,8 +152,8 @@
>
<mat-icon>delete</mat-icon>Löschen
</button>
</div>
}
}
</div>
}
</mat-card>
}

View File

@@ -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 })))

View File

@@ -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);

View File

@@ -6,7 +6,7 @@
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2e7d32" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="icon" type="image/x-icon" href="icons/icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
<link