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() }}

{{ ui.t('dashboard.share') }}

@if (stats?.byCategory?.length) {
} @else {
{{ ui.t('dashboard.noChartData') }}
}

{{ ui.t('nav.cashflow') }}

{{ 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('dashboard.recent') }}

@for (item of recentExpenses; track item.id) { } @empty { }
{{ ui.t('table.title') }}{{ ui.t('table.category') }}{{ ui.t('expenses.field.status') }}{{ ui.t('table.amount') }}
{{ 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' }}
{{ ui.t('common.noExpenses') }}

{{ ui.t('cashflow.upcomingRecurring') }}

@for (item of cashflow?.upcomingRecurring || []; track item.id) { } @empty { }
{{ ui.t('table.title') }}{{ ui.t('table.date') }}{{ ui.t('table.amount') }}
{{ item.title }}{{ item.nextRunDate | date:'yyyy-MM-dd' }}{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}
{{ 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 {} } }