feat: introduce an analytics view to display expense trends and category breakdowns.

This commit is contained in:
xds
2025-12-11 23:10:46 +03:00
parent 4b6a6f2fa7
commit 2156e57716
3 changed files with 331 additions and 0 deletions

View File

@@ -30,6 +30,7 @@ const isSpaceSelected = computed(
const menu = [ const menu = [
{ name: "Dashboard", icon: "pi pi-chart-bar", link: "/", navStack: 'dashboard' }, { name: "Dashboard", icon: "pi pi-chart-bar", link: "/", navStack: 'dashboard' },
{ name: "Analytics", icon: "pi pi-chart-line", link: "/analytics", navStack: 'analytics' },
{ name: "Transactions", icon: "pi pi-list", link: "/transactions", navStack: 'transactions' }, { name: "Transactions", icon: "pi pi-list", link: "/transactions", navStack: 'transactions' },
{ name: "Settings", icon: "pi pi-cog", link: "/settings", navStack: 'settings' }, { name: "Settings", icon: "pi pi-cog", link: "/settings", navStack: 'settings' },
]; ];

View File

@@ -24,6 +24,7 @@ declare module 'vue-router' {
// ⚙️ Ленивая загрузка компонентов (code-splitting) // ⚙️ Ленивая загрузка компонентов (code-splitting)
const SettingsList = () => import('@/components/settings/SettingsList.vue') const SettingsList = () => import('@/components/settings/SettingsList.vue')
const CategoriesList = () => import('@/components/settings/CategoriesList.vue') const CategoriesList = () => import('@/components/settings/CategoriesList.vue')
const AnalyticsView = () => import('@/views/AnalyticsView.vue')
const RecurrentsList = () => import('@/components/settings/RecurrentsList.vue') const RecurrentsList = () => import('@/components/settings/RecurrentsList.vue')
const SpaceSettings = () => import('@/components/settings/SpaceSettings.vue') const SpaceSettings = () => import('@/components/settings/SpaceSettings.vue')
const NotificationSettings = () => import('@/components/settings/NotificationSettings.vue') const NotificationSettings = () => import('@/components/settings/NotificationSettings.vue')
@@ -32,6 +33,7 @@ const NotificationSettings = () => import('@/components/settings/NotificationSet
export const RouteName = { export const RouteName = {
Login: 'login', Login: 'login',
Dashboard: 'dashboard', Dashboard: 'dashboard',
Analytics: 'analytics',
TransactionList: 'transaction-list', TransactionList: 'transaction-list',
TransactionCreate: 'transaction-create', TransactionCreate: 'transaction-create',
TransactionUpdate: 'transaction-update', TransactionUpdate: 'transaction-update',
@@ -59,6 +61,12 @@ const routes: RouteRecordRaw[] = [
component: DashboardView, component: DashboardView,
meta: { requiresAuth: true, navStack: 'dashboard', title: "Home" } meta: { requiresAuth: true, navStack: 'dashboard', title: "Home" }
}, },
{
path: '/analytics',
name: RouteName.Analytics,
component: AnalyticsView,
meta: { requiresAuth: true, navStack: 'analytics', title: "Analytics" }
},
{ {
path: '/transactions', path: '/transactions',
name: RouteName.TransactionList, name: RouteName.TransactionList,

322
src/views/AnalyticsView.vue Normal file
View File

@@ -0,0 +1,322 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useSpaceStore } from '@/stores/spaceStore';
import { DashboardService } from '@/services/dashboard-service';
import { DashboardData } from '@/models/dashboard';
import Chart from 'primevue/chart';
import Button from 'primevue/button';
import dayjs from 'dayjs';
import ProgressSpinner from 'primevue/progressspinner';
import { formatAmount } from '@/utils/utils';
const spaceStore = useSpaceStore();
const period = ref<'3m' | '6m'>('3m');
const monthlyData = ref<{ label: string; data: DashboardData }[]>([]);
const isLoading = ref(false);
const expandedMonths = ref<Set<string>>(new Set());
const toggleMonth = (label: string) => {
if (expandedMonths.value.has(label)) {
expandedMonths.value.delete(label);
} else {
expandedMonths.value.add(label);
}
}
const getSortedCategories = (categories: DashboardData['categories']) => {
return categories
.filter(c => c.category.type === 'EXPENSE' && c.currentPeriodAmount > 0)
.sort((a, b) => b.currentPeriodAmount - a.currentPeriodAmount);
}
const calculateMonthDateRange = (monthsBack: number) => {
const baseDate = dayjs().subtract(monthsBack, 'month');
const currentDay = dayjs().date();
// Logic from DashboardView:
// If current date is 1-9, the "current" fiscal month is actually the previous calendar month.
// e.g. Dec 5 -> Fiscal Nov (Nov 10 - Dec 9).
// We want to generate ranges going back from "current fiscal month".
// Let's establish "Fiscal Month 0" (Current).
// If today is Dec 11: Fiscal Month is Dec (Dec 10 - Jan 9).
// If today is Dec 5: Fiscal Month is Nov (Nov 10 - Dec 9).
let fiscalMonthStart = dayjs();
if (currentDay >= 1 && currentDay <= 9) {
fiscalMonthStart = fiscalMonthStart.subtract(1, 'month').date(10);
} else {
fiscalMonthStart = fiscalMonthStart.date(10);
}
// Now move back `monthsBack` times
const start = fiscalMonthStart.subtract(monthsBack, 'month');
const end = start.add(1, 'month').date(9);
return { startDate: start.toDate(), endDate: end.toDate() };
};
const fetchAnalyticsData = async () => {
if (!spaceStore.selectedSpaceId) return;
isLoading.value = true;
monthlyData.value = [];
const monthsToFetch = period.value === '3m' ? 3 : 6;
const requests = [];
for (let i = monthsToFetch - 1; i >= 0; i--) {
const { startDate, endDate } = calculateMonthDateRange(i);
const label = dayjs(startDate).format('MMMM'); // e.g. "November"
requests.push(
DashboardService.fetchDashboardData(spaceStore.selectedSpaceId, startDate, endDate)
.then(data => ({ label, data }))
);
}
try {
monthlyData.value = await Promise.all(requests);
} catch (e) {
console.error("Failed to fetch analytics data", e);
} finally {
isLoading.value = false;
}
};
const totalExpense = computed(() => {
return monthlyData.value.reduce((acc, curr) => acc + curr.data.totalExpense, 0);
});
const averageExpense = computed(() => {
// Avoid division by zero
if (monthlyData.value.length === 0) return 0;
return Math.round(totalExpense.value / monthlyData.value.length);
});
const aggregatedCategories = computed(() => {
const categoryMap = new Map<number, {
name: string,
icon: string,
totalAmount: number,
count: number
}>();
monthlyData.value.forEach(month => {
month.data.categories.forEach(cat => {
// Check if it's an expense category
// We can infer it if currentPeriodAmount > 0 and type logic
// Default logic from DashboardCharts: filter type === TransactionType.EXPENSE
// But here we rely on DashboardCategory data.
// Typically positive amount in DashboardCategory means it exists.
// Let's assume we filter expense categories (usually most relevant for budget).
// DashboardCategory has 'category.type'. Let's check it.
if (cat.category.type === 'EXPENSE') { // using string 'EXPENSE' as enum might not be imported or used directly here
const existing = categoryMap.get(cat.category.id);
if (existing) {
existing.totalAmount += cat.currentPeriodAmount;
existing.count += 1;
} else {
categoryMap.set(cat.category.id, {
name: cat.category.name,
icon: cat.category.icon,
totalAmount: cat.currentPeriodAmount,
count: 1
});
}
}
});
});
const categories = Array.from(categoryMap.values()).map(c => ({
...c,
averageAmount: Math.round(c.totalAmount / monthlyData.value.length), // Avg over the selected period (3 or 6 months)
percentage: totalExpense.value ? Math.round((c.totalAmount / totalExpense.value) * 100) : 0
}));
return categories.sort((a, b) => b.totalAmount - a.totalAmount);
});
const chartData = computed(() => {
const labels = monthlyData.value.map(d => d.label);
const expenses = monthlyData.value.map(d => d.data.totalExpense);
return {
labels,
datasets: [
{
label: 'Expenses',
data: expenses,
backgroundColor: '#ef4444',
borderRadius: 8
}
]
};
});
const chartOptions = {
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
grid: {
display: false
}
}
}
};
onMounted(() => {
fetchAnalyticsData();
});
watch(() => period.value, () => {
fetchAnalyticsData();
});
watch(() => spaceStore.selectedSpaceId, (newId) => {
if (newId) fetchAnalyticsData();
});
</script>
<template>
<div class="flex flex-col gap-6 w-full pb-20 px-2 lg:px-0">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-surface-900 dark:text-surface-0">Analytics</h1>
<div class="bg-surface-100 dark:bg-surface-800 p-1 rounded-lg inline-flex">
<button @click="period = '3m'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
:class="period === '3m' ? 'bg-white dark:bg-surface-700 shadow-sm !text-primary-600 !dark:text-primary-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200'">
3 Months
</button>
<button @click="period = '6m'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
:class="period === '6m' ? 'bg-white dark:bg-surface-700 shadow-sm !text-primary-600 !dark:text-primary-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200'">
6 Months
</button>
</div>
</div>
<div v-if="isLoading" class="flex justify-center py-10">
<ProgressSpinner />
</div>
<div v-else class="flex flex-col gap-6">
<!-- Aggregated Stats Cards -->
<div class="grid grid-cols-2 gap-4">
<div
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700">
<span class="text-surface-500 dark:text-surface-400 text-sm">Total Expenses</span>
<div class="text-xl font-bold text-surface-900 dark:text-surface-0 mt-1">
{{ formatAmount(totalExpense) }}
</div>
</div>
<div
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700">
<span class="text-surface-500 dark:text-surface-400 text-sm">Average Monthly</span>
<div class="text-xl font-bold text-surface-900 dark:text-surface-0 mt-1">
{{ formatAmount(averageExpense) }}
</div>
</div>
</div>
<!-- Chart -->
<div
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700">
<h2 class="text-lg font-semibold mb-4 text-surface-900 dark:text-surface-0">Expenses Overview</h2>
<div class="h-[300px] w-full">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-full w-full" />
</div>
</div>
<!-- Category Breakdown -->
<div class="flex flex-col gap-3">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-0">Top Categories</h2>
<div
class="bg-surface-0 dark:bg-surface-900 p-2 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 flex flex-col">
<div v-for="cat in aggregatedCategories" :key="cat.name"
class="flex items-center justify-between p-3 hover:bg-surface-50 dark:hover:bg-surface-800 rounded-lg transition-colors">
<div class="flex items-center gap-3">
<div class="text-2xl">{{ cat.icon }}</div>
<div class="flex flex-col">
<span class="font-medium text-surface-700 dark:text-surface-200">{{ cat.name }}</span>
<span class="text-xs text-surface-500">avg. {{ formatAmount(cat.averageAmount) }}
/mo</span>
</div>
</div>
<div class="flex flex-col items-end">
<span class="font-semibold text-surface-900 dark:text-surface-0">{{
formatAmount(cat.totalAmount) }} </span>
<span class="text-xs text-surface-500">{{ cat.percentage }}%</span>
</div>
</div>
</div>
</div>
<!-- Detailed Stats -->
<div class="flex flex-col gap-3">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-0">Monthly Breakdown</h2>
<div v-for="item in monthlyData" :key="item.label" class="flex flex-col gap-2">
<div @click="toggleMonth(item.label)"
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 flex justify-between items-center cursor-pointer hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors">
<div class="flex items-center gap-2">
<i :class="expandedMonths.has(item.label) ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
class="text-surface-500 text-sm"></i>
<span class="font-medium text-lg text-surface-700 dark:text-surface-200">{{ item.label
}}</span>
</div>
<div class="flex flex-col items-end">
<span class="font-bold text-red-500">- {{ formatAmount(item.data.totalExpense) }} </span>
<span class="text-sm text-green-500">+ {{ formatAmount(item.data.totalIncome) }} </span>
</div>
</div>
<!-- Expanded Category List -->
<div v-if="expandedMonths.has(item.label)" class="pl-4 pr-2">
<div
class="bg-surface-0 dark:bg-surface-900 p-2 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 flex flex-col">
<div v-for="cat in getSortedCategories(item.data.categories)" :key="cat.category.id"
class="flex items-center justify-between p-2 hover:bg-surface-50 dark:hover:bg-surface-800 rounded-lg transition-colors text-sm">
<div class="flex items-center gap-3">
<div class="text-xl">{{ cat.category.icon }}</div>
<span class="font-medium text-surface-700 dark:text-surface-200">{{
cat.category.name }}</span>
</div>
<div class="font-semibold text-surface-900 dark:text-surface-0">
{{ formatAmount(cat.currentPeriodAmount) }}
</div>
</div>
<div v-if="getSortedCategories(item.data.categories).length === 0"
class="p-2 text-center text-surface-500">
No expenses this month
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
```