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",
|
"installMode": "prefetch",
|
||||||
"resources": {
|
"resources": {
|
||||||
"files": [
|
"files": [
|
||||||
"/favicon.ico",
|
"/icons/icon.png",
|
||||||
"/index.csr.html",
|
"/index.csr.html",
|
||||||
"/index.html",
|
"/index.html",
|
||||||
"/manifest.webmanifest",
|
"/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": "./",
|
"start_url": "./",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "icons/icon-72x72.png",
|
"src": "icons/icon.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "maskable any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-96x96.png",
|
"src": "icons/icon.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "maskable any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-128x128.png",
|
"src": "icons/icon.png",
|
||||||
"sizes": "128x128",
|
"sizes": "128x128",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "maskable any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-144x144.png",
|
"src": "icons/icon.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "maskable any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-152x152.png",
|
"src": "icons/icon.png",
|
||||||
"sizes": "152x152",
|
"sizes": "152x152",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "maskable any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-192x192.png",
|
"src": "icons/icon.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "maskable any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-384x384.png",
|
"src": "icons/icon.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "maskable any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-512x512.png",
|
"src": "icons/icon.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "maskable any"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#2e7d32" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||||
<link
|
<link
|
||||||
|
|||||||