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>
191 lines
6.1 KiB
TypeScript
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);
|
|
}
|
|
}
|