feat: Add category transactions modal and expandable category list to the dashboard.
This commit is contained in:
59
src/App.vue
59
src/App.vue
@@ -3,13 +3,13 @@ import SpaceList from "@/components/space-list/SpaceList.vue";
|
|||||||
import Toolbar from "@/components/Toolbar.vue";
|
import Toolbar from "@/components/Toolbar.vue";
|
||||||
import Toast from "primevue/toast";
|
import Toast from "primevue/toast";
|
||||||
import ProgressSpinner from "primevue/progressspinner";
|
import ProgressSpinner from "primevue/progressspinner";
|
||||||
import {useSpaceStore} from "@/stores/spaceStore";
|
import { useSpaceStore } from "@/stores/spaceStore";
|
||||||
import {useToolbarStore} from "@/stores/toolbar-store";
|
import { useToolbarStore } from "@/stores/toolbar-store";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import {useRoute} from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import {computed, onBeforeUnmount, onMounted, ref, watch} from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import {useToast} from "primevue/usetoast";
|
import { useToast } from "primevue/usetoast";
|
||||||
import {useUserStore} from "@/stores/userStore";
|
import { useUserStore } from "@/stores/userStore";
|
||||||
|
|
||||||
const spaceStore = useSpaceStore();
|
const spaceStore = useSpaceStore();
|
||||||
const toolbarStore = useToolbarStore();
|
const toolbarStore = useToolbarStore();
|
||||||
@@ -29,9 +29,9 @@ const isSpaceSelected = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const menu = [
|
const menu = [
|
||||||
{name: "Dashboard", icon: "pi pi-chart-bar", link: "/", navStack: 'dashboard'},
|
{ name: "Dashboard", icon: "pi pi-chart-bar", link: "/", navStack: 'dashboard' },
|
||||||
{name: "Transactions", icon: "pi pi-list", link: "/transactions", navStack: 'transactions'},
|
{ name: "Transactions", icon: "pi pi-list", link: "/transactions", navStack: 'transactions' },
|
||||||
{name: "Settings", icon: "pi pi-cog", link: "/settings", navStack: 'settings'},
|
{ name: "Settings", icon: "pi pi-cog", link: "/settings", navStack: 'settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function spaceSelected() {
|
function spaceSelected() {
|
||||||
@@ -138,54 +138,43 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Toast/>
|
<Toast />
|
||||||
<div v-if="isLoading">
|
<div v-if="isLoading">
|
||||||
<ProgressSpinner/>
|
<ProgressSpinner />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-if="!userStore.isAuthorized">
|
<div v-if="!userStore.isAuthorized">
|
||||||
<router-view/>
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-col tg " :class="['ios', 'android'].includes(platform) ? '!pt-10' : ''">
|
<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 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">
|
<div class="flex w-full flex flex-row items-end justify-end pt-2 pe-4">
|
||||||
<Toolbar/>
|
<Toolbar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col w-full h-full items-end px-4 gap-4 pb-6">
|
<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>
|
</div>
|
||||||
<button
|
<button v-if="isInputFocused" @click="blurAllInputs"
|
||||||
v-if="isInputFocused"
|
class="fixed bottom-4 right-4 z-50 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||||
@click="blurAllInputs"
|
|
||||||
class="fixed bottom-4 right-4 z-50 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg"
|
|
||||||
>
|
|
||||||
Готово
|
Готово
|
||||||
</button>
|
</button>
|
||||||
<nav v-if="isNavVisible"
|
<nav v-if="isNavVisible" class="fixed bottom-4 left-1/2 -translate-x-1/2 z-50"
|
||||||
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;">
|
||||||
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"
|
||||||
|
|
||||||
<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"
|
|
||||||
class="flex w-fit h-full flex-col items-center gap-2 !py-2 !px-4"
|
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 ' : ''"
|
:class="route.meta.navStack === item.navStack ? 'bg-green-100 rounded-2xl ' : ''">
|
||||||
>
|
<i class="!text-lg" :class="item.icon" />
|
||||||
<i class="!text-lg" :class="item.icon"/>
|
|
||||||
<span class="font-medium text-gray-900">{{ item.name }}</span>
|
<span class="font-medium text-gray-900">{{ item.name }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex h-16"/>
|
<div class="flex h-16" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ body {
|
|||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
background-color: var(--surface-ground);
|
background-color: var(--surface-ground);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-family: 'Inter', sans-serif;
|
/* font-family: 'Inter', sans-serif; */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Checkbox */
|
/* Checkbox */
|
||||||
@@ -182,11 +182,17 @@ body {
|
|||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
}
|
}
|
||||||
.p-button-rounded{
|
.p-button-rounded{
|
||||||
|
color: gray !important;
|
||||||
background-color: white !important;
|
background-color: white !important;
|
||||||
}
|
}
|
||||||
.p-button-rounded:hover{
|
.p-button-rounded:hover{
|
||||||
|
color: white !important;
|
||||||
background-color: lightgray !important;
|
background-color: lightgray !important;
|
||||||
}
|
}
|
||||||
|
.p-menu .p-menuitem{
|
||||||
|
color: gray !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.p-menu .p-menuitem:hover {
|
.p-menu .p-menuitem:hover {
|
||||||
background-color: var(--menu-item-hover-bg-color) !important;
|
background-color: var(--menu-item-hover-bg-color) !important;
|
||||||
|
|||||||
127
src/components/dashboard/CategoryTransactionsModal.vue
Normal file
127
src/components/dashboard/CategoryTransactionsModal.vue
Normal 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>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import Chart from 'primevue/chart';
|
import Chart from 'primevue/chart';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import CategoryTransactionsModal from './CategoryTransactionsModal.vue';
|
||||||
import { DashboardCategory, DashboardWeek } from '@/models/dashboard';
|
import { DashboardCategory, DashboardWeek } from '@/models/dashboard';
|
||||||
import { Transaction } from '@/models/transaction';
|
import { Transaction } from '@/models/transaction';
|
||||||
import { TransactionType } from '@/models/enums';
|
import { TransactionType } from '@/models/enums';
|
||||||
@@ -14,9 +16,25 @@ const props = defineProps<{
|
|||||||
categories: DashboardCategory[];
|
categories: DashboardCategory[];
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
weeks: DashboardWeek[];
|
weeks: DashboardWeek[];
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const chartType = ref<'category' | 'weekly'>('category');
|
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));
|
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(() => {
|
const categoryData = computed(() => {
|
||||||
return expenses.value
|
return expenses.value
|
||||||
.map(c => ({
|
.map(c => ({
|
||||||
|
...c,
|
||||||
name: c.category.name,
|
name: c.category.name,
|
||||||
amount: c.currentPeriodAmount,
|
amount: c.currentPeriodAmount,
|
||||||
currentPeriodAmount: c.currentPeriodAmount,
|
|
||||||
previousPeriodAmount: c.previousPeriodAmount,
|
|
||||||
changeDiffPercentage: c.changeDiffPercentage
|
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.amount - a.amount);
|
.sort((a, b) => b.amount - a.amount);
|
||||||
});
|
});
|
||||||
@@ -38,12 +54,16 @@ const totalExpenseAmount = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const topCategories = computed(() => {
|
const topCategories = computed(() => {
|
||||||
return categoryData.value.slice(0, 5).map(cat => ({
|
return categoryData.value.map(cat => ({
|
||||||
...cat,
|
...cat,
|
||||||
percentage: totalExpenseAmount.value ? Math.round((cat.amount / totalExpenseAmount.value) * 100) : 0
|
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 categoryChartData = computed(() => {
|
||||||
const labels = categoryData.value.map(c => c.name);
|
const labels = categoryData.value.map(c => c.name);
|
||||||
const data = categoryData.value.map(c => c.amount);
|
const data = categoryData.value.map(c => c.amount);
|
||||||
@@ -157,10 +177,13 @@ const weeklyChartOptions = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Legend / Details Area -->
|
<!-- 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'">
|
<template v-if="chartType === 'category'">
|
||||||
<div v-for="(category, index) in topCategories" :key="category.name"
|
<div class="categories-wrapper">
|
||||||
class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors">
|
<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="flex items-center gap-3">
|
||||||
<div class="w-3 h-3 rounded-full" :class="getCategoryColor(index)"></div>
|
<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>
|
<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>{{ formatAmount(category.amount) }}</span>
|
||||||
<span class="ml-0.5">₽</span>
|
<span class="ml-0.5">₽</span>
|
||||||
</div>
|
</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>
|
formatAmount(category.previousPeriodAmount) }})</span>
|
||||||
|
|
||||||
</div>
|
</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">
|
<div class="flex items-center gap-0">
|
||||||
<span>{{ category.percentage }}</span>
|
<span>{{ category.percentage }}</span>
|
||||||
<span class="ml-0.5">%</span>
|
<span class="ml-0.5">%</span>
|
||||||
</div>
|
</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 ? '-' : '' +
|
category.changeDiffPercentage > 0 ? '+' : category.changeDiffPercentage < 0 ? '-' : '' +
|
||||||
Math.round(category.changeDiffPercentage) }})</span>
|
Math.round(category.changeDiffPercentage) }})</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -222,6 +252,11 @@ const weeklyChartOptions = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -242,4 +277,63 @@ const weeklyChartOptions = computed(() => {
|
|||||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--surface-700);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const dashboardService = DashboardService
|
|||||||
|
|
||||||
|
|
||||||
const dashboardData = ref<DashboardData>()
|
const dashboardData = ref<DashboardData>()
|
||||||
|
const isAiSummaryExpanded = ref(false)
|
||||||
|
|
||||||
const dashboardTransactions = ref<Transaction[]>([]);
|
const dashboardTransactions = ref<Transaction[]>([]);
|
||||||
const plannedTransactions = ref<Transaction[]>([]);
|
const plannedTransactions = ref<Transaction[]>([]);
|
||||||
@@ -48,6 +48,14 @@ const nextMonth = () => {
|
|||||||
fetchDashboardData();
|
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 () => {
|
const fetchDashboardData = async () => {
|
||||||
if (!spaceStore.selectedSpaceId) return;
|
if (!spaceStore.selectedSpaceId) return;
|
||||||
|
|
||||||
@@ -158,15 +166,23 @@ const userName = computed(() => {
|
|||||||
<StatsCard title="Total Balance" :amount="dashboardData.balance" icon="pi pi-wallet" color="blue" />
|
<StatsCard title="Total Balance" :amount="dashboardData.balance" icon="pi pi-wallet" color="blue" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex card">
|
<div class="flex flex-col card">
|
||||||
<span class="text-xl !font-semibold pl-1">Ai summary</span>
|
<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" />
|
<span v-html="dashboardData.analyzedText" />
|
||||||
</div>
|
</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 -->
|
<!-- Charts & Upcoming -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 lg:grid-cols-1 gap-4">
|
||||||
<!-- Charts -->
|
<!-- Charts -->
|
||||||
<DashboardCharts :categories="dashboardData.categories" :transactions="dashboardTransactions"
|
<DashboardCharts :categories="dashboardData.categories" :transactions="dashboardTransactions"
|
||||||
:weeks="dashboardData.weeks" />
|
:weeks="dashboardData.weeks" :startDate="currentStartDate" :endDate="currentEndDate" />
|
||||||
|
|
||||||
<!-- Upcoming Transactions -->
|
<!-- Upcoming Transactions -->
|
||||||
<div class=" ">
|
<div class=" ">
|
||||||
@@ -179,4 +195,67 @@ const userName = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
@@ -9,6 +9,7 @@ export interface DashboardData {
|
|||||||
upcomingTransactions: Transaction[],
|
upcomingTransactions: Transaction[],
|
||||||
recentTransactions: Transaction[],
|
recentTransactions: Transaction[],
|
||||||
weeks: DashboardWeek[],
|
weeks: DashboardWeek[],
|
||||||
|
analyzedText: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardWeek {
|
export interface DashboardWeek {
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ function toDateOnly(d: Date): string {
|
|||||||
export interface TransactionFilters {
|
export interface TransactionFilters {
|
||||||
type: TransactionType | null
|
type: TransactionType | null
|
||||||
kind: TransactionKind | null
|
kind: TransactionKind | null
|
||||||
|
categoriesIds: number[] | null
|
||||||
dateFrom: string | Date | null
|
dateFrom: string | Date | null
|
||||||
dateTo: string | Date | null
|
dateTo: string | Date | null
|
||||||
isDone: boolean | null
|
isDone: boolean | null
|
||||||
offset: number | null
|
offset: number | null
|
||||||
limit: number | null
|
limit: number | null
|
||||||
sorts: Map<string, string>[]
|
sorts: { sortBy: string; sortDirection: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SortDirection {
|
enum SortDirection {
|
||||||
|
|||||||
Reference in New Issue
Block a user