Merge branch 'worktree-kasse-kpi-charts'
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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<TeamWalletTransaction>,
|
||||
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: {
|
||||
|
||||
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/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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
>
|
||||
</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>
|
||||
<p class="eyebrow">Zuletzt passiert</p>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ParamMap>;
|
||||
let httpMock: HttpTestingController;
|
||||
let fixture: ComponentFixture<Overview>;
|
||||
|
||||
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,11 +65,27 @@ 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([
|
||||
flushTransactions([
|
||||
{
|
||||
id: 1,
|
||||
date: '2026-07-31',
|
||||
@@ -49,6 +96,7 @@ describe('Overview', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TeamActivity[]>([]);
|
||||
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() {
|
||||
const parentRoute = this.route.parent;
|
||||
if (!parentRoute) {
|
||||
this.loadingActivities.set(false);
|
||||
this.loadingStats.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
parentRoute.paramMap
|
||||
.pipe(
|
||||
const teamId$ = parentRoute.paramMap.pipe(
|
||||
map((params) => Number(params.get('id'))),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
teamId$
|
||||
.pipe(
|
||||
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 {
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user