+ dashboard

This commit is contained in:
xds
2025-11-21 14:18:01 +03:00
parent e89d725d7e
commit d969551491
9 changed files with 7395 additions and 63 deletions

View File

@@ -0,0 +1,258 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import Chart from 'primevue/chart';
import { Transaction } from '@/models/transaction';
import { TransactionType } from '@/models/enums';
import { formatAmount } from '@/utils/utils';
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
dayjs.extend(isoWeek);
const props = defineProps<{
transactions: Transaction[];
}>();
const chartType = ref<'category' | 'weekly'>('category');
const expenses = computed(() => props.transactions.filter(t => t.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 }))
.sort((a, b) => b.amount - a.amount);
});
const totalExpenseAmount = computed(() => {
return expenses.value.reduce((sum, t) => sum + t.amount, 0);
});
const topCategories = computed(() => {
return categoryData.value.slice(0, 5).map(cat => ({
...cat,
percentage: totalExpenseAmount.value ? Math.round((cat.amount / totalExpenseAmount.value) * 100) : 0
}));
});
const categoryChartData = computed(() => {
const labels = categoryData.value.map(c => c.name);
const data = categoryData.value.map(c => c.amount);
// Generate colors
const backgroundColors = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#84cc16', '#6366f1', '#14b8a6'
];
return {
labels,
datasets: [
{
data,
backgroundColor: backgroundColors.slice(0, labels.length),
hoverBackgroundColor: backgroundColors.slice(0, labels.length)
}
]
};
});
const categoryChartOptions = computed(() => {
return {
plugins: {
legend: {
display: false // Hide default legend to use our custom list
}
}
};
});
const getCategoryColor = (index: number) => {
const colors = [
'bg-blue-500', 'bg-red-500', 'bg-emerald-500', 'bg-amber-500', 'bg-violet-500',
'bg-pink-500', 'bg-cyan-500', 'bg-lime-500', 'bg-indigo-500', 'bg-teal-500'
];
return colors[index % colors.length];
};
// --- Weekly Chart Logic ---
interface WeekData {
label: string;
start: dayjs.Dayjs;
end: dayjs.Dayjs;
amount: number;
transactions: Transaction[];
}
const weeklyData = computed(() => {
const weeks: WeekData[] = [];
// Generate last 4 weeks
for (let i = 3; i >= 0; i--) {
const startOfWeek = dayjs().subtract(i, 'week').startOf('isoWeek');
const endOfWeek = dayjs().subtract(i, 'week').endOf('isoWeek');
weeks.push({
label: `${startOfWeek.format('D MMM')} - ${endOfWeek.format('D MMM')}`,
start: startOfWeek,
end: endOfWeek,
amount: 0,
transactions: []
});
}
expenses.value.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) {
week.amount += t.amount;
week.transactions.push(t);
}
});
// Sort transactions by amount descending
weeks.forEach(week => {
week.transactions.sort((a, b) => b.amount - a.amount);
});
return weeks;
});
const weeklyChartData = computed(() => {
return {
labels: weeklyData.value.map(w => w.label),
datasets: [
{
label: 'Expenses',
data: weeklyData.value.map(w => w.amount),
backgroundColor: '#3b82f6',
borderRadius: 8
}
]
};
});
const weeklyChartOptions = computed(() => {
return {
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
grid: {
display: false
}
}
}
};
});
</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 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">
<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>
</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>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--surface-300);
border-radius: 20px;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--surface-700);
}
</style>