import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common'; import { AfterViewChecked, Component, OnDestroy, OnInit, effect, inject } from '@angular/core'; import { Chart, ArcElement, CategoryScale, DoughnutController, Legend, LineController, LineElement, LinearScale, PointElement, Tooltip } from 'chart.js'; import { AuthService } from '../../core/services/auth.service'; 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, LineController, LineElement, PointElement, CategoryScale, LinearScale); const DASHBOARD_CACHE_KEY = 'expense-control-dashboard-v7'; const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4263eb', '#0ca678', '#e8590c']; type TimelineRangeKey = '1m' | '2m' | '3m' | 'q' | '6m' | '12m'; type TimelineRangeOption = { value: TimelineRangeKey; labelKey: string; }; 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 startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); const addMonths = (date: Date, amount: number) => { const next = new Date(date); next.setMonth(next.getMonth() + amount, 1); return next; }; const getMonthRange = () => { const today = new Date(); return { start: formatLocalDate(startOfMonth(today)), end: formatLocalDate(today) }; }; const getTimelineRange = (range: TimelineRangeKey) => { const today = new Date(); const monthsBack = ({ '1m': 0, '2m': 1, '3m': 2, q: 2, '6m': 5, '12m': 11 } as Record)[range] ?? 0; return { start: formatLocalDate(startOfMonth(addMonths(today, -monthsBack))), 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' }}
@if (canShowExternalStats()) {
{{ ui.t('dashboard.externalSpend') }}
{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}
{{ ui.t('dashboard.externalRecords') }}
{{ externalCount() }}
}

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

@for (option of timelineRangeOptions; track option.value) { }
@if (timelineStats?.timeline?.length) {
} @else {
{{ ui.t('dashboard.noTrendData') }}
}

{{ 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); readonly auth = inject(AuthService); private readonly expensesService = inject(ExpensesService); private readonly statsService = inject(StatsService); private readonly shoppingIntegration = inject(ShoppingListIntegrationService); readonly timelineRangeOptions: TimelineRangeOption[] = [ { value: '1m', labelKey: 'dashboard.range.1m' }, { value: '2m', labelKey: 'dashboard.range.2m' }, { value: '3m', labelKey: 'dashboard.range.3m' }, { value: 'q', labelKey: 'dashboard.range.q' }, { value: '6m', labelKey: 'dashboard.range.6m' }, { value: '12m', labelKey: 'dashboard.range.12m' } ]; recentExpenses: Expense[] = []; stats: StatsResponse | null = null; timelineStats: StatsResponse | null = null; cashflow: CashflowResponse | null = null; externalSummary: ShoppingListSummary | null = null; selectedTimelineRange: TimelineRangeKey = '3m'; private categoryChart?: Chart; private timelineChart?: Chart; private categoryChartPending = false; private timelineChartPending = false; constructor() { effect(() => { if (this.canShowExternalStats()) this.loadExternalSummary(); else { this.externalSummary = null; this.persistCache(); } }); } ngOnInit() { this.restoreCache(); this.loadDashboard(); this.loadTimeline(); } ngAfterViewChecked() { if (this.categoryChartPending) { this.categoryChartPending = false; this.renderCategoryChart(); } if (this.timelineChartPending) { this.timelineChartPending = false; this.renderTimelineChart(); } } ngOnDestroy() { this.categoryChart?.destroy(); this.timelineChart?.destroy(); } canShowExternalStats() { return Boolean(this.auth.currentUser()?.integrationsEnabled); } 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.categoryChartPending = true; this.persistCache(); } }); this.statsService.cashflow().subscribe({ next: (response) => { this.cashflow = response; this.persistCache(); } }); } changeTimelineRange(range: TimelineRangeKey) { if (this.selectedTimelineRange === range) return; this.selectedTimelineRange = range; this.loadTimeline(); } loadTimeline() { const range = getTimelineRange(this.selectedTimelineRange); this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({ next: (response) => { this.timelineStats = response; this.timelineChartPending = true; this.persistCache(); } }); } private loadExternalSummary() { const range = getMonthRange(); 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 renderCategoryChart() { 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 renderTimelineChart() { const canvas = document.getElementById('dashboardTimelineChart') as HTMLCanvasElement | null; if (!canvas || !this.timelineStats?.timeline?.length) { this.timelineChart?.destroy(); return; } this.timelineChart?.destroy(); this.timelineChart = new Chart(canvas, { type: 'line', data: { labels: this.timelineStats.timeline.map((item) => item.label), datasets: [{ label: this.ui.t('stats.expensesLabel'), data: this.timelineStats.timeline.map((item) => item.total), borderColor: '#206bc4', backgroundColor: '#206bc4', tension: 0.25, fill: false }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: '#9ca3af' } }, y: { ticks: { 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; timelineStats?: StatsResponse | null; cashflow?: CashflowResponse | null; externalSummary?: ShoppingListSummary | null; selectedTimelineRange?: TimelineRangeKey; }; this.recentExpenses = parsed.recentExpenses ?? []; this.stats = parsed.stats ?? null; this.timelineStats = parsed.timelineStats ?? null; this.cashflow = parsed.cashflow ?? null; this.externalSummary = parsed.externalSummary ?? null; this.selectedTimelineRange = parsed.selectedTimelineRange ?? '3m'; this.categoryChartPending = Boolean(this.stats?.byCategory?.length); this.timelineChartPending = Boolean(this.timelineStats?.timeline?.length); } catch {} } private persistCache() { try { localStorage.setItem(DASHBOARD_CACHE_KEY, JSON.stringify({ recentExpenses: this.recentExpenses, stats: this.stats, timelineStats: this.timelineStats, cashflow: this.cashflow, externalSummary: this.externalSummary, selectedTimelineRange: this.selectedTimelineRange })); } catch {} } }