404 lines
17 KiB
TypeScript
404 lines
17 KiB
TypeScript
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<TimelineRangeKey, number>)[range] ?? 0;
|
|
return {
|
|
start: formatLocalDate(startOfMonth(addMonths(today, -monthsBack))),
|
|
end: formatLocalDate(today)
|
|
};
|
|
};
|
|
|
|
@Component({
|
|
selector: 'app-dashboard',
|
|
standalone: true,
|
|
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">{{ ui.t('nav.dashboard') }}</h2>
|
|
<div class="text-secondary">{{ ui.t('dashboard.cashflowHint') }}</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>
|
|
|
|
@if (canShowExternalStats()) {
|
|
<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-12 d-flex align-items-stretch">
|
|
<div class="card pv-card h-100 w-100 overflow-hidden">
|
|
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
|
<h3 class="card-title mb-0">{{ ui.t('dashboard.trend') }}</h3>
|
|
<div class="btn-list">
|
|
@for (option of timelineRangeOptions; track option.value) {
|
|
<button class="btn btn-sm"
|
|
type="button"
|
|
[class.btn-primary]="selectedTimelineRange === option.value"
|
|
[class.btn-outline-secondary]="selectedTimelineRange !== option.value"
|
|
(click)="changeTimelineRange(option.value)">
|
|
{{ ui.t(option.labelKey) }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
@if (timelineStats?.timeline?.length) {
|
|
<div class="ec-chart-wrap"><canvas id="dashboardTimelineChart"></canvas></div>
|
|
} @else {
|
|
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noTrendData') }}</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 (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>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<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 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 (item of recentExpenses; track item.id) {
|
|
<tr>
|
|
<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>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
})
|
|
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 {}
|
|
}
|
|
}
|