diff --git a/myteamwallet_backend/src/teams/teams.controller.ts b/myteamwallet_backend/src/teams/teams.controller.ts index d39a5e0..1ac20de 100644 --- a/myteamwallet_backend/src/teams/teams.controller.ts +++ b/myteamwallet_backend/src/teams/teams.controller.ts @@ -86,6 +86,16 @@ export class TeamsController { return this.service.getOverview(id); } + @ApiBearerAuth() + @Roles([RoleEnum.user, RoleEnum.admin]) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Get(':id/overview/stats') + @HttpCode(HttpStatus.OK) + getOverviewStats(@Req() req, @Param('id') id: string) { + const userId = req.user?.id; + return this.service.getOverviewStats(id, userId); + } + @ApiOperation({ summary: 'Transactionen für ein Team', description: diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index 3bac329..8f1533f 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -11,6 +11,7 @@ import { Repository } from 'typeorm'; import { CreateTeamDTO } from './dto/create-team.dto'; import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto'; import { Team } from './entities/team.entity'; +import { TeamAccessService } from './team-access.service'; @Injectable() export class TeamsService { @@ -28,6 +29,7 @@ export class TeamsService { @InjectRepository(TeamWalletTransaction) private teamWalletTransactionRepository: Repository, private logger: LoggingService, + private access: TeamAccessService, ) {} async getOverview(teamId: string) { @@ -218,6 +220,129 @@ export class TeamsService { return result; } + async getOverviewStats( + teamId: string | number, + actorUserId: string | number, + ): Promise<{ + balanceHistory: { month: string; balance: number }[]; + monthlyFlow: { month: string; income: number; expense: number }[]; + topOutstanding: { playerId: number; playerName: string; balance: number }[]; + }> { + const id = Number(teamId); + + // Read access: any active team member may view the KPI charts (same + // audience as the existing :id/overview page), not just managers. + await this.access.assertMember(Number(actorUserId), id); + + const team = await this.repository.findOneOrFail({ + where: { id }, + relations: ['players', 'players.transactions', 'transactions'], + }); + + const players = team.players ?? []; + const teamTransactions = team.transactions ?? []; + + // Only payment/credit/expense movements represent actual cash flow and + // are the only types that touch team.balance (see setBalance() on + // TeamWalletTransaction/Transaction) — fine/levy/fee raise a player's + // debt but never move money, so they are excluded entirely. + const movements: { date: string; amount: number; type: string }[] = []; + + for (const t of teamTransactions) { + movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); + } + + for (const p of players) { + for (const t of p.transactions ?? []) { + if (t.type.name === 'payment') { + movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); + } + } + } + + movements.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); + + const topOutstanding = players + .filter((p) => p.active && Number(p.balance) < 0) + .sort((a, b) => Number(a.balance) - Number(b.balance)) + .slice(0, 10) + .map((p) => ({ + playerId: p.id, + playerName: p.firstName + ' ' + p.lastName, + balance: this.round(Math.abs(Number(p.balance))), + })); + + // A brand-new team with no relevant movements at all (ever, not just in + // the last 12 months) has nothing to chart — return empty arrays so the + // frontend's empty-state fires instead of rendering 12 flat zero points. + if (movements.length === 0) { + return { balanceHistory: [], monthlyFlow: [], topOutstanding }; + } + + const months = this.getLast12Months(); + + // team.balance is the one authoritative, current value — it can include + // historical adjustments (e.g. from the removed legacy backend) that + // don't trace back to the visible payment/credit/expense rows. Forward- + // summing the movements from zero would silently drift away from + // team.balance for such teams. Instead we anchor to team.balance and + // walk the movements backward (newest first), "undoing" each one to + // reconstruct earlier month-end balances — this guarantees the most + // recent point always equals team.balance by construction, regardless + // of undocumented history. + const descendingMovements = [...movements].sort((a, b) => + a.date > b.date ? -1 : a.date < b.date ? 1 : 0, + ); + + const currentBalance = Number(team.balance); + let futureSum = 0; + let movementIndex = 0; + const balanceHistory = [...months] + .reverse() + .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 monthMovements = movements.filter((m) => m.date.slice(0, 7) === month); + const income = monthMovements + .filter((m) => m.type === 'payment' || m.type === 'credit') + .reduce((sum, m) => sum + m.amount, 0); + const expense = monthMovements + .filter((m) => m.type === 'expense') + .reduce((sum, m) => sum + m.amount, 0); + return { month, income: this.round(income), expense: this.round(expense) }; + }); + + return { balanceHistory, monthlyFlow, topOutstanding }; + } + + private getLast12Months(): string[] { + const now = new Date(); + const months: string[] = []; + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`); + } + return months; + } + + private signedFlowAmount(movement: { amount: number; type: string }): number { + return movement.type === 'expense' ? -movement.amount : movement.amount; + } + + private round(value: number): number { + return Math.round(value * 100) / 100; + } + async updatePlayer(playerDTO: UpdatePlayerProfileDto) { const player = await this.playerRepository.findOneOrFail({ where: { diff --git a/myteamwallet_frontend_modern/package-lock.json b/myteamwallet_frontend_modern/package-lock.json index 8967254..611998e 100644 --- a/myteamwallet_frontend_modern/package-lock.json +++ b/myteamwallet_frontend_modern/package-lock.json @@ -17,6 +17,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "@angular/service-worker": "^21.2.0", + "chart.js": "^4.5.1", "qrcode": "^1.5.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -2115,6 +2116,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", @@ -4639,6 +4646,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", diff --git a/myteamwallet_frontend_modern/package.json b/myteamwallet_frontend_modern/package.json index c94655e..1657c43 100644 --- a/myteamwallet_frontend_modern/package.json +++ b/myteamwallet_frontend_modern/package.json @@ -21,6 +21,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "@angular/service-worker": "^21.2.0", + "chart.js": "^4.5.1", "qrcode": "^1.5.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" diff --git a/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts new file mode 100644 index 0000000..15f4133 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TeamStatsApi } from './team-stats-api'; +import { environment } from '../../../environments/environment'; +import { TeamOverviewStats } from '../../models/team-stats.model'; + +describe('TeamStatsApi', () => { + let api: TeamStatsApi; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + api = TestBed.inject(TeamStatsApi); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('loads the overview stats for a team', () => { + const stats: TeamOverviewStats = { + balanceHistory: [{ month: '2026-07', balance: 125 }], + monthlyFlow: [{ month: '2026-07', income: 50, expense: 12 }], + topOutstanding: [{ playerId: 3, playerName: 'Alex Muster', balance: 20 }], + }; + + api.loadStats(5).subscribe((response) => expect(response).toEqual(stats)); + + const request = httpMock.expectOne(`${environment.apiUrl}teams/5/overview/stats`); + expect(request.request.method).toBe('GET'); + request.flush(stats); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.ts b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.ts new file mode 100644 index 0000000..7e156da --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.ts @@ -0,0 +1,14 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { TeamOverviewStats } from '../../models/team-stats.model'; + +@Injectable({ providedIn: 'root' }) +export class TeamStatsApi { + private readonly http = inject(HttpClient); + + loadStats(teamId: number): Observable { + return this.http.get(`${environment.apiUrl}teams/${teamId}/overview/stats`); + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.html b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.html index 473f40f..4835e8c 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.html +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.html @@ -18,6 +18,89 @@ > + +
+
+
+

Kennzahlen

+

Kassenstand-Verlauf

+
+
+ + + @if (loadingStats()) { +
+ } @else if (balanceHistory().length === 0) { +
+ show_chartNoch keine Kassenstand-HistorieSobald Buchungen vorliegen, siehst du den Verlauf hier. +
+ } @else { +
+ +
+ } +
+
+
+ +
+
+
+

Kennzahlen

+

Einnahmen & Ausgaben

+
+
+ + + @if (loadingStats()) { +
+ } @else if (monthlyFlow().length === 0) { +
+ bar_chartNoch keine BewegungenEinnahmen und Ausgaben erscheinen hier pro Monat. +
+ } @else { +
+ +
+ } +
+
+
+ +
+
+
+

Kennzahlen

+

Offene Beiträge (Top 10)

+
+
+ + + @if (loadingStats()) { +
+ } @else if (topOutstanding().length === 0) { +
+ emoji_eventsKeine offenen BeiträgeAlle aktiven Mitglieder sind ausgeglichen. +
+ } @else { +
+ +
+ } + groupAlle Spieler ansehen +
+
+
+

Zuletzt passiert

diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.scss b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.scss index 060a31e..d0df808 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.scss +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.scss @@ -106,6 +106,21 @@ h2 { color: var(--mat-sys-on-surface-variant); text-align: center; } +.chart-block { + margin-bottom: 2rem; +} +.chart-card mat-card-content { + padding: 1rem 1.25rem 1.25rem; +} +.chart-canvas-box { + position: relative; + height: 220px; +} +.chart-card__link { + margin-top: 0.75rem; + display: flex; + justify-content: flex-end; +} @media (max-width: 520px) { .balance-grid { grid-template-columns: 1fr; diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts index 40add21..cc28a55 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts @@ -1,21 +1,52 @@ import { signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, ParamMap, convertToParamMap, provideRouter } from '@angular/router'; import { BehaviorSubject } from 'rxjs'; import { Overview } from './overview'; +import { ChartCanvas } from '../../../shared/chart-canvas/chart-canvas'; import { TeamStore } from '../../../core/team/team-store'; import { environment } from '../../../../environments/environment'; +import { TeamOverviewStats } from '../../../models/team-stats.model'; + +// `vi.mock`'s factory is hoisted above regular imports, so the shared mock class is +// loaded via a dynamic import inside `vi.hoisted` rather than a plain top-level import. +const { MockChart } = await vi.hoisted( + async () => import('../../../shared/chart-canvas/testing/mock-chart'), +); + +vi.mock('chart.js', () => ({ Chart: MockChart, registerables: [] })); + +const sampleStats: TeamOverviewStats = { + balanceHistory: [ + { month: '2026-06', balance: 100 }, + { month: '2026-07', balance: 125 }, + ], + monthlyFlow: [ + { month: '2026-06', income: 50, expense: 10 }, + { month: '2026-07', income: 40, expense: 15 }, + ], + topOutstanding: [{ playerId: 3, playerName: 'Chris Beispiel', balance: 20 }], +}; + +const emptyStats: TeamOverviewStats = { balanceHistory: [], monthlyFlow: [], topOutstanding: [] }; describe('Overview', () => { - it('renders balances and the recent team activity', async () => { - const routeParams = new BehaviorSubject(convertToParamMap({ id: '5' })); + let routeParams: BehaviorSubject; + let httpMock: HttpTestingController; + let fixture: ComponentFixture; + + beforeEach(async () => { + MockChart.instances.length = 0; + routeParams = new BehaviorSubject(convertToParamMap({ id: '5' })); await TestBed.configureTestingModule({ imports: [Overview], providers: [ provideHttpClient(), provideHttpClientTesting(), + provideRouter([]), { provide: TeamStore, useValue: { @@ -34,21 +65,38 @@ describe('Overview', () => { }, ], }).compileComponents(); - const fixture = TestBed.createComponent(Overview); + httpMock = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(Overview); + }); + + function flushTransactions(activities: unknown[], teamId = 5): void { + httpMock.expectOne(`${environment.apiUrl}teams/${teamId}/transactions`).flush(activities); + } + + function flushStats(stats: TeamOverviewStats, teamId = 5): void { + httpMock.expectOne(`${environment.apiUrl}teams/${teamId}/overview/stats`).flush(stats); + } + + function failStats(teamId = 5): void { + httpMock + .expectOne(`${environment.apiUrl}teams/${teamId}/overview/stats`) + .flush(null, { status: 500, statusText: 'Server Error' }); + } + + it('renders balances and the recent team activity', async () => { fixture.detectChanges(); - TestBed.inject(HttpTestingController) - .expectOne(`${environment.apiUrl}teams/5/transactions`) - .flush([ - { - id: 1, - date: '2026-07-31', - amount: 12, - type: 'fine', - note: 'Beitrag', - playerName: 'Alex', - isTeamWalletTransaction: false, - }, - ]); + flushTransactions([ + { + id: 1, + date: '2026-07-31', + amount: 12, + type: 'fine', + note: 'Beitrag', + playerName: 'Alex', + isTeamWalletTransaction: false, + }, + ]); + flushStats(sampleStats); await fixture.whenStable(); fixture.detectChanges(); @@ -58,9 +106,8 @@ describe('Overview', () => { expect(fixture.nativeElement.textContent).toContain('-12,00'); routeParams.next(convertToParamMap({ id: '6' })); - TestBed.inject(HttpTestingController) - .expectOne(`${environment.apiUrl}teams/6/transactions`) - .flush([ + flushTransactions( + [ { id: 2, date: '2026-08-01', @@ -70,10 +117,84 @@ describe('Overview', () => { playerName: 'Bea', isTeamWalletTransaction: false, }, - ]); + ], + 6, + ); + flushStats(sampleStats, 6); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('Neues Team'); expect(fixture.nativeElement.textContent).not.toContain('Beitrag'); }); + + it('shows a loading spinner in each chart card while stats are loading', () => { + fixture.detectChanges(); + + const spinners = fixture.nativeElement.querySelectorAll('.chart-block mat-spinner'); + expect(spinners.length).toBe(3); + + flushTransactions([]); + flushStats(sampleStats); + }); + + it('shows an empty state per chart when its dataset is an empty array', async () => { + fixture.detectChanges(); + flushTransactions([]); + flushStats(emptyStats); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Noch keine Kassenstand-Historie'); + expect(fixture.nativeElement.textContent).toContain('Noch keine Bewegungen'); + expect(fixture.nativeElement.textContent).toContain('Keine offenen Beiträge'); + expect(fixture.debugElement.queryAll(By.directive(ChartCanvas))).toHaveLength(0); + }); + + it('passes the loaded stats to each ChartCanvas once the request resolves', async () => { + fixture.detectChanges(); + flushTransactions([]); + flushStats(sampleStats); + await fixture.whenStable(); + fixture.detectChanges(); + + const charts = fixture.debugElement.queryAll(By.directive(ChartCanvas)); + expect(charts).toHaveLength(3); + + const [balanceChart, flowChart, outstandingChart] = charts.map( + (c) => c.componentInstance as ChartCanvas, + ); + expect(balanceChart.type).toBe('line'); + expect(balanceChart.data.labels).toHaveLength(2); + expect(flowChart.type).toBe('bar'); + expect(flowChart.data.datasets).toHaveLength(2); + expect(outstandingChart.type).toBe('bar'); + expect(outstandingChart.data.labels).toEqual(['Chris Beispiel']); + }); + + it('shows an empty state without throwing when the stats request errors', async () => { + fixture.detectChanges(); + flushTransactions([]); + + expect(() => failStats()).not.toThrow(); + await fixture.whenStable(); + expect(() => fixture.detectChanges()).not.toThrow(); + + expect(fixture.nativeElement.textContent).toContain('Noch keine Kassenstand-Historie'); + expect(fixture.nativeElement.textContent).toContain('Noch keine Bewegungen'); + expect(fixture.nativeElement.textContent).toContain('Keine offenen Beiträge'); + }); + + it('links the Top-10 card to the members route', async () => { + fixture.detectChanges(); + flushTransactions([]); + flushStats(sampleStats); + await fixture.whenStable(); + fixture.detectChanges(); + + const link: HTMLAnchorElement | null = fixture.nativeElement.querySelector( + '.chart-block--outstanding a[routerLink]', + ); + expect(link).toBeTruthy(); + expect(link?.getAttribute('routerLink')).toBe('../members'); + }); }); diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts index 760fe52..552cafc 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts @@ -1,23 +1,48 @@ import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common'; import localeDe from '@angular/common/locales/de'; -import { Component, LOCALE_ID, inject, signal } from '@angular/core'; +import { Component, LOCALE_ID, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, RouterLink } from '@angular/router'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import type { ChartData, ChartOptions } from 'chart.js'; +import { ChartCanvas } from '../../../shared/chart-canvas/chart-canvas'; import { TeamStore } from '../../../core/team/team-store'; import { TransactionsApi } from '../../../core/team/transactions-api'; +import { TeamStatsApi } from '../../../core/team/team-stats-api'; import { TeamActivity } from '../../../models/transaction.model'; +import { TeamOverviewStats } from '../../../models/team-stats.model'; import { signedTransactionAmount } from '../../../models/transaction-amount'; import { of } from 'rxjs'; import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; registerLocaleData(localeDe); +const BALANCE_COLOR = '#4f8f46'; +const INCOME_COLOR = '#4f8f46'; +const EXPENSE_COLOR = '#c1121f'; + +function formatMonthLabel(month: string): string { + const [year, monthNumber] = month.split('-').map(Number); + return new Intl.DateTimeFormat('de-DE', { month: 'short', year: '2-digit' }).format( + new Date(year, monthNumber - 1, 1), + ); +} + @Component({ selector: 'app-overview', - imports: [CurrencyPipe, DatePipe, MatCardModule, MatIconModule, MatProgressSpinnerModule], + imports: [ + CurrencyPipe, + DatePipe, + MatButtonModule, + MatCardModule, + MatIconModule, + MatProgressSpinnerModule, + RouterLink, + ChartCanvas, + ], providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }], templateUrl: './overview.html', styleUrl: './overview.scss', @@ -25,21 +50,101 @@ registerLocaleData(localeDe); export class Overview { private readonly route = inject(ActivatedRoute); private readonly transactionsApi = inject(TransactionsApi); + private readonly teamStatsApi = inject(TeamStatsApi); protected readonly team = inject(TeamStore).team; protected readonly activities = signal([]); protected readonly loadingActivities = signal(true); + protected readonly stats = signal(null); + protected readonly loadingStats = signal(true); + + protected readonly balanceHistory = computed(() => this.stats()?.balanceHistory ?? []); + protected readonly monthlyFlow = computed(() => this.stats()?.monthlyFlow ?? []); + protected readonly topOutstanding = computed(() => this.stats()?.topOutstanding ?? []); + + protected readonly balanceChartData = computed(() => { + 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, + }, + ], + }; + }); + + protected readonly flowChartData = computed(() => { + const points = this.monthlyFlow(); + return { + labels: points.map((point) => formatMonthLabel(point.month)), + datasets: [ + { + label: 'Einnahmen', + data: points.map((point) => point.income), + backgroundColor: INCOME_COLOR, + }, + { + label: 'Ausgaben', + data: points.map((point) => point.expense), + backgroundColor: EXPENSE_COLOR, + }, + ], + }; + }); + + protected readonly topOutstandingChartData = computed(() => { + const players = this.topOutstanding(); + return { + labels: players.map((player) => player.playerName), + datasets: [ + { + label: 'Offener Betrag', + data: players.map((player) => player.balance), + backgroundColor: EXPENSE_COLOR, + }, + ], + }; + }); + + protected readonly balanceChartOptions: ChartOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + }; + + protected readonly flowChartOptions: ChartOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: 'bottom' } }, + }; + + protected readonly topOutstandingChartOptions: ChartOptions = { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + }; constructor() { const parentRoute = this.route.parent; if (!parentRoute) { this.loadingActivities.set(false); + this.loadingStats.set(false); return; } - parentRoute.paramMap + const teamId$ = parentRoute.paramMap.pipe( + map((params) => Number(params.get('id'))), + distinctUntilChanged(), + ); + + teamId$ .pipe( - map((params) => Number(params.get('id'))), - distinctUntilChanged(), tap((id) => { this.activities.set([]); this.loadingActivities.set(Number.isInteger(id) && id > 0); @@ -55,6 +160,24 @@ export class Overview { this.activities.set(activities.slice(0, 10)); this.loadingActivities.set(false); }); + + teamId$ + .pipe( + tap((id) => { + this.stats.set(null); + this.loadingStats.set(Number.isInteger(id) && id > 0); + }), + switchMap((id) => + Number.isInteger(id) && id > 0 + ? this.teamStatsApi.loadStats(id).pipe(catchError(() => of(null))) + : of(null), + ), + takeUntilDestroyed(), + ) + .subscribe((stats) => { + this.stats.set(stats); + this.loadingStats.set(false); + }); } protected activityIcon(activity: TeamActivity): string { diff --git a/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts b/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts new file mode 100644 index 0000000..c6694af --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts @@ -0,0 +1,22 @@ +export interface BalanceHistoryPoint { + month: string; + balance: number; +} + +export interface MonthlyFlowPoint { + month: string; + income: number; + expense: number; +} + +export interface TopOutstandingPlayer { + playerId: number; + playerName: string; + balance: number; +} + +export interface TeamOverviewStats { + balanceHistory: BalanceHistoryPoint[]; + monthlyFlow: MonthlyFlowPoint[]; + topOutstanding: TopOutstandingPlayer[]; +} diff --git a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.html b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.html @@ -0,0 +1 @@ + diff --git a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.scss b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.scss new file mode 100644 index 0000000..78f1a56 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.scss @@ -0,0 +1,10 @@ +:host { + display: block; + position: relative; + width: 100%; + height: 100%; +} +canvas { + width: 100% !important; + height: 100% !important; +} diff --git a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.spec.ts b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.spec.ts new file mode 100644 index 0000000..e0d9d2e --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.spec.ts @@ -0,0 +1,85 @@ +import { TestBed } from '@angular/core/testing'; +import { ChartCanvas } from './chart-canvas'; + +// `vi.mock`'s factory is hoisted above regular imports, so the shared mock class is +// loaded via a dynamic import inside `vi.hoisted` rather than a plain top-level import. +const { MockChart } = await vi.hoisted(async () => import('./testing/mock-chart')); + +vi.mock('chart.js', () => ({ Chart: MockChart, registerables: [] })); + +describe('ChartCanvas', () => { + afterEach(() => { + MockChart.instances.length = 0; + }); + + it('creates a Chart.js instance from the type/data/options inputs', () => { + const fixture = TestBed.createComponent(ChartCanvas); + fixture.componentRef.setInput('type', 'line'); + fixture.componentRef.setInput('data', { labels: ['Jan'], datasets: [{ data: [1] }] }); + fixture.componentRef.setInput('options', { responsive: true }); + fixture.detectChanges(); + + expect(MockChart.instances).toHaveLength(1); + const instance = MockChart.instances[0]; + expect(instance.config.type).toBe('line'); + expect(instance.config.data).toEqual({ labels: ['Jan'], datasets: [{ data: [1] }] }); + expect(instance.config.options).toEqual({ responsive: true }); + }); + + it('updates the chart instance in place when the data input changes', () => { + const fixture = TestBed.createComponent(ChartCanvas); + fixture.componentRef.setInput('type', 'bar'); + fixture.componentRef.setInput('data', { labels: ['Jan'], datasets: [{ data: [1] }] }); + fixture.detectChanges(); + const instance = MockChart.instances[0]; + + fixture.componentRef.setInput('data', { labels: ['Feb'], datasets: [{ data: [2] }] }); + fixture.detectChanges(); + + expect(MockChart.instances).toHaveLength(1); + expect(instance.data).toEqual({ labels: ['Feb'], datasets: [{ data: [2] }] }); + expect(instance.update).toHaveBeenCalled(); + }); + + it('updates the chart instance when the options input changes', () => { + const fixture = TestBed.createComponent(ChartCanvas); + fixture.componentRef.setInput('type', 'bar'); + fixture.componentRef.setInput('data', { labels: ['Jan'], datasets: [{ data: [1] }] }); + fixture.componentRef.setInput('options', { responsive: true }); + fixture.detectChanges(); + const instance = MockChart.instances[0]; + + fixture.componentRef.setInput('options', { responsive: false }); + fixture.detectChanges(); + + expect(instance.options).toEqual({ responsive: false }); + expect(instance.update).toHaveBeenCalled(); + }); + + it('recreates the chart instance when the chart type changes', () => { + const fixture = TestBed.createComponent(ChartCanvas); + fixture.componentRef.setInput('type', 'line'); + fixture.componentRef.setInput('data', { labels: ['Jan'], datasets: [{ data: [1] }] }); + fixture.detectChanges(); + const firstInstance = MockChart.instances[0]; + + fixture.componentRef.setInput('type', 'bar'); + fixture.detectChanges(); + + expect(firstInstance.destroy).toHaveBeenCalled(); + expect(MockChart.instances).toHaveLength(2); + expect(MockChart.instances[1].config.type).toBe('bar'); + }); + + it('destroys the chart instance when the component is destroyed', () => { + const fixture = TestBed.createComponent(ChartCanvas); + fixture.componentRef.setInput('type', 'line'); + fixture.componentRef.setInput('data', { labels: [], datasets: [] }); + fixture.detectChanges(); + const instance = MockChart.instances[0]; + + fixture.destroy(); + + expect(instance.destroy).toHaveBeenCalled(); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.ts b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.ts new file mode 100644 index 0000000..24fa86f --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/chart-canvas.ts @@ -0,0 +1,81 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import { + Chart, + ChartConfiguration, + ChartData, + ChartOptions, + ChartType, + registerables, +} from 'chart.js'; + +Chart.register(...registerables); + +/** + * Thin wrapper around a Chart.js instance bound to a ``. Chart-specific + * configuration (labels, datasets, colors, ...) is built by the caller and passed + * in via inputs — this component only manages the Chart.js instance lifecycle. + */ +@Component({ + selector: 'app-chart-canvas', + templateUrl: './chart-canvas.html', + styleUrl: './chart-canvas.scss', +}) +export class ChartCanvas implements AfterViewInit, OnChanges, OnDestroy { + @Input({ required: true }) type!: ChartType; + @Input({ required: true }) data!: ChartData; + @Input() options?: ChartOptions; + + @ViewChild('canvas', { static: true }) + private readonly canvasRef!: ElementRef; + + private chart?: Chart; + + ngAfterViewInit(): void { + this.createChart(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!this.chart) { + // Initial creation is handled by ngAfterViewInit once the canvas exists. + return; + } + + if (changes['type'] && !changes['type'].firstChange) { + this.chart.destroy(); + this.createChart(); + return; + } + + if (changes['data']) { + this.chart.data = this.data; + } + if (changes['options']) { + this.chart.options = this.options ?? {}; + } + if (changes['data'] || changes['options']) { + this.chart.update(); + } + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } + + private createChart(): void { + const config = { + type: this.type, + data: this.data, + options: this.options, + } as ChartConfiguration; + this.chart = new Chart(this.canvasRef.nativeElement, config); + } +} diff --git a/myteamwallet_frontend_modern/src/app/shared/chart-canvas/testing/mock-chart.ts b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/testing/mock-chart.ts new file mode 100644 index 0000000..2210a22 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/chart-canvas/testing/mock-chart.ts @@ -0,0 +1,32 @@ +import { vi } from 'vitest'; + +/** + * Test double for Chart.js's `Chart` class, shared by `chart-canvas.spec.ts` and + * `overview.spec.ts`. jsdom has no canvas 2D context, so real Chart.js cannot render + * in this project's test environment — specs mock the whole `chart.js` module via + * `vi.mock('chart.js', () => ({ Chart: MockChart, registerables: [] }))` and assert + * on the Chart.js lifecycle contract (constructor args, update(), destroy()) instead. + * + * Not a `*.spec.ts` file on purpose: it exports a class rather than defining tests, + * so it must not be picked up by the test runner's `**\/*.spec.ts` include glob. + */ +export class MockChart { + static register = vi.fn(); + static instances: MockChart[] = []; + + data: unknown; + options: unknown; + config: { type: unknown; data: unknown; options: unknown }; + destroy = vi.fn(); + update = vi.fn(); + + constructor( + public ctx: unknown, + config: { type: unknown; data: unknown; options: unknown }, + ) { + this.config = config; + this.data = config.data; + this.options = config.options; + MockChart.instances.push(this); + } +}