Merge branch 'worktree-theoretischer-kassenstand'
This commit is contained in:
431
docs/superpowers/plans/2026-08-03-theoretischer-kassenstand.md
Normal file
431
docs/superpowers/plans/2026-08-03-theoretischer-kassenstand.md
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
# Theoretischer Kassenstand 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:** Eine zweite, gestrichelte Linie im bestehenden "Kassenstand-Verlauf"-Chart auf der Team-Übersicht zeigt pro Monat den theoretischen Kassenstand (Ist-Kassenstand + zu diesem Zeitpunkt offene, noch unbezahlte Beiträge).
|
||||||
|
|
||||||
|
**Architecture:** Backend (`teams.service.ts#getOverviewStats`) rekonstruiert zusätzlich zur bestehenden Team-Kassenstand-Historie eine Spieler-Saldo-Historie pro aktivem Spieler (gleiches Rückwärts-Reconstruction-Muster wie beim Team-Kassenstand, nur mit allen Buchungstypen statt nur `payment`), summiert sie pro Monat zu "offene Beiträge" und addiert das Ergebnis als `theoreticalBalance`-Feld in jeden bestehenden `balanceHistory`-Punkt. Frontend übernimmt das neue Feld unverändert strukturell als zweite Chart.js-Datenserie im bestehenden Liniendiagramm.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS/TypeORM (Backend, `myteamwallet_backend`), Angular 21 mit Chart.js (Frontend, `myteamwallet_frontend_modern`), Jest (Backend-Tests), Vitest (Frontend-Tests).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Referenz-Spec: `docs/superpowers/specs/2026-08-03-theoretischer-kassenstand-design.md` (und Basis-Feature `docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md`).
|
||||||
|
- `theoreticalBalance` wird als zusätzliches Feld in die bestehenden `balanceHistory`-Punkte eingebettet, kein separates Array.
|
||||||
|
- Historische Rekonstruktion nutzt die *heutige* Menge aktiver Spieler (kein historisches Mitgliedschafts-Tracking) — bewusste Näherung, identisch zur bestehenden `balanceHistory`-Logik.
|
||||||
|
- Buchungen mit Notiz-Präfix `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX` (aus `team-members.service.ts`) werden aus der Rekonstruktion ausgeschlossen.
|
||||||
|
- Vorzeichen-Regel für Spieler-Buchungen: `type.id > 10` (Strafe/Umlage/Gebühr) mindert den Saldo, alle anderen Typen erhöhen ihn — exakt wie in `TeamMembersService.recomputeBalance` und `Transaction.setBalance()`.
|
||||||
|
- Zweite Chart-Linie: gestrichelt (`borderDash: [6, 4]`), Farbe `#1d70b8`, Label „Theoretisch (inkl. offene Beiträge)". Bestehende Ist-Linie bleibt `#4f8f46`, durchgezogen.
|
||||||
|
- `balanceChartOptions.plugins.legend.display` wechselt von `false` auf sichtbar (`position: 'bottom'`, wie beim bestehenden Flow-Chart).
|
||||||
|
- Gating unverändert: hat das Team gar keine Kassenbewegung (`movements.length === 0`), bleibt `balanceHistory: []` (kein Chart, wie heute).
|
||||||
|
- Bestehende Felder `monthlyFlow` und `topOutstanding` bleiben strukturell unverändert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Backend — `theoreticalBalance` in `getOverviewStats` berechnen
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `myteamwallet_backend/src/teams/teams.service.ts:1-15` (Import), `:231-334` (`getOverviewStats` + neue private Hilfsmethoden)
|
||||||
|
- Create: `myteamwallet_backend/src/teams/teams.service.spec.ts` (existiert noch nicht)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX` (exportiert aus `myteamwallet_backend/src/teams/team-members.service.ts`), `Transaction`-Entity (bereits importiert in `teams.service.ts`), `Player`-Entity (bereits importiert).
|
||||||
|
- Produces: `getOverviewStats(...)` liefert `balanceHistory: { month: string; balance: number; theoreticalBalance: number }[]` — dieses Feld konsumiert Task 2 im Frontend (`BalanceHistoryPoint.theoreticalBalance`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Neue Testdatei mit fehlschlagenden Tests schreiben**
|
||||||
|
|
||||||
|
Erstelle `myteamwallet_backend/src/teams/teams.service.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { TeamsService } from './teams.service';
|
||||||
|
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||||
|
|
||||||
|
function monthKey(monthsAgo: number): string {
|
||||||
|
const now = new Date();
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, 1);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(monthsAgo: number, day: number): string {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - monthsAgo, day).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TeamsService#getOverviewStats theoretical balance', () => {
|
||||||
|
const repository = { findOneOrFail: jest.fn() };
|
||||||
|
const access = { assertMember: jest.fn() };
|
||||||
|
let service: TeamsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
access.assertMember.mockResolvedValue(undefined);
|
||||||
|
service = new TeamsService(
|
||||||
|
repository as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||||
|
access as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds still-open, unpaid debt to the theoretical balance while leaving the actual cash balance untouched', async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 100,
|
||||||
|
// Bewegung liegt außerhalb des 12-Monats-Fensters, damit der Ist-Kassenstand
|
||||||
|
// über das gesamte sichtbare Fenster flach bei 100 bleibt.
|
||||||
|
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Muster',
|
||||||
|
active: true,
|
||||||
|
balance: -30,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
date: isoDate(2, 10),
|
||||||
|
amount: 30,
|
||||||
|
type: { id: 11, name: 'fine' },
|
||||||
|
note: 'Zu spät zum Training',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
|
||||||
|
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
||||||
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||||
|
|
||||||
|
expect(beforeFine?.balance).toBe(100);
|
||||||
|
expect(beforeFine?.theoreticalBalance).toBe(100);
|
||||||
|
expect(now?.balance).toBe(100);
|
||||||
|
// Sanity-Check: entspricht team.balance (100) + aktuelle offene Beiträge (30).
|
||||||
|
expect(now?.theoreticalBalance).toBe(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes deactivation-adjustment transactions from the historical reconstruction', async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 50,
|
||||||
|
transactions: [{ date: isoDate(13, 5), amount: 50, type: { name: 'credit' } }],
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Muster',
|
||||||
|
active: true,
|
||||||
|
balance: -20,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
date: isoDate(6, 5),
|
||||||
|
amount: 999,
|
||||||
|
type: { id: 1, name: 'credit' },
|
||||||
|
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #1`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: isoDate(1, 10),
|
||||||
|
amount: 20,
|
||||||
|
type: { id: 11, name: 'fine' },
|
||||||
|
note: 'Zu spät',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
||||||
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||||
|
|
||||||
|
// Wäre die Ausgleichsbuchung (999) nicht ausgeschlossen, würde sie hier bereits
|
||||||
|
// durchschlagen (beforeFine liegt chronologisch nach ihrem Datum) — tut sie aber nicht.
|
||||||
|
expect(beforeFine?.theoreticalBalance).toBe(50);
|
||||||
|
expect(now?.theoreticalBalance).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 0,
|
||||||
|
transactions: [],
|
||||||
|
players: [{ id: 1, firstName: 'Alex', lastName: 'Muster', active: true, balance: 0, transactions: [] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
expect(result.balanceHistory).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Tests ausführen und Fehlschlag bestätigen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && npx jest src/teams/teams.service.spec.ts`
|
||||||
|
Expected: FAIL — `result.balanceHistory` Punkte haben noch kein `theoreticalBalance`-Feld (`undefined` statt der erwarteten Zahlen), erste zwei Tests schlagen fehl. Dritter Test (Leerfall) sollte bereits PASS sein (unverändertes Verhalten) — das bestätigt, dass der Testaufbau korrekt gegen die bisherige Implementierung läuft.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX`-Import ergänzen**
|
||||||
|
|
||||||
|
In `myteamwallet_backend/src/teams/teams.service.ts`, nach der bestehenden Import-Zeile für `TeamAccessService` (Zeile ~15) ergänzen:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rückgabetyp von `getOverviewStats` erweitern**
|
||||||
|
|
||||||
|
In `teams.service.ts`, die Signatur von `getOverviewStats` (aktuell Zeile 231-238) ändern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async getOverviewStats(
|
||||||
|
teamId: string | number,
|
||||||
|
actorUserId: string | number,
|
||||||
|
): Promise<{
|
||||||
|
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[];
|
||||||
|
monthlyFlow: { month: string; income: number; expense: number }[];
|
||||||
|
topOutstanding: { playerId: number; playerName: string; balance: number }[];
|
||||||
|
}> {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Berechnung von `theoreticalBalance` einfügen**
|
||||||
|
|
||||||
|
In `teams.service.ts`, direkt vor dem finalen `return { balanceHistory, monthlyFlow, topOutstanding };` am Ende von `getOverviewStats` (aktuell Zeile 333) einfügen:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const outstandingHistory = this.reconstructOutstandingHistory(months, players);
|
||||||
|
const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({
|
||||||
|
...point,
|
||||||
|
theoreticalBalance: this.round(point.balance - outstandingHistory[index]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
und die alte letzte Zeile ` return { balanceHistory, monthlyFlow, topOutstanding };` entfernen (sie wird durch den obigen Block ersetzt).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Private Hilfsmethoden ergänzen**
|
||||||
|
|
||||||
|
In `teams.service.ts`, nach der bestehenden privaten Methode `signedFlowAmount` (aktuell Zeile 346-348) einfügen:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private reconstructOutstandingHistory(months: string[], players: Player[]): number[] {
|
||||||
|
const activePlayers = players.filter((p) => p.active);
|
||||||
|
const totals = months.map(() => 0);
|
||||||
|
|
||||||
|
for (const player of activePlayers) {
|
||||||
|
const realTransactions = (player.transactions ?? []).filter(
|
||||||
|
(t) => !t.note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX),
|
||||||
|
);
|
||||||
|
const playerHistory = this.reconstructPlayerBalanceHistory(
|
||||||
|
months,
|
||||||
|
Number(player.balance),
|
||||||
|
realTransactions,
|
||||||
|
);
|
||||||
|
playerHistory.forEach((balance, index) => {
|
||||||
|
totals[index] += balance;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reconstructPlayerBalanceHistory(
|
||||||
|
months: string[],
|
||||||
|
currentBalance: number,
|
||||||
|
transactions: Transaction[],
|
||||||
|
): number[] {
|
||||||
|
const descendingMovements = transactions
|
||||||
|
.map((t) => ({
|
||||||
|
date: t.date,
|
||||||
|
amount: t.type && t.type.id > 10 ? -Number(t.amount) : Number(t.amount),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.date > b.date ? -1 : a.date < b.date ? 1 : 0));
|
||||||
|
|
||||||
|
let futureSum = 0;
|
||||||
|
let movementIndex = 0;
|
||||||
|
return [...months]
|
||||||
|
.reverse()
|
||||||
|
.map((month) => {
|
||||||
|
while (
|
||||||
|
movementIndex < descendingMovements.length &&
|
||||||
|
descendingMovements[movementIndex].date.slice(0, 7) > month
|
||||||
|
) {
|
||||||
|
futureSum += descendingMovements[movementIndex].amount;
|
||||||
|
movementIndex++;
|
||||||
|
}
|
||||||
|
return currentBalance - futureSum;
|
||||||
|
})
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Transaction` und `Player` sind in `teams.service.ts` bereits importiert (Zeile 4 bzw. 10 im bestehenden Import-Block) — keine weiteren Imports nötig.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Tests ausführen und Erfolg bestätigen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && npx jest src/teams/teams.service.spec.ts`
|
||||||
|
Expected: PASS — alle drei Tests grün.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Vollständige Backend-Suite und Build laufen lassen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_backend && npm test -- --silent && npm run build`
|
||||||
|
Expected: alle bestehenden Tests weiterhin PASS (insbesondere keine Regression in anderen `teams`-Tests), Build ohne TypeScript-Fehler.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd myteamwallet_backend
|
||||||
|
git add src/teams/teams.service.ts src/teams/teams.service.spec.ts
|
||||||
|
git commit -m "feat: add theoretical balance history to team overview stats
|
||||||
|
|
||||||
|
Reconstructs each active player's balance per month (same backward
|
||||||
|
technique as the existing cash-balance history) so the overview stats
|
||||||
|
endpoint can report what the team balance would be if all currently
|
||||||
|
open dues had already been paid."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Frontend — zweite Chart-Linie im Kassenstand-Verlauf
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `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.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `theoreticalBalance` Feld aus Task 1 (`BalanceHistoryPoint.theoreticalBalance: number`, via `GET /teams/:id/overview/stats`).
|
||||||
|
- Produces: keine neuen öffentlichen Interfaces — reine Chart-Darstellungs-Änderung innerhalb `Overview`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Modell erweitern**
|
||||||
|
|
||||||
|
In `myteamwallet_frontend_modern/src/app/models/team-stats.model.ts`, `BalanceHistoryPoint` ändern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface BalanceHistoryPoint {
|
||||||
|
month: string;
|
||||||
|
balance: number;
|
||||||
|
theoreticalBalance: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fehlschlagenden Test schreiben**
|
||||||
|
|
||||||
|
In `myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts`, `sampleStats` (aktuell Zeile 22-32) um `theoreticalBalance` ergänzen:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const sampleStats: TeamOverviewStats = {
|
||||||
|
balanceHistory: [
|
||||||
|
{ month: '2026-06', balance: 100, theoreticalBalance: 100 },
|
||||||
|
{ month: '2026-07', balance: 125, theoreticalBalance: 150 },
|
||||||
|
],
|
||||||
|
monthlyFlow: [
|
||||||
|
{ month: '2026-06', income: 50, expense: 10 },
|
||||||
|
{ month: '2026-07', income: 40, expense: 15 },
|
||||||
|
],
|
||||||
|
topOutstanding: [{ playerId: 3, playerName: 'Chris Beispiel', balance: 20 }],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Test `'passes the loaded stats to each ChartCanvas once the request resolves'` (aktuell Zeile 181-200), nach der bestehenden Assertion `expect(balanceChart.data.labels).toHaveLength(2);` ergänzen:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(balanceChart.data.datasets).toHaveLength(2);
|
||||||
|
expect(balanceChart.data.datasets[0].data).toEqual([100, 125]);
|
||||||
|
expect(balanceChart.data.datasets[1].label).toBe('Theoretisch (inkl. offene Beiträge)');
|
||||||
|
expect(balanceChart.data.datasets[1].data).toEqual([100, 150]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test ausführen und Fehlschlag bestätigen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/overview.spec.ts'`
|
||||||
|
Expected: FAIL — `balanceChart.data.datasets` hat noch Länge 1, die neuen Assertions schlagen fehl (bzw. TypeScript-Compile-Fehler, weil `sampleStats` noch nicht zum erweiterten `BalanceHistoryPoint`-Typ passt, falls Step 1 vor Step 2 gemacht wurde — in diesem Fall zunächst nur diesen Test isoliert betrachten).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Zweite Datenserie und Legende in `overview.ts` ergänzen**
|
||||||
|
|
||||||
|
In `myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts`, die Farbkonstanten (aktuell Zeile 23-25) um die neue Farbe ergänzen:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const BALANCE_COLOR = '#4f8f46';
|
||||||
|
const THEORETICAL_BALANCE_COLOR = '#1d70b8';
|
||||||
|
const INCOME_COLOR = '#4f8f46';
|
||||||
|
const EXPENSE_COLOR = '#c1121f';
|
||||||
|
```
|
||||||
|
|
||||||
|
`balanceChartData` (aktuell Zeile 65-80) um die zweite Datenserie erweitern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
protected readonly balanceChartData = computed<ChartData>(() => {
|
||||||
|
const points = this.balanceHistory();
|
||||||
|
return {
|
||||||
|
labels: points.map((point) => formatMonthLabel(point.month)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Kassenstand',
|
||||||
|
data: points.map((point) => point.balance),
|
||||||
|
borderColor: BALANCE_COLOR,
|
||||||
|
backgroundColor: BALANCE_COLOR,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Theoretisch (inkl. offene Beiträge)',
|
||||||
|
data: points.map((point) => point.theoreticalBalance),
|
||||||
|
borderColor: THEORETICAL_BALANCE_COLOR,
|
||||||
|
backgroundColor: THEORETICAL_BALANCE_COLOR,
|
||||||
|
borderDash: [6, 4],
|
||||||
|
tension: 0.3,
|
||||||
|
fill: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`balanceChartOptions` (aktuell Zeile 115-119) die Legende einblenden:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
protected readonly balanceChartOptions: ChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Test ausführen und Erfolg bestätigen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/overview.spec.ts'`
|
||||||
|
Expected: PASS — alle Tests in `overview.spec.ts` grün, inklusive der neuen Dataset-Assertions.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Vollständige Frontend-Suite laufen lassen**
|
||||||
|
|
||||||
|
Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false`
|
||||||
|
Expected: alle Tests PASS (keine Regression in anderen Specs durch die geänderte `BalanceHistoryPoint`-Typform).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd myteamwallet_frontend_modern
|
||||||
|
git add src/app/models/team-stats.model.ts src/app/features/team/overview/overview.ts src/app/features/team/overview/overview.spec.ts
|
||||||
|
git commit -m "feat: show theoretical balance line in the cash-balance chart
|
||||||
|
|
||||||
|
Adds a second, dashed line to the existing balance-history chart that
|
||||||
|
includes currently open player dues, so managers can see at a glance
|
||||||
|
how far the actual cash balance lags behind what has been pledged."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec-Abdeckung:** Backend-Berechnung (Task 1, Steps 4-6), Response-Form-Änderung (Task 1, Step 4), Frontend-Chart/Legende/Farben (Task 2, Step 4), Testing-Anforderungen aus der Spec (historische Rekonstruktion, Ausgleichsbuchungs-Ausschluss, Leerfall, Frontend-Dataset-Assertions) sind je in eigenen Test-Steps abgedeckt. Manuelle Verifikation aus der Spec ist bewusst nicht als Plan-Task modelliert — bei Bedarf nach Abschluss beider Tasks manuell im Browser gegen ein Team mit unbezahlter Strafe prüfen.
|
||||||
|
- **Typkonsistenz:** `theoreticalBalance: number` konsistent in Backend-Rückgabetyp (Task 1, Step 4), Frontend-Modell (Task 2, Step 1) und allen Test-Fixtures verwendet.
|
||||||
|
- **Scope:** Einzelne, in sich geschlossene Erweiterung eines bereits bestehenden Features — keine weitere Zerlegung nötig.
|
||||||
207
myteamwallet_backend/src/teams/teams.service.spec.ts
Normal file
207
myteamwallet_backend/src/teams/teams.service.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { TeamsService } from './teams.service';
|
||||||
|
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||||
|
|
||||||
|
function monthKey(monthsAgo: number): string {
|
||||||
|
const now = new Date();
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, 1);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(monthsAgo: number, day: number): string {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - monthsAgo, day).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TeamsService#getOverviewStats theoretical balance', () => {
|
||||||
|
const repository = { findOneOrFail: jest.fn() };
|
||||||
|
const access = { assertMember: jest.fn() };
|
||||||
|
let service: TeamsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
access.assertMember.mockResolvedValue(undefined);
|
||||||
|
service = new TeamsService(
|
||||||
|
repository as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
||||||
|
access as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds still-open, unpaid debt to the theoretical balance while leaving the actual cash balance untouched', async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 100,
|
||||||
|
// Bewegung liegt außerhalb des 12-Monats-Fensters, damit der Ist-Kassenstand
|
||||||
|
// über das gesamte sichtbare Fenster flach bei 100 bleibt.
|
||||||
|
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Muster',
|
||||||
|
active: true,
|
||||||
|
balance: -30,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
date: isoDate(2, 10),
|
||||||
|
amount: 30,
|
||||||
|
type: { id: 11, name: 'fine' },
|
||||||
|
note: 'Zu spät zum Training',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
|
||||||
|
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
||||||
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||||
|
|
||||||
|
expect(beforeFine?.balance).toBe(100);
|
||||||
|
expect(beforeFine?.theoreticalBalance).toBe(100);
|
||||||
|
expect(now?.balance).toBe(100);
|
||||||
|
// Sanity-Check: entspricht team.balance (100) + aktuelle offene Beiträge (30).
|
||||||
|
expect(now?.theoreticalBalance).toBe(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes deactivation-adjustment transactions from the historical reconstruction', async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 50,
|
||||||
|
transactions: [{ date: isoDate(13, 5), amount: 50, type: { name: 'credit' } }],
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Muster',
|
||||||
|
active: true,
|
||||||
|
balance: -20,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
date: isoDate(6, 5),
|
||||||
|
amount: 999,
|
||||||
|
type: { id: 1, name: 'credit' },
|
||||||
|
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #1`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: isoDate(1, 10),
|
||||||
|
amount: 20,
|
||||||
|
type: { id: 11, name: 'fine' },
|
||||||
|
note: 'Zu spät',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
// Older than the adjustment's own month (6 months ago) — the only kind of checkpoint
|
||||||
|
// where wrongly including the €999 adjustment would still show up as "already happened".
|
||||||
|
const beforeAdjustment = result.balanceHistory.find((p) => p.month === monthKey(9));
|
||||||
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||||
|
|
||||||
|
// Without exclusion this would read 1049 (50 + the wrongly-included €999 adjustment);
|
||||||
|
// with correct exclusion only the (not-yet-existing-at-month-9) fine is irrelevant here too,
|
||||||
|
// so it stays at the team's flat 50.
|
||||||
|
expect(beforeAdjustment?.theoreticalBalance).toBe(50);
|
||||||
|
expect(now?.theoreticalBalance).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes an inactive player's outstanding debt from the theoretical balance", async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 100,
|
||||||
|
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Muster',
|
||||||
|
active: true,
|
||||||
|
balance: 0,
|
||||||
|
transactions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
firstName: 'Bea',
|
||||||
|
lastName: 'Beispiel',
|
||||||
|
active: false,
|
||||||
|
balance: -40,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
date: isoDate(2, 10),
|
||||||
|
amount: 40,
|
||||||
|
type: { id: 11, name: 'fine' },
|
||||||
|
note: 'Zu spät',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||||
|
|
||||||
|
// The inactive player's unpaid fine is real debt, but no longer counted at all —
|
||||||
|
// the theoretical line must match the actual cash balance exactly.
|
||||||
|
expect(now?.balance).toBe(100);
|
||||||
|
expect(now?.theoreticalBalance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowers the theoretical balance when a player has prepaid credit not yet reflected as spent', async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 100,
|
||||||
|
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Muster',
|
||||||
|
active: true,
|
||||||
|
balance: 30,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
date: isoDate(2, 10),
|
||||||
|
amount: 30,
|
||||||
|
type: { id: 1, name: 'credit' },
|
||||||
|
note: 'Vorauszahlung',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||||
|
|
||||||
|
// Prepaid credit is not yet "spent" in the reconstruction, so the theoretical
|
||||||
|
// line dips below the actual cash balance at this checkpoint.
|
||||||
|
expect(now?.balance).toBe(100);
|
||||||
|
expect(now?.theoreticalBalance).toBe(70);
|
||||||
|
expect(now!.theoreticalBalance).toBeLessThan(now!.balance);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 0,
|
||||||
|
transactions: [],
|
||||||
|
players: [{ id: 1, firstName: 'Alex', lastName: 'Muster', active: true, balance: 0, transactions: [] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
expect(result.balanceHistory).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import { CreateTeamDTO } from './dto/create-team.dto';
|
|||||||
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
|
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
|
||||||
import { Team } from './entities/team.entity';
|
import { Team } from './entities/team.entity';
|
||||||
import { TeamAccessService } from './team-access.service';
|
import { TeamAccessService } from './team-access.service';
|
||||||
|
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TeamsService {
|
export class TeamsService {
|
||||||
@@ -232,7 +233,7 @@ export class TeamsService {
|
|||||||
teamId: string | number,
|
teamId: string | number,
|
||||||
actorUserId: string | number,
|
actorUserId: string | number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
balanceHistory: { month: string; balance: number }[];
|
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[];
|
||||||
monthlyFlow: { month: string; income: number; expense: number }[];
|
monthlyFlow: { month: string; income: number; expense: number }[];
|
||||||
topOutstanding: { playerId: number; playerName: string; balance: number }[];
|
topOutstanding: { playerId: number; playerName: string; balance: number }[];
|
||||||
}> {
|
}> {
|
||||||
@@ -298,26 +299,16 @@ export class TeamsService {
|
|||||||
// reconstruct earlier month-end balances — this guarantees the most
|
// reconstruct earlier month-end balances — this guarantees the most
|
||||||
// recent point always equals team.balance by construction, regardless
|
// recent point always equals team.balance by construction, regardless
|
||||||
// of undocumented history.
|
// of undocumented history.
|
||||||
const descendingMovements = [...movements].sort((a, b) =>
|
const signedMovements = movements.map((m) => ({
|
||||||
a.date > b.date ? -1 : a.date < b.date ? 1 : 0,
|
date: m.date,
|
||||||
);
|
amount: this.signedFlowAmount(m),
|
||||||
|
}));
|
||||||
const currentBalance = Number(team.balance);
|
const currentBalance = Number(team.balance);
|
||||||
let futureSum = 0;
|
const rawBalances = this.reconstructBackward(months, currentBalance, signedMovements);
|
||||||
let movementIndex = 0;
|
const balanceHistory = months.map((month, index) => ({
|
||||||
const balanceHistory = [...months]
|
month,
|
||||||
.reverse()
|
balance: this.round(rawBalances[index]),
|
||||||
.map((month) => {
|
}));
|
||||||
while (
|
|
||||||
movementIndex < descendingMovements.length &&
|
|
||||||
descendingMovements[movementIndex].date.slice(0, 7) > month
|
|
||||||
) {
|
|
||||||
futureSum += this.signedFlowAmount(descendingMovements[movementIndex]);
|
|
||||||
movementIndex++;
|
|
||||||
}
|
|
||||||
return { month, balance: this.round(currentBalance - futureSum) };
|
|
||||||
})
|
|
||||||
.reverse();
|
|
||||||
|
|
||||||
const monthlyFlow = months.map((month) => {
|
const monthlyFlow = months.map((month) => {
|
||||||
const monthMovements = movements.filter((m) => m.date.slice(0, 7) === month);
|
const monthMovements = movements.filter((m) => m.date.slice(0, 7) === month);
|
||||||
@@ -330,7 +321,13 @@ export class TeamsService {
|
|||||||
return { month, income: this.round(income), expense: this.round(expense) };
|
return { month, income: this.round(income), expense: this.round(expense) };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { balanceHistory, monthlyFlow, topOutstanding };
|
const outstandingHistory = this.reconstructOutstandingHistory(months, players);
|
||||||
|
const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({
|
||||||
|
...point,
|
||||||
|
theoreticalBalance: this.round(point.balance + outstandingHistory[index]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding };
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLast12Months(): string[] {
|
private getLast12Months(): string[] {
|
||||||
@@ -347,6 +344,65 @@ export class TeamsService {
|
|||||||
return movement.type === 'expense' ? -movement.amount : movement.amount;
|
return movement.type === 'expense' ? -movement.amount : movement.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private reconstructBackward(
|
||||||
|
months: string[],
|
||||||
|
currentValue: number,
|
||||||
|
signedMovements: { date: string; amount: number }[],
|
||||||
|
): number[] {
|
||||||
|
const descending = [...signedMovements].sort((a, b) =>
|
||||||
|
a.date > b.date ? -1 : a.date < b.date ? 1 : 0,
|
||||||
|
);
|
||||||
|
let futureSum = 0;
|
||||||
|
let index = 0;
|
||||||
|
return [...months]
|
||||||
|
.reverse()
|
||||||
|
.map((month) => {
|
||||||
|
while (index < descending.length && descending[index].date.slice(0, 7) > month) {
|
||||||
|
futureSum += descending[index].amount;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return currentValue - futureSum;
|
||||||
|
})
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reconstructOutstandingHistory(months: string[], players: Player[]): number[] {
|
||||||
|
const activePlayers = players.filter((p) => p.active);
|
||||||
|
const totals = months.map(() => 0);
|
||||||
|
|
||||||
|
for (const player of activePlayers) {
|
||||||
|
const realTransactions = (player.transactions ?? []).filter(
|
||||||
|
(t) => !t.note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX),
|
||||||
|
);
|
||||||
|
const playerHistory = this.reconstructPlayerBalanceHistory(
|
||||||
|
months,
|
||||||
|
Number(player.balance),
|
||||||
|
realTransactions,
|
||||||
|
);
|
||||||
|
playerHistory.forEach((balance, index) => {
|
||||||
|
totals[index] += balance;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// House convention (see getOverview()'s team.outstanding = out * -1): "outstanding"
|
||||||
|
// is a positive number when players owe money, negative when they're in credit.
|
||||||
|
return totals.map((total) => total * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private reconstructPlayerBalanceHistory(
|
||||||
|
months: string[],
|
||||||
|
currentBalance: number,
|
||||||
|
transactions: Transaction[],
|
||||||
|
): number[] {
|
||||||
|
const signedMovements = transactions.map((t) => {
|
||||||
|
const rawAmount = Number(t.amount);
|
||||||
|
const amount = t.type && t.type.id > 10 && rawAmount > 0 ? -rawAmount : rawAmount;
|
||||||
|
return { date: t.date, amount };
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.reconstructBackward(months, currentBalance, signedMovements);
|
||||||
|
}
|
||||||
|
|
||||||
private round(value: number): number {
|
private round(value: number): number {
|
||||||
return Math.round(value * 100) / 100;
|
return Math.round(value * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe('TeamStatsApi', () => {
|
|||||||
|
|
||||||
it('loads the overview stats for a team', () => {
|
it('loads the overview stats for a team', () => {
|
||||||
const stats: TeamOverviewStats = {
|
const stats: TeamOverviewStats = {
|
||||||
balanceHistory: [{ month: '2026-07', balance: 125 }],
|
balanceHistory: [{ month: '2026-07', balance: 125, theoreticalBalance: 150 }],
|
||||||
monthlyFlow: [{ month: '2026-07', income: 50, expense: 12 }],
|
monthlyFlow: [{ month: '2026-07', income: 50, expense: 12 }],
|
||||||
topOutstanding: [{ playerId: 3, playerName: 'Alex Muster', balance: 20 }],
|
topOutstanding: [{ playerId: 3, playerName: 'Alex Muster', balance: 20 }],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ vi.mock('chart.js', () => ({ Chart: MockChart, registerables: [] }));
|
|||||||
|
|
||||||
const sampleStats: TeamOverviewStats = {
|
const sampleStats: TeamOverviewStats = {
|
||||||
balanceHistory: [
|
balanceHistory: [
|
||||||
{ month: '2026-06', balance: 100 },
|
{ month: '2026-06', balance: 100, theoreticalBalance: 100 },
|
||||||
{ month: '2026-07', balance: 125 },
|
{ month: '2026-07', balance: 125, theoreticalBalance: 150 },
|
||||||
],
|
],
|
||||||
monthlyFlow: [
|
monthlyFlow: [
|
||||||
{ month: '2026-06', income: 50, expense: 10 },
|
{ month: '2026-06', income: 50, expense: 10 },
|
||||||
@@ -193,6 +193,11 @@ describe('Overview', () => {
|
|||||||
);
|
);
|
||||||
expect(balanceChart.type).toBe('line');
|
expect(balanceChart.type).toBe('line');
|
||||||
expect(balanceChart.data.labels).toHaveLength(2);
|
expect(balanceChart.data.labels).toHaveLength(2);
|
||||||
|
expect(balanceChart.data.datasets).toHaveLength(2);
|
||||||
|
expect(balanceChart.data.datasets[0].data).toEqual([100, 125]);
|
||||||
|
expect(balanceChart.data.datasets[1].label).toBe('Theoretisch (inkl. offene Beiträge)');
|
||||||
|
expect(balanceChart.data.datasets[1].data).toEqual([100, 150]);
|
||||||
|
expect(balanceChart.options?.plugins?.legend?.position).toBe('bottom');
|
||||||
expect(flowChart.type).toBe('bar');
|
expect(flowChart.type).toBe('bar');
|
||||||
expect(flowChart.data.datasets).toHaveLength(2);
|
expect(flowChart.data.datasets).toHaveLength(2);
|
||||||
expect(outstandingChart.type).toBe('bar');
|
expect(outstandingChart.type).toBe('bar');
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/oper
|
|||||||
registerLocaleData(localeDe);
|
registerLocaleData(localeDe);
|
||||||
|
|
||||||
const BALANCE_COLOR = '#4f8f46';
|
const BALANCE_COLOR = '#4f8f46';
|
||||||
|
const THEORETICAL_BALANCE_COLOR = '#1d70b8';
|
||||||
const INCOME_COLOR = '#4f8f46';
|
const INCOME_COLOR = '#4f8f46';
|
||||||
const EXPENSE_COLOR = '#c1121f';
|
const EXPENSE_COLOR = '#c1121f';
|
||||||
|
|
||||||
@@ -75,6 +76,15 @@ export class Overview {
|
|||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: false,
|
fill: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Theoretisch (inkl. offene Beiträge)',
|
||||||
|
data: points.map((point) => point.theoreticalBalance),
|
||||||
|
borderColor: THEORETICAL_BALANCE_COLOR,
|
||||||
|
backgroundColor: THEORETICAL_BALANCE_COLOR,
|
||||||
|
borderDash: [6, 4],
|
||||||
|
tension: 0.3,
|
||||||
|
fill: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -115,7 +125,7 @@ export class Overview {
|
|||||||
protected readonly balanceChartOptions: ChartOptions = {
|
protected readonly balanceChartOptions: ChartOptions = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { position: 'bottom' } },
|
||||||
};
|
};
|
||||||
|
|
||||||
protected readonly flowChartOptions: ChartOptions = {
|
protected readonly flowChartOptions: ChartOptions = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface BalanceHistoryPoint {
|
export interface BalanceHistoryPoint {
|
||||||
month: string;
|
month: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
|
theoreticalBalance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonthlyFlowPoint {
|
export interface MonthlyFlowPoint {
|
||||||
|
|||||||
Reference in New Issue
Block a user