This commit is contained in:
Mateusz Gruszczyński
2026-04-06 10:46:48 +02:00
parent 1ba1a26291
commit 0c7414101a
23 changed files with 962 additions and 355 deletions

View File

@@ -1,5 +1,5 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { AfterViewChecked, Component, OnDestroy, OnInit, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Chart, DoughnutController, ArcElement, Tooltip, Legend } from 'chart.js';
import { AuthService } from '../../core/services/auth.service';
@@ -10,6 +10,24 @@ import type { Expense, StatsResponse } from '../../shared/models';
Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4263eb', '#0ca678', '#e8590c'];
const DASHBOARD_CACHE_KEY = 'expense-control-dashboard-cache';
const formatLocalDate = (date: Date) => {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
};
const getMonthRange = () => {
const now = new Date();
return {
start: formatLocalDate(new Date(now.getFullYear(), now.getMonth(), 1)),
end: formatLocalDate(now)
};
};
@Component({
selector: 'app-dashboard',
standalone: true,
@@ -19,7 +37,6 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ auth.currentUser()?.fullName }}</h2>
<div class="text-secondary">{{ ui.t('dashboard.subtitle') }}</div>
</div>
<div class="col-12 col-xl d-flex justify-content-xl-end">
<div class="ec-page-header-actions">
@@ -38,8 +55,8 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
<div class="card-body">
<div class="row align-items-center g-3">
<div class="col-lg-7">
<div class="display-6 fw-bold mb-2">{{ stats?.total || 0 | currency:'PLN':'symbol':'1.2-2' }}</div>
<div class="text-secondary">{{ ui.t('dashboard.subtitle') }}</div>
<div class="text-secondary text-uppercase small fw-semibold mb-2">{{ ui.t('dashboard.total') }}</div>
<div class="display-6 fw-bold mb-0">{{ (stats?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div>
</div>
<div class="col-lg-5">
<div class="row g-3">
@@ -52,7 +69,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
<div class="col-6">
<div class="border rounded-3 p-3 h-100 ec-metric-card">
<div class="text-secondary small">{{ ui.t('dashboard.avg') }}</div>
<div class="h2 mb-0">{{ stats?.average || 0 | currency:'PLN':'symbol':'1.2-2' }}</div>
<div class="h2 mb-0">{{ (stats?.average || 0) | currency:'PLN':'symbol':'1.2-2' }}</div>
</div>
</div>
<div class="col-12">
@@ -73,7 +90,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
<div class="card-header">
<div>
<h3 class="card-title">{{ ui.t('dashboard.share') }}</h3>
<div class="ec-card-header-muted">Miesięczny przekrój kosztów według kategorii.</div>
<div class="ec-card-header-muted">{{ ui.t('dashboard.shareHint') }}</div>
</div>
</div>
<div class="card-body">
@@ -82,7 +99,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
<canvas id="dashboardCategoryChart"></canvas>
</div>
} @else {
<div class="alert alert-info mb-0">Brak danych do pokazania wykresu kategorii.</div>
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noChartData') }}</div>
}
</div>
</div>
@@ -93,13 +110,13 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
<div class="card-header">
<div>
<h3 class="card-title">{{ ui.t('dashboard.areas') }}</h3>
<div class="ec-card-header-muted">Najważniejsze obszary kosztowe w aktualnym okresie.</div>
<div class="ec-card-header-muted">{{ ui.t('dashboard.areasHint') }}</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table table-striped mb-0">
<thead>
<tr><th>Kategoria</th><th class="text-end">Kwota</th><th class="text-end">Liczba</th></tr>
<tr><th>{{ ui.t('table.category') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr>
</thead>
<tbody>
@for (row of stats?.byCategory || []; track row.categoryId) {
@@ -122,14 +139,14 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
<div class="card-header">
<div>
<h3 class="card-title">{{ ui.t('dashboard.recent') }}</h3>
<div class="ec-card-header-muted">Ostatnio dodane pozycje wraz z kontrahentami.</div>
<div class="ec-card-header-muted">{{ ui.t('dashboard.recentHint') }}</div>
</div>
</div>
@if (recentExpenses.length) {
<div class="table-responsive">
<table class="table table-vcenter card-table table-striped mb-0">
<thead>
<tr><th>Tytuł</th><th>Kontrahent</th><th>Data</th><th class="text-end">Kwota</th></tr>
<tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.merchant') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr>
</thead>
<tbody>
@for (item of recentExpenses; track item.id) {
@@ -153,7 +170,7 @@ Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
</div>
`
})
export class DashboardComponent implements OnInit {
export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
readonly auth = inject(AuthService);
readonly ui = inject(UiService);
private readonly expensesService = inject(ExpensesService);
@@ -162,48 +179,102 @@ export class DashboardComponent implements OnInit {
recentExpenses: Expense[] = [];
stats: StatsResponse | null = null;
private categoryChart?: Chart;
private chartPending = false;
ngOnInit() {
const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10);
const end = now.toISOString().slice(0, 10);
this.restoreCache();
this.loadDashboard();
}
this.expensesService.list({ startDate: start, endDate: end }).subscribe({
next: (response) => (this.recentExpenses = response.items.slice(0, 8))
});
ngAfterViewChecked() {
if (this.chartPending) {
this.chartPending = false;
this.renderChart();
}
}
this.statsService.overview({ startDate: start, endDate: end, bucket: 'month' }).subscribe({
next: (response) => {
this.stats = response;
setTimeout(() => this.renderChart(), 0);
}
});
ngOnDestroy() {
this.categoryChart?.destroy();
}
hasCategoryData() {
return Boolean(this.stats?.byCategory?.length);
}
private loadDashboard() {
const range = getMonthRange();
this.expensesService.list().subscribe({
next: (response) => {
this.recentExpenses = response.items.slice(0, 8);
this.persistCache();
}
});
this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({
next: (response) => {
this.stats = response;
this.chartPending = true;
this.persistCache();
}
});
}
private renderChart() {
const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null;
if (!canvas || !this.stats?.byCategory.length) {
if (!canvas || !this.stats?.byCategory?.length) {
this.categoryChart?.destroy();
return;
}
const colors = this.stats.byCategory.map((_, index) => chartPalette[index % chartPalette.length]);
this.categoryChart?.destroy();
this.categoryChart = new Chart(canvas, {
type: 'doughnut',
data: {
labels: this.stats.byCategory.map((item) => item.categoryName),
datasets: [{ data: this.stats.byCategory.map((item) => item.total) }]
datasets: [
{
data: this.stats.byCategory.map((item) => item.total),
backgroundColor: colors,
borderColor: '#ffffff',
hoverOffset: 10
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '66%',
plugins: { legend: { position: 'bottom' } }
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
boxWidth: 10,
color: '#9ca3af'
}
}
}
}
});
}
private restoreCache() {
try {
const raw = localStorage.getItem(DASHBOARD_CACHE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as { recentExpenses?: Expense[]; stats?: StatsResponse | null };
this.recentExpenses = parsed.recentExpenses ?? [];
this.stats = parsed.stats ?? null;
this.chartPending = Boolean(this.stats?.byCategory?.length);
} catch {}
}
private persistCache() {
try {
localStorage.setItem(DASHBOARD_CACHE_KEY, JSON.stringify({ recentExpenses: this.recentExpenses, stats: this.stats }));
} catch {}
}
}