341 lines
12 KiB
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>
|