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

@@ -3,13 +3,13 @@ import SpaceList from "@/components/space-list/SpaceList.vue";
import Toolbar from "@/components/Toolbar.vue";
import Toast from "primevue/toast";
import ProgressSpinner from "primevue/progressspinner";
import {useSpaceStore} from "@/stores/spaceStore";
import {useToolbarStore} from "@/stores/toolbar-store";
import { useSpaceStore } from "@/stores/spaceStore";
import { useToolbarStore } from "@/stores/toolbar-store";
import router from "@/router";
import {useRoute} from "vue-router";
import {computed, onBeforeUnmount, onMounted, ref, watch} from "vue";
import {useToast} from "primevue/usetoast";
import {useUserStore} from "@/stores/userStore";
import { useRoute } from "vue-router";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useToast } from "primevue/usetoast";
import { useUserStore } from "@/stores/userStore";
const spaceStore = useSpaceStore();
const toolbarStore = useToolbarStore();
@@ -29,9 +29,9 @@ const isSpaceSelected = computed(
);
const menu = [
{name: "Dashboard", icon: "pi pi-chart-bar", link: "/", navStack: 'dashboard'},
{name: "Transactions", icon: "pi pi-list", link: "/transactions", navStack: 'transactions'},
{name: "Settings", icon: "pi pi-cog", link: "/settings", navStack: 'settings'},
{ name: "Dashboard", icon: "pi pi-chart-bar", link: "/", navStack: 'dashboard' },
{ name: "Transactions", icon: "pi pi-list", link: "/transactions", navStack: 'transactions' },
{ name: "Settings", icon: "pi pi-cog", link: "/settings", navStack: 'settings' },
];
function spaceSelected() {
@@ -138,54 +138,43 @@ onBeforeUnmount(() => {
</script>
<template>
<Toast/>
<Toast />
<div v-if="isLoading">
<ProgressSpinner/>
<ProgressSpinner />
</div>
<div v-else>
<div v-if="!userStore.isAuthorized">
<router-view/>
<router-view />
</div>
<div v-else class="flex flex-col tg " :class="['ios', 'android'].includes(platform) ? '!pt-10' : ''">
<SpaceList v-if="isSpaceSelected && userStore.isAuthorized" @space-selected="spaceSelected"/>
<SpaceList v-if="isSpaceSelected && userStore.isAuthorized" @space-selected="spaceSelected" />
<div v-else class="flex flex-col w-full gap-4">
<div class="flex w-full flex flex-row items-end justify-end pt-2 pe-4">
<Toolbar/>
<Toolbar />
</div>
<div class="flex flex-col w-full h-full items-end px-4 gap-4 pb-6">
<router-view class=" w-full"/>
<router-view class=" w-full" />
</div>
<button
v-if="isInputFocused"
@click="blurAllInputs"
class="fixed bottom-4 right-4 z-50 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg"
>
<button v-if="isInputFocused" @click="blurAllInputs"
class="fixed bottom-4 right-4 z-50 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg">
Готово
</button>
<nav v-if="isNavVisible"
class="fixed inset-x-0 bottom-4 z-50 w-full flex justify-center items-center "
style="padding-bottom: var(--tg-content-safe-area-inset-bottom) !important;"
>
<div class="flex h-full items-center justify-between py-2 bg-white rounded-4xl px-6 w-fit shadow">
<!-- <div class="flex h-full justify-items-center items-center justify-between py-2 bg-white rounded-4xl !px-6 w-fit">-->
<router-link
v-for="item in menu"
:key="item.link"
:to="item.link"
<nav v-if="isNavVisible" class="fixed bottom-4 left-1/2 -translate-x-1/2 z-50"
style="padding-bottom: var(--tg-content-safe-area-inset-bottom) !important;">
<div class="flex items-center justify-between py-2 bg-white rounded-4xl px-6 shadow">
<router-link v-for="item in menu" :key="item.link" :to="item.link"
class="flex w-fit h-full flex-col items-center gap-2 !py-2 !px-4"
:class="route.meta.navStack === item.navStack ? 'bg-green-100 rounded-2xl ' : ''"
>
<i class="!text-lg" :class="item.icon"/>
:class="route.meta.navStack === item.navStack ? 'bg-green-100 rounded-2xl ' : ''">
<i class="!text-lg" :class="item.icon" />
<span class="font-medium text-gray-900">{{ item.name }}</span>
</router-link>
</div>
</nav>
<div class="flex h-16"/>
<div class="flex h-16" />
</div>
</div>
</div>

View File

@@ -164,7 +164,7 @@ body {
width: 100% !important;
background-color: var(--surface-ground);
color: var(--text-color);
font-family: 'Inter', sans-serif;
/* font-family: 'Inter', sans-serif; */
}
/* Checkbox */
@@ -182,11 +182,17 @@ body {
border-radius: 1rem;
}
.p-button-rounded{
color: gray !important;
background-color: white !important;
}
.p-button-rounded:hover{
color: white !important;
background-color: lightgray !important;
}
.p-menu .p-menuitem{
color: gray !important;
}
.p-menu .p-menuitem:hover {
background-color: var(--menu-item-hover-bg-color) !important;

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import Dialog from 'primevue/dialog';
import { Transaction } from '@/models/transaction';
import { TransactionService, TransactionFilters } from '@/services/transactions-service';
import { useSpaceStore } from '@/stores/spaceStore';
import { formatAmount, toDateOnly } from '@/utils/utils';
import ProgressSpinner from 'primevue/progressspinner';
import dayjs from 'dayjs';
const props = defineProps<{
visible: boolean;
categoryId: number | null;
categoryName: string;
categoryIcon: string;
startDate: Date;
endDate: Date;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
}>();
const spaceStore = useSpaceStore();
const transactions = ref<Transaction[]>([]);
const isLoading = ref(false);
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
});
const fetchCategoryTransactions = async () => {
if (!props.categoryId || !spaceStore.selectedSpaceId) return;
isLoading.value = true;
try {
const filters: TransactionFilters = {
type: null,
kind: null,
categoriesIds: [props.categoryId],
dateFrom: toDateOnly(props.startDate),
dateTo: toDateOnly(props.endDate),
isDone: null,
offset: 0,
limit: 100,
sorts: [{ "sortBy": "t.date", "sortDirection": "DESC" }, { "sortBy": "t.id", "sortDirection": "DESC" }]
};
transactions.value = await TransactionService.getTransactions(spaceStore.selectedSpaceId, filters);
} catch (error) {
console.error('Failed to fetch category transactions:', error);
} finally {
isLoading.value = false;
}
};
watch(() => props.visible, (newVal) => {
if (newVal && props.categoryId) {
fetchCategoryTransactions();
}
});
const getTransactionTypeColor = (type: string) => {
return type === 'EXPENSE' ? 'text-red-600' : 'text-green-600';
};
const getTransactionTypeIcon = (type: string) => {
return type === 'EXPENSE' ? 'pi pi-arrow-up-right' : 'pi pi-arrow-down-left';
};
</script>
<template>
<Dialog v-model:visible="dialogVisible" :header="`${categoryIcon} ${categoryName}`" :modal="true"
:style="{ width: '90vw', maxWidth: '600px' }" :dismissableMask="true">
<div v-if="isLoading" class="flex justify-center py-8">
<ProgressSpinner />
</div>
<div v-else-if="transactions.length === 0" class="text-center py-8 text-surface-500">
No transactions found for this category
</div>
<div v-else class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto custom-scrollbar">
<div v-for="transaction in transactions" :key="transaction.id"
class="flex items-center justify-between p-3 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors border border-surface-200 dark:border-surface-700">
<div class="flex flex-col gap-1 flex-1">
<div class="flex items-center gap-2">
<i
:class="[getTransactionTypeIcon(transaction.type), getTransactionTypeColor(transaction.type)]"></i>
<span class="font-medium text-surface-900 dark:text-surface-0">
{{ transaction.comment || 'No description' }}
</span>
</div>
<span class="text-xs text-surface-500 dark:text-surface-400">
{{ dayjs(transaction.date).format('DD MMM YYYY') }}
</span>
</div>
<div class="flex items-center gap-1 font-semibold" :class="getTransactionTypeColor(transaction.type)">
<span>{{ transaction.type === 'EXPENSE' ? '-' : '+' }}</span>
<span>{{ formatAmount(transaction.amount) }}</span>
<span></span>
</div>
</div>
</div>
</Dialog>
</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>

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,10 +177,13 @@ 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="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>
@@ -171,22 +194,29 @@ const weeklyChartOptions = computed(() => {
<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"> ({{
<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 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"> ({{
<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>
</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>

View File

@@ -27,7 +27,7 @@ const dashboardService = DashboardService
const dashboardData = ref<DashboardData>()
const isAiSummaryExpanded = ref(false)
const dashboardTransactions = ref<Transaction[]>([]);
const plannedTransactions = ref<Transaction[]>([]);
@@ -48,6 +48,14 @@ const nextMonth = () => {
fetchDashboardData();
};
const currentStartDate = computed(() => {
return currentBaseDate.value.date(10).toDate();
});
const currentEndDate = computed(() => {
return currentBaseDate.value.add(1, 'month').date(9).toDate();
});
const fetchDashboardData = async () => {
if (!spaceStore.selectedSpaceId) return;
@@ -158,15 +166,23 @@ const userName = computed(() => {
<StatsCard title="Total Balance" :amount="dashboardData.balance" icon="pi pi-wallet" color="blue" />
</div>
</div>
<div class="flex card">
<span class="text-xl !font-semibold pl-1">Ai summary</span>
<div class="flex flex-col card">
<span class="text-xl !font-semibold pl-1 mb-2">AI Summary</span>
<div class="ai-summary-wrapper">
<div :class="['ai-summary-content', { 'expanded': isAiSummaryExpanded }]">
<span v-html="dashboardData.analyzedText" />
</div>
<div v-if="!isAiSummaryExpanded" class="ai-summary-fade"></div>
</div>
<Button :label="isAiSummaryExpanded ? 'Show less' : 'Show more'"
:icon="isAiSummaryExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" class="expand-button"
@click="isAiSummaryExpanded = !isAiSummaryExpanded" />
</div>
<!-- Charts & Upcoming -->
<div class="grid grid-cols-1 lg:grid-cols-1 gap-4">
<!-- Charts -->
<DashboardCharts :categories="dashboardData.categories" :transactions="dashboardTransactions"
:weeks="dashboardData.weeks" />
:weeks="dashboardData.weeks" :startDate="currentStartDate" :endDate="currentEndDate" />
<!-- Upcoming Transactions -->
<div class=" ">
@@ -179,4 +195,67 @@ const userName = computed(() => {
</div>
</template>
<style scoped></style>
<style scoped>
.ai-summary-wrapper {
position: relative;
}
.ai-summary-content {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
transition: all 0.3s ease;
line-height: 1.6;
}
.ai-summary-content.expanded {
display: block;
-webkit-line-clamp: unset;
}
.ai-summary-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3em;
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) .ai-summary-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>

View File

@@ -9,6 +9,7 @@ export interface DashboardData {
upcomingTransactions: Transaction[],
recentTransactions: Transaction[],
weeks: DashboardWeek[],
analyzedText: string,
}
export interface DashboardWeek {

View File

@@ -15,12 +15,13 @@ function toDateOnly(d: Date): string {
export interface TransactionFilters {
type: TransactionType | null
kind: TransactionKind | null
categoriesIds: number[] | null
dateFrom: string | Date | null
dateTo: string | Date | null
isDone: boolean | null
offset: number | null
limit: number | null
sorts: Map<string, string>[]
sorts: { sortBy: string; sortDirection: string }[]
}
enum SortDirection {