Merge branch 'worktree-kasse-kpi-charts'
This commit is contained in:
@@ -86,6 +86,16 @@ export class TeamsController {
|
|||||||
return this.service.getOverview(id);
|
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({
|
@ApiOperation({
|
||||||
summary: 'Transactionen für ein Team',
|
summary: 'Transactionen für ein Team',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Repository } from 'typeorm';
|
|||||||
import { CreateTeamDTO } from './dto/create-team.dto';
|
import { CreateTeamDTO } from './dto/create-team.dto';
|
||||||
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
|
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
|
||||||
import { Team } from './entities/team.entity';
|
import { Team } from './entities/team.entity';
|
||||||
|
import { TeamAccessService } from './team-access.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TeamsService {
|
export class TeamsService {
|
||||||
@@ -28,6 +29,7 @@ export class TeamsService {
|
|||||||
@InjectRepository(TeamWalletTransaction)
|
@InjectRepository(TeamWalletTransaction)
|
||||||
private teamWalletTransactionRepository: Repository<TeamWalletTransaction>,
|
private teamWalletTransactionRepository: Repository<TeamWalletTransaction>,
|
||||||
private logger: LoggingService,
|
private logger: LoggingService,
|
||||||
|
private access: TeamAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getOverview(teamId: string) {
|
async getOverview(teamId: string) {
|
||||||
@@ -218,6 +220,129 @@ export class TeamsService {
|
|||||||
return result;
|
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) {
|
async updatePlayer(playerDTO: UpdatePlayerProfileDto) {
|
||||||
const player = await this.playerRepository.findOneOrFail({
|
const player = await this.playerRepository.findOneOrFail({
|
||||||
where: {
|
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/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,52 @@
|
|||||||
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';
|
||||||
|
|
||||||
|
// `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', () => {
|
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 +65,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 +106,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 +117,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,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