Files
teamwallet/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts
Bastian Wagner 435b8d5c53 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>
2026-08-01 20:21:50 +02:00

191 lines
6.1 KiB
TypeScript

import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { Component, LOCALE_ID, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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,
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
RouterLink,
ChartCanvas,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './overview.html',
styleUrl: './overview.scss',
})
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;
}
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);
}),
switchMap((id) =>
Number.isInteger(id) && id > 0
? this.transactionsApi.loadTeamTransactions(id).pipe(catchError(() => of([])))
: of([]),
),
takeUntilDestroyed(),
)
.subscribe((activities) => {
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 {
return activity.isTeamWalletTransaction ? 'account_balance' : 'person';
}
protected displayAmount(activity: TeamActivity): number {
return signedTransactionAmount(activity.amount, activity.type);
}
}