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:
@@ -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
|
||||
const teamId$ = parentRoute.paramMap.pipe(
|
||||
map((params) => Number(params.get('id'))),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
teamId$
|
||||
.pipe(
|
||||
map((params) => Number(params.get('id'))),
|
||||
distinctUntilChanged(),
|
||||
tap((id) => {
|
||||
this.activities.set([]);
|
||||
this.loadingActivities.set(Number.isInteger(id) && id > 0);
|
||||
@@ -55,6 +160,24 @@ export class Overview {
|
||||
this.activities.set(activities.slice(0, 10));
|
||||
this.loadingActivities.set(false);
|
||||
});
|
||||
|
||||
teamId$
|
||||
.pipe(
|
||||
tap((id) => {
|
||||
this.stats.set(null);
|
||||
this.loadingStats.set(Number.isInteger(id) && id > 0);
|
||||
}),
|
||||
switchMap((id) =>
|
||||
Number.isInteger(id) && id > 0
|
||||
? this.teamStatsApi.loadStats(id).pipe(catchError(() => of(null)))
|
||||
: of(null),
|
||||
),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe((stats) => {
|
||||
this.stats.set(stats);
|
||||
this.loadingStats.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
protected activityIcon(activity: TeamActivity): string {
|
||||
|
||||
Reference in New Issue
Block a user