Compare commits
5 Commits
9664187049
...
369d556a8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
369d556a8b | ||
|
|
727ff0b1af | ||
|
|
c1238929ef | ||
|
|
b6f311b11b | ||
|
|
5dae4362b2 |
67
docs/superpowers/plans/2026-08-01-kasse-kpi-charts.md
Normal 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.
|
||||
145
docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md
Normal 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).
|
||||
@@ -7,7 +7,7 @@
|
||||
"installMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/favicon.ico",
|
||||
"/icons/icon.png",
|
||||
"/index.csr.html",
|
||||
"/index.html",
|
||||
"/manifest.webmanifest",
|
||||
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
BIN
myteamwallet_frontend_modern/public/icons/icon.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||