poprawki
This commit is contained in:
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user