zmiany
This commit is contained in:
@@ -1,17 +1,16 @@
|
||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
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';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { Chart, ArcElement, DoughnutController, Legend, Tooltip } from 'chart.js';
|
||||
import { ExpensesService } from '../../core/services/expenses.service';
|
||||
import { ShoppingListIntegrationService } from '../../core/services/shopping-list-integration.service';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import type { Expense, StatsResponse } from '../../shared/models';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { CashflowResponse, Expense, ShoppingListSummary, StatsResponse } from '../../shared/models';
|
||||
|
||||
Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
||||
|
||||
const DASHBOARD_CACHE_KEY = 'expense-control-dashboard-v6';
|
||||
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();
|
||||
@@ -21,83 +20,42 @@ const formatLocalDate = (date: Date) => {
|
||||
};
|
||||
|
||||
const getMonthRange = () => {
|
||||
const now = new Date();
|
||||
const today = new Date();
|
||||
return {
|
||||
start: formatLocalDate(new Date(now.getFullYear(), now.getMonth(), 1)),
|
||||
end: formatLocalDate(now)
|
||||
start: formatLocalDate(new Date(today.getFullYear(), today.getMonth(), 1)),
|
||||
end: formatLocalDate(today)
|
||||
};
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, CurrencyPipe, DatePipe, RouterLink],
|
||||
imports: [CommonModule, CurrencyPipe, DatePipe],
|
||||
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>
|
||||
<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>
|
||||
<h2 class="page-title mb-1">{{ ui.t('nav.dashboard') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('dashboard.cashflowHint') }}</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="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">
|
||||
<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="row row-cards">
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.total') }}</div><div class="display-6">{{ (stats?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.count') }}</div><div class="display-6">{{ stats?.count || 0 }}</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.budgetUsage') }}</div><div class="display-6">{{ cashflow?.budgetUsagePercent || 0 }}%</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (cashflow?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</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">{{ ui.t('dashboard.shareHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalSpend') }}</div><div class="display-6">{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalRecords') }}</div><div class="display-6">{{ externalCount() }}</div></div></div></div>
|
||||
|
||||
<div class="col-lg-7 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('dashboard.share') }}</h3></div>
|
||||
<div class="card-body">
|
||||
@if (hasCategoryData()) {
|
||||
<div class="ec-chart-wrap ec-chart-wrap-sm">
|
||||
<canvas id="dashboardCategoryChart"></canvas>
|
||||
</div>
|
||||
@if (stats?.byCategory?.length) {
|
||||
<div class="ec-chart-wrap"><canvas id="dashboardCategoryChart"></canvas></div>
|
||||
} @else {
|
||||
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noChartData') }}</div>
|
||||
}
|
||||
@@ -105,26 +63,63 @@ const getMonthRange = () => {
|
||||
</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 class="col-lg-5 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('nav.cashflow') }}</h3></div>
|
||||
<div class="card-body d-grid gap-3">
|
||||
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.actual') }}</span><strong>{{ (cashflow?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}</strong></div>
|
||||
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.budget') }}</span><strong>{{ (cashflow?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}</strong></div>
|
||||
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.pending') }}</span><strong>{{ cashflow?.pendingApproval || 0 }}</strong></div>
|
||||
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.duplicates') }}</span><strong>{{ cashflow?.duplicateCount || 0 }}</strong></div>
|
||||
<div>
|
||||
<h3 class="card-title">{{ ui.t('dashboard.areas') }}</h3>
|
||||
<div class="ec-card-header-muted">{{ ui.t('dashboard.areasHint') }}</div>
|
||||
<div class="form-label mb-2">{{ ui.t('budget.alerts') }}</div>
|
||||
<div class="d-grid gap-2">
|
||||
@for (alert of cashflow?.alerts || []; track alert.id) {
|
||||
<div class="alert alert-warning mb-0 py-2 px-3">{{ alert.name }} · {{ alert.usagePercent }}%</div>
|
||||
} @empty {
|
||||
<div class="text-secondary">{{ ui.t('common.noData') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('dashboard.recent') }}</h3></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table table-striped mb-0">
|
||||
<thead>
|
||||
<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>
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.category') }}</th><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
|
||||
<tbody>
|
||||
@for (row of stats?.byCategory || []; track row.categoryId) {
|
||||
@for (item of recentExpenses; track item.id) {
|
||||
<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>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ item.title }}</div>
|
||||
<div class="text-secondary small">{{ item.merchant || ui.t('expenses.noMerchant') }}</div>
|
||||
</td>
|
||||
<td>{{ item.category.name }}</td>
|
||||
<td><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></td>
|
||||
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noExpenses') }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.upcomingRecurring') }}</h3></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
|
||||
<tbody>
|
||||
@for (item of cashflow?.upcomingRecurring || []; track item.id) {
|
||||
<tr><td>{{ item.title }}</td><td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td><td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td></tr>
|
||||
} @empty {
|
||||
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
||||
}
|
||||
@@ -133,51 +128,19 @@ const getMonthRange = () => {
|
||||
</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">{{ 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>{{ 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) {
|
||||
<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, AfterViewChecked, OnDestroy {
|
||||
readonly auth = inject(AuthService);
|
||||
readonly ui = inject(UiService);
|
||||
private readonly expensesService = inject(ExpensesService);
|
||||
private readonly statsService = inject(StatsService);
|
||||
private readonly shoppingIntegration = inject(ShoppingListIntegrationService);
|
||||
|
||||
recentExpenses: Expense[] = [];
|
||||
stats: StatsResponse | null = null;
|
||||
cashflow: CashflowResponse | null = null;
|
||||
externalSummary: ShoppingListSummary | null = null;
|
||||
private categoryChart?: Chart;
|
||||
private chartPending = false;
|
||||
|
||||
@@ -197,27 +160,24 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
this.categoryChart?.destroy();
|
||||
}
|
||||
|
||||
hasCategoryData() {
|
||||
return Boolean(this.stats?.byCategory?.length);
|
||||
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(); } });
|
||||
this.statsService.cashflow().subscribe({ next: (response) => { this.cashflow = response; this.persistCache(); } });
|
||||
this.shoppingIntegration.summary({ start_date: range.start, end_date: range.end }).subscribe({ next: (response) => { this.externalSummary = response; this.persistCache(); }, error: () => { this.externalSummary = this.externalSummary ?? null; } });
|
||||
}
|
||||
|
||||
private loadDashboard() {
|
||||
const range = getMonthRange();
|
||||
externalAmount() { return Number(this.externalSummary?.total ?? this.externalSummary?.amount ?? this.externalSummary?.meta?.total_amount ?? 0); }
|
||||
externalCount() { return Number(this.externalSummary?.count ?? this.externalSummary?.records ?? this.externalSummary?.meta?.total_count ?? 0); }
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
statusBadgeClass(status: string) {
|
||||
return {
|
||||
DRAFT: 'text-bg-secondary',
|
||||
PENDING: 'text-bg-warning',
|
||||
APPROVED: 'text-bg-success',
|
||||
REJECTED: 'text-bg-danger'
|
||||
}[status] || 'text-bg-secondary';
|
||||
}
|
||||
|
||||
private renderChart() {
|
||||
@@ -228,35 +188,18 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
}
|
||||
|
||||
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),
|
||||
backgroundColor: colors,
|
||||
borderColor: '#ffffff',
|
||||
hoverOffset: 10
|
||||
}
|
||||
]
|
||||
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',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
boxWidth: 10,
|
||||
color: '#9ca3af'
|
||||
}
|
||||
}
|
||||
}
|
||||
plugins: { legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 10, color: '#9ca3af' } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -265,16 +208,18 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
try {
|
||||
const raw = localStorage.getItem(DASHBOARD_CACHE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as { recentExpenses?: Expense[]; stats?: StatsResponse | null };
|
||||
const parsed = JSON.parse(raw) as { recentExpenses?: Expense[]; stats?: StatsResponse | null; cashflow?: CashflowResponse | null; externalSummary?: ShoppingListSummary | null };
|
||||
this.recentExpenses = parsed.recentExpenses ?? [];
|
||||
this.stats = parsed.stats ?? null;
|
||||
this.cashflow = parsed.cashflow ?? null;
|
||||
this.externalSummary = parsed.externalSummary ?? 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 }));
|
||||
localStorage.setItem(DASHBOARD_CACHE_KEY, JSON.stringify({ recentExpenses: this.recentExpenses, stats: this.stats, cashflow: this.cashflow, externalSummary: this.externalSummary }));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user