import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { AfterViewChecked, Component, OnDestroy, OnInit, inject } from '@angular/core';
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 { 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 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 today = new Date();
return {
start: formatLocalDate(new Date(today.getFullYear(), today.getMonth(), 1)),
end: formatLocalDate(today)
};
};
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, CurrencyPipe, DatePipe],
template: `
{{ ui.t('dashboard.total') }}
{{ (stats?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}
{{ ui.t('dashboard.count') }}
{{ stats?.count || 0 }}
{{ ui.t('dashboard.budgetUsage') }}
{{ cashflow?.budgetUsagePercent || 0 }}%
{{ ui.t('cashflow.forecast') }}
{{ (cashflow?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}
{{ ui.t('dashboard.externalSpend') }}
{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}
{{ ui.t('dashboard.externalRecords') }}
{{ externalCount() }}
@if (stats?.byCategory?.length) {
} @else {
{{ ui.t('dashboard.noChartData') }}
}
{{ ui.t('cashflow.actual') }}{{ (cashflow?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}
{{ ui.t('cashflow.budget') }}{{ (cashflow?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}
{{ ui.t('cashflow.pending') }}{{ cashflow?.pendingApproval || 0 }}
{{ ui.t('cashflow.duplicates') }}{{ cashflow?.duplicateCount || 0 }}
{{ ui.t('budget.alerts') }}
@for (alert of cashflow?.alerts || []; track alert.id) {
{{ alert.name }} ยท {{ alert.usagePercent }}%
} @empty {
{{ ui.t('common.noData') }}
}
| {{ ui.t('table.title') }} | {{ ui.t('table.category') }} | {{ ui.t('expenses.field.status') }} | {{ ui.t('table.amount') }} |
@for (item of recentExpenses; track item.id) {
|
{{ item.title }}
{{ item.merchant || ui.t('expenses.noMerchant') }}
|
{{ item.category.name }} |
{{ ui.t('status.' + item.status.toLowerCase()) }} |
{{ item.amount | currency:'PLN':'symbol':'1.2-2' }} |
} @empty {
| {{ ui.t('common.noExpenses') }} |
}
| {{ ui.t('table.title') }} | {{ ui.t('table.date') }} | {{ ui.t('table.amount') }} |
@for (item of cashflow?.upcomingRecurring || []; track item.id) {
| {{ item.title }} | {{ item.nextRunDate | date:'yyyy-MM-dd' }} | {{ item.amount | currency:'PLN':'symbol':'1.2-2' }} |
} @empty {
| {{ ui.t('common.noData') }} |
}
`
})
export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
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;
ngOnInit() {
this.restoreCache();
this.loadDashboard();
}
ngAfterViewChecked() {
if (this.chartPending) {
this.chartPending = false;
this.renderChart();
}
}
ngOnDestroy() {
this.categoryChart?.destroy();
}
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; } });
}
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); }
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 scheduleChartRender() {
requestAnimationFrame(() => this.renderChart());
}
private renderChart() {
const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null;
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), backgroundColor: colors, borderColor: '#ffffff', hoverOffset: 10 }]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '66%',
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; 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, cashflow: this.cashflow, externalSummary: this.externalSummary }));
} catch {}
}
}