Files
app-v2/src/components/dashboard/DashboardCharts.vue

341 lines
12 KiB
Vue

<script setup lang="ts">
import { computed, ref } from 'vue';
import Chart from 'primevue/chart';
import Button from 'primevue/button';
import CategoryTransactionsModal from './CategoryTransactionsModal.vue';
import { DashboardCategory, DashboardWeek } from '@/models/dashboard';
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<{
categories: DashboardCategory[];
transactions: Transaction[];
weeks: DashboardWeek[];
startDate: Date;
endDate: Date;
}>();
const chartType = ref<'category' | 'weekly'>('category');
const isCategoriesExpanded = ref(false);
// Modal state
const isModalVisible = ref(false);
const selectedCategory = ref<{ id: number; name: string; icon: string } | null>(null);
const openCategoryModal = (category: DashboardCategory) => {
selectedCategory.value = {
id: category.category.id,
name: category.category.name,
icon: category.category.icon
};
isModalVisible.value = true;
};
const expenses = computed(() => props.categories.filter(c => c.category.type === TransactionType.EXPENSE));
// --- Category Chart Logic ---
const categoryData = computed(() => {
return expenses.value
.map(c => ({
...c,
name: c.category.name,
amount: c.currentPeriodAmount,
}))
.sort((a, b) => b.amount - a.amount);
});
const totalExpenseAmount = computed(() => {
return expenses.value.reduce((sum, c) => sum + c.currentPeriodAmount, 0);
});
const topCategories = computed(() => {
return categoryData.value.map(cat => ({
...cat,
percentage: totalExpenseAmount.value ? Math.round((cat.amount / totalExpenseAmount.value) * 100) : 0
}));
});
const displayedCategories = computed(() => {
return isCategoriesExpanded.value ? topCategories.value : topCategories.value.slice(0, 5);
});
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 ---
const weeklyChartData = computed(() => {
return {
labels: props.weeks.map(w => `${dayjs(w.startDate).format('D MMM')} - ${dayjs(w.endDate).format('D MMM')}`),
datasets: [
{
label: 'Expenses',
data: props.weeks.map(w => w.expenseSum),
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-1">
<span class="text-xl !font-semibold pl-1">Expences by category </span>
<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">
<!-- 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">
<template v-if="chartType === 'category'">
<div class="categories-wrapper">
<div :class="['categories-content', { 'expanded': isCategoriesExpanded }]">
<div v-for="(category, index) in displayedCategories" :key="category.name"
class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors cursor-pointer"
@click="openCategoryModal(category)">
<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 !w-full">
<div class="flex items-center gap-0">
<span>{{ category.percentage }}</span>
<span class="ml-0.5">%</span>
</div>
<span class="text-xs ml-1 !w-fit"
:class="category.changeDiffPercentage > 0 ? 'text-red-500' : 'text-green-500'">({{
(category.changeDiffPercentage > 0 ? '+' : '') + Math.round(category.changeDiffPercentage)
}}%)</span>
</div>
</div>
</div>
</div>
<div v-if="!isCategoriesExpanded && topCategories.length > 5" class="categories-fade"></div>
</div>
<Button v-if="topCategories.length > 5" :label="isCategoriesExpanded ? 'Show less' : 'Show more'"
:icon="isCategoriesExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" class="expand-button"
@click="isCategoriesExpanded = !isCategoriesExpanded" />
</template>
<template v-else>
<div class="flex flex-col gap-4">
<div v-for="week in props.weeks" :key="week.startDate.toString()" 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">{{
dayjs(week.startDate).format('D MMM') }} - {{ dayjs(week.endDate).format('D MMM') }}</span>
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ formatAmount(week.expenseSum) }}
</span>
</div>
<!-- Categories 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="cat in week.categories.slice(0, 5)" :key="cat.categoryId"
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">{{ cat.categoryIcon }}</span>
<span class="text-surface-600 dark:text-surface-300 truncate">{{ cat.categoryName }}</span>
</div>
<span class="font-medium text-surface-900 dark:text-surface-100 whitespace-nowrap">{{
cat.sum ? formatAmount(cat.sum) : 0 }} </span>
</div>
<div v-if="week.categories.length > 5" class="text-xs text-surface-400 pl-2 pt-1">
+{{ week.categories.length - 5 }} more
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Category Transactions Modal -->
<CategoryTransactionsModal v-model:visible="isModalVisible" :categoryId="selectedCategory?.id ?? null"
:categoryName="selectedCategory?.name ?? ''" :categoryIcon="selectedCategory?.icon ?? ''" :startDate="startDate"
:endDate="endDate" />
</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);
}
.categories-wrapper {
position: relative;
}
.categories-content {
max-height: 300px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.categories-content.expanded {
max-height: 2000px;
}
.categories-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 80px;
background: linear-gradient(to bottom,
transparent 0%,
rgba(255, 255, 255, 0.7) 50%,
rgba(255, 255, 255, 0.95) 100%);
pointer-events: none;
transition: opacity 0.3s ease;
}
:global(.dark) .categories-fade {
background: linear-gradient(to bottom,
transparent 0%,
rgba(24, 24, 27, 0.7) 50%,
rgba(24, 24, 27, 0.95) 100%);
}
.expand-button {
margin-top: 1rem;
align-self: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
border: none !important;
color: white !important;
padding: 0.625rem 1.5rem !important;
border-radius: 2rem !important;
font-weight: 500 !important;
font-size: 0.875rem !important;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
transition: all 0.3s ease !important;
}
.expand-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4) !important;
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%) !important;
}
.expand-button:active {
transform: translateY(0);
}
</style>