first commit
This commit is contained in:
209
web/src/app/features/dashboard/dashboard.component.ts
Normal file
209
web/src/app/features/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { Component, 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';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { ExpensesService } from '../../core/services/expenses.service';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import type { Expense, StatsResponse } from '../../shared/models';
|
||||
|
||||
Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, CurrencyPipe, DatePipe, RouterLink],
|
||||
template: `
|
||||
<div class="page-header d-print-none mb-3 ec-page-header">
|
||||
<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">
|
||||
<a class="btn btn-success d-inline-flex align-items-center gap-2" routerLink="/expenses">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14"/><path d="M5 12l14 0"/></svg>
|
||||
<span>{{ ui.t('action.addExpense') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards g-3">
|
||||
<div class="col-12">
|
||||
<div class="card pv-card pv-hero-card overflow-hidden">
|
||||
<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>
|
||||
<div class="col-lg-5">
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<div class="border rounded-3 p-3 h-100 ec-metric-card">
|
||||
<div class="text-secondary small">{{ ui.t('dashboard.count') }}</div>
|
||||
<div class="h2 mb-0">{{ stats?.count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="border rounded-3 p-3 h-100 ec-metric-card">
|
||||
<div class="text-secondary small">{{ ui.t('dashboard.top') }}</div>
|
||||
<div class="h3 mb-0">{{ stats?.topCategory?.categoryName || ui.t('common.none') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5 d-flex align-items-stretch">
|
||||
<div class="card pv-card w-100 overflow-hidden">
|
||||
<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>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (hasCategoryData()) {
|
||||
<div class="ec-chart-wrap ec-chart-wrap-sm">
|
||||
<canvas id="dashboardCategoryChart"></canvas>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-info mb-0">Brak danych do pokazania wykresu kategorii.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7 d-flex align-items-stretch">
|
||||
<div class="card pv-card w-100 overflow-hidden">
|
||||
<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>
|
||||
</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of stats?.byCategory || []; track row.categoryId) {
|
||||
<tr>
|
||||
<td>{{ row.categoryName }}</td>
|
||||
<td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||
<td class="text-end">{{ row.count }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card pv-card overflow-hidden">
|
||||
<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>
|
||||
</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of recentExpenses; track item.id) {
|
||||
<tr>
|
||||
<td>{{ item.title }}</td>
|
||||
<td>{{ item.merchant || ui.t('common.none') }}</td>
|
||||
<td>{{ item.expenseDate | date:'shortDate' }}</td>
|
||||
<td class="text-end">{{ item.amount | currency:item.currency:'symbol':'1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning mb-0">{{ ui.t('common.noExpenses') }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
readonly auth = inject(AuthService);
|
||||
readonly ui = inject(UiService);
|
||||
private readonly expensesService = inject(ExpensesService);
|
||||
private readonly statsService = inject(StatsService);
|
||||
|
||||
recentExpenses: Expense[] = [];
|
||||
stats: StatsResponse | null = null;
|
||||
private categoryChart?: Chart;
|
||||
|
||||
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.expensesService.list({ startDate: start, endDate: end }).subscribe({
|
||||
next: (response) => (this.recentExpenses = response.items.slice(0, 8))
|
||||
});
|
||||
|
||||
this.statsService.overview({ startDate: start, endDate: end, bucket: 'month' }).subscribe({
|
||||
next: (response) => {
|
||||
this.stats = response;
|
||||
setTimeout(() => this.renderChart(), 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hasCategoryData() {
|
||||
return Boolean(this.stats?.byCategory?.length);
|
||||
}
|
||||
|
||||
private renderChart() {
|
||||
const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null;
|
||||
if (!canvas || !this.stats?.byCategory.length) {
|
||||
this.categoryChart?.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
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) }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '66%',
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user