feat: Add category transactions modal and expandable category list to the dashboard.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user