feat: introduce an analytics view to display expense trends and category breakdowns.
This commit is contained in:
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
322
src/views/AnalyticsView.vue
Normal 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>
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user