feat: Implement dedicated dashboard service and data models, revamp upcoming transactions UI, and update dashboard charts to use new category data.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import Chart from 'primevue/chart';
|
||||
import { DashboardCategory } from '@/models/dashboard';
|
||||
import { Transaction } from '@/models/transaction';
|
||||
import { TransactionType } from '@/models/enums';
|
||||
import { formatAmount } from '@/utils/utils';
|
||||
@@ -10,30 +11,29 @@ import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
const props = defineProps<{
|
||||
categories: DashboardCategory[];
|
||||
transactions: Transaction[];
|
||||
}>();
|
||||
|
||||
const chartType = ref<'category' | 'weekly'>('category');
|
||||
|
||||
const expenses = computed(() => props.transactions.filter(t => t.type === TransactionType.EXPENSE));
|
||||
const expenses = computed(() => props.categories.filter(c => c.category.type === TransactionType.EXPENSE));
|
||||
|
||||
// --- Category Chart Logic ---
|
||||
const categoryData = computed(() => {
|
||||
const categoryMap = new Map<string, number>();
|
||||
|
||||
expenses.value.forEach(t => {
|
||||
const categoryName = t.category?.name || 'Uncategorized';
|
||||
const currentAmount = categoryMap.get(categoryName) || 0;
|
||||
categoryMap.set(categoryName, currentAmount + t.amount);
|
||||
});
|
||||
|
||||
return Array.from(categoryMap.entries())
|
||||
.map(([name, amount]) => ({ name, amount }))
|
||||
return expenses.value
|
||||
.map(c => ({
|
||||
name: c.category.name,
|
||||
amount: c.currentPeriodAmount,
|
||||
currentPeriodAmount: c.currentPeriodAmount,
|
||||
previousPeriodAmount: c.previousPeriodAmount,
|
||||
changeDiffPercentage: c.changeDiffPercentage
|
||||
}))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
});
|
||||
|
||||
const totalExpenseAmount = computed(() => {
|
||||
return expenses.value.reduce((sum, t) => sum + t.amount, 0);
|
||||
return expenses.value.reduce((sum, c) => sum + c.currentPeriodAmount, 0);
|
||||
});
|
||||
|
||||
const topCategories = computed(() => {
|
||||
@@ -107,7 +107,10 @@ const weeklyData = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
expenses.value.forEach(t => {
|
||||
// Filter expense transactions from the transactions prop
|
||||
const expenseTransactions = props.transactions;
|
||||
|
||||
expenseTransactions.forEach(t => {
|
||||
const tDate = dayjs(t.date);
|
||||
const week = weeks.find(w => tDate.isAfter(w.start.subtract(1, 'second')) && tDate.isBefore(w.end.add(1, 'second')));
|
||||
if (week) {
|
||||
@@ -163,76 +166,100 @@ const weeklyChartOptions = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Chart Switcher -->
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-surface-100 dark:bg-surface-800 p-1 rounded-lg inline-flex">
|
||||
<button @click="chartType = 'category'"
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
|
||||
:class="chartType === 'category' ? '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'">
|
||||
By Category
|
||||
</button>
|
||||
<button @click="chartType = 'weekly'"
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
|
||||
:class="chartType === 'weekly' ? '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'">
|
||||
Last 4 Weeks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xl !font-semibold pl-1">Expences by category </span>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-start gap-8">
|
||||
<!-- Chart Area -->
|
||||
<div class="w-full md:w-1/2 flex justify-center h-[250px] items-center top-4">
|
||||
<Chart v-if="chartType === 'category'" type="doughnut" :data="categoryChartData" :options="categoryChartOptions"
|
||||
class="w-full max-w-[250px]" />
|
||||
<Chart v-else type="bar" :data="weeklyChartData" :options="weeklyChartOptions" class="w-full h-full" />
|
||||
</div>
|
||||
<div
|
||||
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700">
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<!-- Legend / Details Area -->
|
||||
<div class="w-full md:w-1/2 flex flex-col gap-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
<template v-if="chartType === 'category'">
|
||||
<div v-for="(category, index) in topCategories" :key="category.name"
|
||||
class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full" :class="getCategoryColor(index)"></div>
|
||||
<span class="font-medium text-surface-700 dark:text-surface-200">{{ category.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ formatAmount(category.amount) }}
|
||||
₽</span>
|
||||
<span class="text-sm text-surface-500 dark:text-surface-400 w-10 text-right">{{ category.percentage
|
||||
}}%</span>
|
||||
</div>
|
||||
<!-- Chart Switcher -->
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-surface-100 dark:bg-surface-800 p-1 rounded-lg inline-flex">
|
||||
<button @click="chartType = 'category'"
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
|
||||
:class="chartType === 'category' ? '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'">
|
||||
By Category
|
||||
</button>
|
||||
<button @click="chartType = 'weekly'"
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
|
||||
:class="chartType === 'weekly' ? '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'">
|
||||
Last 4 Weeks
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="week in weeklyData" :key="week.label" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between p-2 bg-surface-50 dark:bg-surface-800 rounded-lg">
|
||||
<span class="font-medium text-surface-700 dark:text-surface-200">{{ week.label }}</span>
|
||||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ formatAmount(week.amount) }}
|
||||
₽</span>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row items-start gap-8">
|
||||
<!-- Chart Area -->
|
||||
<div class="w-full md:w-1/2 flex justify-center h-[250px] items-center top-4">
|
||||
<Chart v-if="chartType === 'category'" type="doughnut" :data="categoryChartData"
|
||||
:options="categoryChartOptions" class="w-full max-w-[250px]" />
|
||||
<Chart v-else type="bar" :data="weeklyChartData" :options="weeklyChartOptions" class="w-full h-full" />
|
||||
</div>
|
||||
|
||||
<!-- Legend / Details Area -->
|
||||
<div class="w-full md:w-1/2 flex flex-col gap-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
<template v-if="chartType === 'category'">
|
||||
<div v-for="(category, index) in topCategories" :key="category.name"
|
||||
class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full" :class="getCategoryColor(index)"></div>
|
||||
<span class="font-medium text-surface-700 dark:text-surface-200">{{ category.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center font-semibold gap-1 text-surface-900 dark:text-surface-0">
|
||||
<div class="flex items-center gap-0">
|
||||
<span>{{ formatAmount(category.amount) }}</span>
|
||||
<span class="ml-0.5">₽</span>
|
||||
</div>
|
||||
<span class="text-xs text-surface-500 dark:text-surface-400 ml-1"> ({{
|
||||
formatAmount(category.previousPeriodAmount) }})</span>
|
||||
|
||||
</div>
|
||||
<div class="flex items-center text-sm gap-1 text-surface-500 dark:text-surface-400 w-10 text-right">
|
||||
<div class="flex items-center gap-0">
|
||||
<span>{{ category.percentage }}</span>
|
||||
<span class="ml-0.5">%</span>
|
||||
</div>
|
||||
<span class="text-xs text-surface-500 dark:text-surface-400 ml-1"> ({{
|
||||
category.changeDiffPercentage > 0 ? '+' : category.changeDiffPercentage < 0 ? '-' : '' +
|
||||
Math.round(category.changeDiffPercentage) }})</span>
|
||||
|
||||
<!-- Transactions List -->
|
||||
<div class="flex flex-col gap-1 pl-2 border-l-2 border-surface-100 dark:border-surface-700 ml-2 h-fit">
|
||||
<div v-for="tx in week.transactions.slice(0, 5)" :key="tx.id"
|
||||
class="flex items-center justify-between py-1 px-2 text-sm hover:bg-surface-50 dark:hover:bg-surface-800 rounded transition-colors">
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<span class="text-lg">{{ tx.category.icon }}</span>
|
||||
<span class="text-surface-600 dark:text-surface-300 truncate">{{ tx.category.name }}</span>
|
||||
</div>
|
||||
<span class="font-medium text-surface-900 dark:text-surface-100 whitespace-nowrap">{{
|
||||
formatAmount(tx.amount) }} ₽</span>
|
||||
</div>
|
||||
<div v-if="week.transactions.length > 5" class="text-xs text-surface-400 pl-2 pt-1">
|
||||
+{{ week.transactions.length - 5 }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="week in weeklyData" :key="week.label" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between p-2 bg-surface-50 dark:bg-surface-800 rounded-lg">
|
||||
<span class="font-medium text-surface-700 dark:text-surface-200">{{ week.label }}</span>
|
||||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ formatAmount(week.amount) }}
|
||||
₽</span>
|
||||
</div>
|
||||
|
||||
<!-- Transactions List -->
|
||||
<div
|
||||
class="flex flex-col gap-1 pl-2 border-l-2 border-surface-100 dark:border-surface-700 ml-2 h-fit">
|
||||
<div v-for="tx in week.transactions.slice(0, 5)" :key="tx.id"
|
||||
class="flex items-center justify-between py-1 px-2 text-sm hover:bg-surface-50 dark:hover:bg-surface-800 rounded transition-colors">
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<span class="text-lg">{{ tx.category.icon }}</span>
|
||||
<span class="text-surface-600 dark:text-surface-300 truncate">{{ tx.category.name }}</span>
|
||||
</div>
|
||||
<span class="font-medium text-surface-900 dark:text-surface-100 whitespace-nowrap">{{
|
||||
formatAmount(tx.amount) }} ₽</span>
|
||||
</div>
|
||||
<div v-if="week.transactions.length > 5" class="text-xs text-surface-400 pl-2 pt-1">
|
||||
+{{ week.transactions.length - 5 }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user