feat(overview): add KPI charts to team overview page
Adds three chart.js-backed KPI cards (Kassenstand-Verlauf, Einnahmen & Ausgaben, Top-10 offene Beitraege) to the existing Uebersicht page, consuming the new GET teams/:id/overview/stats endpoint via a new TeamStatsApi service. Introduces a small reusable ChartCanvas shared component that wraps the Chart.js instance lifecycle via @Input()/ ngOnChanges, following this codebase's existing input-decorator convention rather than effect(). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
19
myteamwallet_frontend_modern/package-lock.json
generated
19
myteamwallet_frontend_modern/package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"@angular/service-worker": "^21.2.0",
|
"@angular/service-worker": "^21.2.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@@ -2115,6 +2116,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
||||||
@@ -4639,6 +4646,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"@angular/service-worker": "^21.2.0",
|
"@angular/service-worker": "^21.2.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TeamOverviewStats> {
|
||||||
|
return this.http.get<TeamOverviewStats>(`${environment.apiUrl}teams/${teamId}/overview/stats`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,89 @@
|
|||||||
></mat-card
|
></mat-card
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="chart-block chart-block--balance">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Kennzahlen</p>
|
||||||
|
<h2>Kassenstand-Verlauf</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<mat-card class="chart-card">
|
||||||
|
<mat-card-content>
|
||||||
|
@if (loadingStats()) {
|
||||||
|
<div class="state"><mat-spinner diameter="32" /></div>
|
||||||
|
} @else if (balanceHistory().length === 0) {
|
||||||
|
<div class="state">
|
||||||
|
<mat-icon>show_chart</mat-icon><strong>Noch keine Kassenstand-Historie</strong
|
||||||
|
><span>Sobald Buchungen vorliegen, siehst du den Verlauf hier.</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="chart-canvas-box">
|
||||||
|
<app-chart-canvas type="line" [data]="balanceChartData()" [options]="balanceChartOptions" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="chart-block chart-block--flow">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Kennzahlen</p>
|
||||||
|
<h2>Einnahmen & Ausgaben</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<mat-card class="chart-card">
|
||||||
|
<mat-card-content>
|
||||||
|
@if (loadingStats()) {
|
||||||
|
<div class="state"><mat-spinner diameter="32" /></div>
|
||||||
|
} @else if (monthlyFlow().length === 0) {
|
||||||
|
<div class="state">
|
||||||
|
<mat-icon>bar_chart</mat-icon><strong>Noch keine Bewegungen</strong
|
||||||
|
><span>Einnahmen und Ausgaben erscheinen hier pro Monat.</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="chart-canvas-box">
|
||||||
|
<app-chart-canvas type="bar" [data]="flowChartData()" [options]="flowChartOptions" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="chart-block chart-block--outstanding">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Kennzahlen</p>
|
||||||
|
<h2>Offene Beiträge (Top 10)</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<mat-card class="chart-card">
|
||||||
|
<mat-card-content>
|
||||||
|
@if (loadingStats()) {
|
||||||
|
<div class="state"><mat-spinner diameter="32" /></div>
|
||||||
|
} @else if (topOutstanding().length === 0) {
|
||||||
|
<div class="state">
|
||||||
|
<mat-icon>emoji_events</mat-icon><strong>Keine offenen Beiträge</strong
|
||||||
|
><span>Alle aktiven Mitglieder sind ausgeglichen.</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="chart-canvas-box">
|
||||||
|
<app-chart-canvas
|
||||||
|
type="bar"
|
||||||
|
[data]="topOutstandingChartData()"
|
||||||
|
[options]="topOutstandingChartOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<a mat-button class="chart-card__link" routerLink="../members"
|
||||||
|
><mat-icon>group</mat-icon>Alle Spieler ansehen</a
|
||||||
|
>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Zuletzt passiert</p>
|
<p class="eyebrow">Zuletzt passiert</p>
|
||||||
|
|||||||
@@ -106,6 +106,21 @@ h2 {
|
|||||||
color: var(--mat-sys-on-surface-variant);
|
color: var(--mat-sys-on-surface-variant);
|
||||||
text-align: center;
|
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) {
|
@media (max-width: 520px) {
|
||||||
.balance-grid {
|
.balance-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -1,21 +1,69 @@
|
|||||||
import { signal } from '@angular/core';
|
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 { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
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 { BehaviorSubject } from 'rxjs';
|
||||||
import { Overview } from './overview';
|
import { Overview } from './overview';
|
||||||
|
import { ChartCanvas } from '../../../shared/chart-canvas/chart-canvas';
|
||||||
import { TeamStore } from '../../../core/team/team-store';
|
import { TeamStore } from '../../../core/team/team-store';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
|
import { TeamOverviewStats } from '../../../models/team-stats.model';
|
||||||
|
|
||||||
|
const { MockChart } = vi.hoisted(() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { MockChart };
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('Overview', () => {
|
||||||
it('renders balances and the recent team activity', async () => {
|
let routeParams: BehaviorSubject<ParamMap>;
|
||||||
const routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
|
let httpMock: HttpTestingController;
|
||||||
|
let fixture: ComponentFixture<Overview>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
MockChart.instances.length = 0;
|
||||||
|
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [Overview],
|
imports: [Overview],
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
|
provideRouter([]),
|
||||||
{
|
{
|
||||||
provide: TeamStore,
|
provide: TeamStore,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -34,21 +82,38 @@ describe('Overview', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).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();
|
fixture.detectChanges();
|
||||||
TestBed.inject(HttpTestingController)
|
flushTransactions([
|
||||||
.expectOne(`${environment.apiUrl}teams/5/transactions`)
|
{
|
||||||
.flush([
|
id: 1,
|
||||||
{
|
date: '2026-07-31',
|
||||||
id: 1,
|
amount: 12,
|
||||||
date: '2026-07-31',
|
type: 'fine',
|
||||||
amount: 12,
|
note: 'Beitrag',
|
||||||
type: 'fine',
|
playerName: 'Alex',
|
||||||
note: 'Beitrag',
|
isTeamWalletTransaction: false,
|
||||||
playerName: 'Alex',
|
},
|
||||||
isTeamWalletTransaction: false,
|
]);
|
||||||
},
|
flushStats(sampleStats);
|
||||||
]);
|
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
@@ -58,9 +123,8 @@ describe('Overview', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('-12,00');
|
expect(fixture.nativeElement.textContent).toContain('-12,00');
|
||||||
|
|
||||||
routeParams.next(convertToParamMap({ id: '6' }));
|
routeParams.next(convertToParamMap({ id: '6' }));
|
||||||
TestBed.inject(HttpTestingController)
|
flushTransactions(
|
||||||
.expectOne(`${environment.apiUrl}teams/6/transactions`)
|
[
|
||||||
.flush([
|
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
date: '2026-08-01',
|
date: '2026-08-01',
|
||||||
@@ -70,10 +134,84 @@ describe('Overview', () => {
|
|||||||
playerName: 'Bea',
|
playerName: 'Bea',
|
||||||
isTeamWalletTransaction: false,
|
isTeamWalletTransaction: false,
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
flushStats(sampleStats, 6);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Neues Team');
|
expect(fixture.nativeElement.textContent).toContain('Neues Team');
|
||||||
expect(fixture.nativeElement.textContent).not.toContain('Beitrag');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,48 @@
|
|||||||
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
|
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, inject, signal } from '@angular/core';
|
import { Component, LOCALE_ID, computed, inject, signal } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
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 { MatCardModule } from '@angular/material/card';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
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 { TeamStore } from '../../../core/team/team-store';
|
||||||
import { TransactionsApi } from '../../../core/team/transactions-api';
|
import { TransactionsApi } from '../../../core/team/transactions-api';
|
||||||
|
import { TeamStatsApi } from '../../../core/team/team-stats-api';
|
||||||
import { TeamActivity } from '../../../models/transaction.model';
|
import { TeamActivity } from '../../../models/transaction.model';
|
||||||
|
import { TeamOverviewStats } from '../../../models/team-stats.model';
|
||||||
import { signedTransactionAmount } from '../../../models/transaction-amount';
|
import { signedTransactionAmount } from '../../../models/transaction-amount';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
|
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
|
||||||
|
|
||||||
registerLocaleData(localeDe);
|
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({
|
@Component({
|
||||||
selector: 'app-overview',
|
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' }],
|
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||||
templateUrl: './overview.html',
|
templateUrl: './overview.html',
|
||||||
styleUrl: './overview.scss',
|
styleUrl: './overview.scss',
|
||||||
@@ -25,21 +50,101 @@ registerLocaleData(localeDe);
|
|||||||
export class Overview {
|
export class Overview {
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly transactionsApi = inject(TransactionsApi);
|
private readonly transactionsApi = inject(TransactionsApi);
|
||||||
|
private readonly teamStatsApi = inject(TeamStatsApi);
|
||||||
protected readonly team = inject(TeamStore).team;
|
protected readonly team = inject(TeamStore).team;
|
||||||
protected readonly activities = signal<TeamActivity[]>([]);
|
protected readonly activities = signal<TeamActivity[]>([]);
|
||||||
protected readonly loadingActivities = signal(true);
|
protected readonly loadingActivities = signal(true);
|
||||||
|
protected readonly stats = signal<TeamOverviewStats | null>(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<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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly flowChartData = computed<ChartData>(() => {
|
||||||
|
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<ChartData>(() => {
|
||||||
|
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() {
|
constructor() {
|
||||||
const parentRoute = this.route.parent;
|
const parentRoute = this.route.parent;
|
||||||
if (!parentRoute) {
|
if (!parentRoute) {
|
||||||
this.loadingActivities.set(false);
|
this.loadingActivities.set(false);
|
||||||
|
this.loadingStats.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
parentRoute.paramMap
|
const teamId$ = parentRoute.paramMap.pipe(
|
||||||
|
map((params) => Number(params.get('id'))),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
);
|
||||||
|
|
||||||
|
teamId$
|
||||||
.pipe(
|
.pipe(
|
||||||
map((params) => Number(params.get('id'))),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
tap((id) => {
|
tap((id) => {
|
||||||
this.activities.set([]);
|
this.activities.set([]);
|
||||||
this.loadingActivities.set(Number.isInteger(id) && id > 0);
|
this.loadingActivities.set(Number.isInteger(id) && id > 0);
|
||||||
@@ -55,6 +160,24 @@ export class Overview {
|
|||||||
this.activities.set(activities.slice(0, 10));
|
this.activities.set(activities.slice(0, 10));
|
||||||
this.loadingActivities.set(false);
|
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 {
|
protected activityIcon(activity: TeamActivity): string {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<canvas #canvas></canvas>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ChartCanvas } from './chart-canvas';
|
||||||
|
|
||||||
|
const { MockChart } = vi.hoisted(() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { MockChart };
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 `<canvas>`. 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<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user