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 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();
@@ -25,13 +25,13 @@ const isTelegram = computed(() => !!tgApp);
const isSpaceSelectorVisible = ref(false); const isSpaceSelectorVisible = ref(false);
const isSpaceSelected = computed( const isSpaceSelected = computed(
() => spaceStore.selectedSpaceId === undefined || isSpaceSelectorVisible.value () => spaceStore.selectedSpaceId === undefined || isSpaceSelectorVisible.value
); );
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() {
@@ -124,8 +124,8 @@ onMounted(async () => {
// 🔁 следим за изменением маршрута // 🔁 следим за изменением маршрута
watch( watch(
() => route.path, () => route.path,
() => setupBackButton() () => setupBackButton()
); );
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -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"
class="flex w-fit h-full flex-col items-center gap-2 !py-2 !px-4"
<div class="flex h-full items-center justify-between py-2 bg-white rounded-4xl px-6 w-fit shadow"> :class="route.meta.navStack === item.navStack ? 'bg-green-100 rounded-2xl ' : ''">
<!-- <div class="flex h-full justify-items-center items-center justify-between py-2 bg-white rounded-4xl !px-6 w-fit">--> <i class="!text-lg" :class="item.icon" />
<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"/>
<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>

View File

@@ -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;

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"> <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,36 +177,46 @@ 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 class="flex items-center gap-3"> <div v-for="(category, index) in displayedCategories" :key="category.name"
<div class="w-3 h-3 rounded-full" :class="getCategoryColor(index)"></div> class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors cursor-pointer"
<span class="font-medium text-surface-700 dark:text-surface-200">{{ category.name }}</span> @click="openCategoryModal(category)">
</div> <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="flex items-center font-semibold gap-1 text-surface-900 dark:text-surface-0"> <span class="font-medium text-surface-700 dark:text-surface-200">{{ category.name }}</span>
<div class="flex items-center gap-0">
<span>{{ formatAmount(category.amount) }}</span>
<span class="ml-0.5"></span>
</div> </div>
<span class="text-xs text-surface-500 dark:text-surface-400 ml-1"> ({{ <div class="flex items-center gap-3">
formatAmount(category.previousPeriodAmount) }})</span> <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>
<div class="flex items-center text-sm gap-1 text-surface-500 dark:text-surface-400 w-10 text-right"> <div
<div class="flex items-center gap-0"> class="flex items-center text-sm gap-1 text-surface-500 dark:text-surface-400 w-10 text-right">
<span>{{ category.percentage }}</span> <div class="flex items-center gap-0">
<span class="ml-0.5">%</span> <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> </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> </div>
<div v-if="!isCategoriesExpanded && topCategories.length > 5" class="categories-fade"></div>
</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>

View File

@@ -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>
<span v-html="dashboardData.analyzedText" /> <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> </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>

View File

@@ -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 {

View File

@@ -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 {