feat: Add category transactions modal and expandable category list to the dashboard.

This commit is contained in:
xds
2025-11-25 01:30:53 +03:00
parent 397175e34b
commit 203727de3e
7 changed files with 371 additions and 74 deletions

View File

@@ -1,6 +1,8 @@
<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';
@@ -14,9 +16,25 @@ 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));
@@ -24,11 +42,9 @@ const expenses = computed(() => props.categories.filter(c => c.category.type ===
const categoryData = computed(() => {
return expenses.value
.map(c => ({
...c,
name: c.category.name,
amount: c.currentPeriodAmount,
currentPeriodAmount: c.currentPeriodAmount,
previousPeriodAmount: c.previousPeriodAmount,
changeDiffPercentage: c.changeDiffPercentage
}))
.sort((a, b) => b.amount - a.amount);
});
@@ -38,12 +54,16 @@ const totalExpenseAmount = computed(() => {
});
const topCategories = computed(() => {
return categoryData.value.slice(0, 5).map(cat => ({
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);
@@ -157,36 +177,46 @@ const weeklyChartOptions = computed(() => {
</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">
<div class="w-full md:w-1/2 flex flex-col gap-3">
<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 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>
<span class="text-xs text-surface-500 dark:text-surface-400 ml-1"> ({{
formatAmount(category.previousPeriodAmount) }})</span>
<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>
<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>
</div>
</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>
</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>
@@ -222,6 +252,11 @@ const weeklyChartOptions = computed(() => {
</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>
@@ -242,4 +277,63 @@ const weeklyChartOptions = computed(() => {
.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>