Merge remote-tracking branch 'origin/main'

This commit is contained in:
xds
2026-03-10 15:09:05 +03:00
16 changed files with 1353 additions and 340 deletions

View File

@@ -1,6 +1,6 @@
ssh root@213.226.71.138 " ssh root@31.59.58.220 "
cd /root/luminic/space/app && cd /root/luminic/app/front &&
git pull && git pull &&
npm run build && npm run build &&
cp -r dist/* /var/www/app.luminic.space/ cp -r dist/* /var/www/app.luminic.space/

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();
@@ -29,9 +29,10 @@ 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: "Analytics", icon: "pi pi-chart-line", link: "/analytics", navStack: 'analytics' },
{name: "Settings", icon: "pi pi-cog", link: "/settings", navStack: 'settings'}, { name: "Transactions", icon: "pi pi-list", link: "/transactions", navStack: 'transactions' },
{ name: "Settings", icon: "pi pi-cog", link: "/settings", navStack: 'settings' },
]; ];
function spaceSelected() { function spaceSelected() {
@@ -44,23 +45,33 @@ let backHandler: (() => void) | null = null;
function setupBackButton() { function setupBackButton() {
if (!tgApp.initData) return; if (!tgApp.initData) return;
if (route.path !== "/") { // снять старый обработчик
if (backHandler) {
tgApp.BackButton.offClick(backHandler);
backHandler = null;
}
console.log('history legth:' + window.history.length)
if (window.history.length > 1) {
tgApp.BackButton.show(); tgApp.BackButton.show();
// снять старый обработчик
if (backHandler) tgApp.BackButton.offClick(backHandler);
// навесить новый
backHandler = () => { backHandler = () => {
if (window.history.length > 1) { if (window.history.length > 1) {
router.back(); router.back();
} else { } else {
tgApp.BackButton.hide(); tgApp.close();
} }
}; };
tgApp.BackButton.onClick(backHandler); tgApp.BackButton.onClick(backHandler);
} else { } else {
tgApp.BackButton.hide(); // tgApp.BackButton.show();
backHandler = () => {
tgApp.close();
};
tgApp.BackButton.onClick(backHandler);
} }
} }
@@ -138,54 +149,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>

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 */
@@ -181,6 +181,18 @@ body {
box-shadow: 0 2px 8px var(--shadow-color); box-shadow: 0 2px 8px var(--shadow-color);
border-radius: 1rem; 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 { .p-menu .p-menuitem:hover {
background-color: var(--menu-item-hover-bg-color) !important; background-color: var(--menu-item-hover-bg-color) !important;
@@ -200,7 +212,4 @@ body {
color: var(--warning-color); color: var(--warning-color);
} }
span, a, i {
color: var(--text-color) !important;
padding: 0 !important;
}

View File

@@ -0,0 +1,128 @@
<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';
import { TransactionKind } from '@/models/enums';
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: TransactionKind.INSTANT,
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,9 @@
<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 { Transaction } from '@/models/transaction'; import { Transaction } from '@/models/transaction';
import { TransactionType } from '@/models/enums'; import { TransactionType } from '@/models/enums';
import { formatAmount } from '@/utils/utils'; import { formatAmount } from '@/utils/utils';
@@ -10,39 +13,57 @@ import isoWeek from 'dayjs/plugin/isoWeek';
dayjs.extend(isoWeek); dayjs.extend(isoWeek);
const props = defineProps<{ const props = defineProps<{
categories: DashboardCategory[];
transactions: Transaction[]; transactions: Transaction[];
weeks: DashboardWeek[];
startDate: Date;
endDate: Date;
}>(); }>();
const chartType = ref<'category' | 'weekly'>('category'); const chartType = ref<'category' | 'weekly'>('category');
const isCategoriesExpanded = ref(false);
const expenses = computed(() => props.transactions.filter(t => t.type === TransactionType.EXPENSE)); // 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));
// --- Category Chart Logic --- // --- Category Chart Logic ---
const categoryData = computed(() => { const categoryData = computed(() => {
const categoryMap = new Map<string, number>(); return expenses.value
.map(c => ({
expenses.value.forEach(t => { ...c,
const categoryName = t.category?.name || 'Uncategorized'; name: c.category.name,
const currentAmount = categoryMap.get(categoryName) || 0; amount: c.currentPeriodAmount,
categoryMap.set(categoryName, currentAmount + t.amount); }))
});
return Array.from(categoryMap.entries())
.map(([name, amount]) => ({ name, amount }))
.sort((a, b) => b.amount - a.amount); .sort((a, b) => b.amount - a.amount);
}); });
const totalExpenseAmount = computed(() => { const totalExpenseAmount = computed(() => {
return expenses.value.reduce((sum, t) => sum + t.amount, 0); return expenses.value.reduce((sum, c) => sum + c.currentPeriodAmount, 0);
}); });
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);
@@ -84,53 +105,14 @@ const getCategoryColor = (index: number) => {
}; };
// --- Weekly Chart Logic --- // --- Weekly Chart Logic ---
interface WeekData {
label: string;
start: dayjs.Dayjs;
end: dayjs.Dayjs;
amount: number;
transactions: Transaction[];
}
const weeklyData = computed(() => {
const weeks: WeekData[] = [];
// Generate last 4 weeks
for (let i = 3; i >= 0; i--) {
const startOfWeek = dayjs().subtract(i, 'week').startOf('isoWeek');
const endOfWeek = dayjs().subtract(i, 'week').endOf('isoWeek');
weeks.push({
label: `${startOfWeek.format('D MMM')} - ${endOfWeek.format('D MMM')}`,
start: startOfWeek,
end: endOfWeek,
amount: 0,
transactions: []
});
}
expenses.value.forEach(t => {
const tDate = dayjs(t.date);
const week = weeks.find(w => tDate.isAfter(w.start.subtract(1, 'second')) && tDate.isBefore(w.end.add(1, 'second')));
if (week) {
week.amount += t.amount;
week.transactions.push(t);
}
});
// Sort transactions by amount descending
weeks.forEach(week => {
week.transactions.sort((a, b) => b.amount - a.amount);
});
return weeks;
});
const weeklyChartData = computed(() => { const weeklyChartData = computed(() => {
return { return {
labels: weeklyData.value.map(w => w.label), labels: props.weeks.map(w => `${dayjs(w.startDate).format('D MMM')} - ${dayjs(w.endDate).format('D MMM')}`),
datasets: [ datasets: [
{ {
label: 'Expenses', label: 'Expenses',
data: weeklyData.value.map(w => w.amount), data: props.weeks.map(w => w.expenseSum),
backgroundColor: '#3b82f6', backgroundColor: '#3b82f6',
borderRadius: 8 borderRadius: 8
} }
@@ -163,18 +145,24 @@ const weeklyChartOptions = computed(() => {
</script> </script>
<template> <template>
<div class="flex flex-col gap-1">
<span class="text-xl !font-semibold pl-1">Expences by category </span>
<div
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700">
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<!-- Chart Switcher --> <!-- Chart Switcher -->
<div class="flex justify-center"> <div class="flex justify-center">
<div class="bg-surface-100 dark:bg-surface-800 p-1 rounded-lg inline-flex"> <div class="bg-surface-100 dark:bg-surface-800 p-1 rounded-lg inline-flex">
<button @click="chartType = 'category'" <button @click="chartType = 'category'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200" class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
:class="chartType === 'category' ? 'bg-white dark:bg-surface-700 shadow-sm text-primary-600 dark:text-primary-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200'"> :class="chartType === 'category' ? 'bg-white dark:bg-surface-700 shadow-sm !text-primary-600 !dark:text-primary-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200'">
By Category By Category
</button> </button>
<button @click="chartType = 'weekly'" <button @click="chartType = 'weekly'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200" class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
:class="chartType === 'weekly' ? 'bg-white dark:bg-surface-700 shadow-sm text-primary-600 dark:text-primary-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200'"> :class="chartType === 'weekly' ? 'bg-white dark:bg-surface-700 shadow-sm !text-primary-600 !dark:text-primary-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200'">
Last 4 Weeks Last 4 Weeks
</button> </button>
</div> </div>
@@ -183,51 +171,79 @@ const weeklyChartOptions = computed(() => {
<div class="flex flex-col md:flex-row items-start gap-8"> <div class="flex flex-col md:flex-row items-start gap-8">
<!-- Chart Area --> <!-- Chart Area -->
<div class="w-full md:w-1/2 flex justify-center h-[250px] items-center top-4"> <div class="w-full md:w-1/2 flex justify-center h-[250px] items-center top-4">
<Chart v-if="chartType === 'category'" type="doughnut" :data="categoryChartData" :options="categoryChartOptions" <Chart v-if="chartType === 'category'" type="doughnut" :data="categoryChartData"
class="w-full max-w-[250px]" /> :options="categoryChartOptions" class="w-full max-w-[250px]" />
<Chart v-else type="bar" :data="weeklyChartData" :options="weeklyChartOptions" class="w-full h-full" /> <Chart v-else type="bar" :data="weeklyChartData" :options="weeklyChartOptions" class="w-full h-full" />
</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>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ formatAmount(category.amount) }} <div class="flex items-center font-semibold gap-1 text-surface-900 dark:text-surface-0">
</span> <div class="flex items-center gap-0">
<span class="text-sm text-surface-500 dark:text-surface-400 w-10 text-right">{{ category.percentage <span>{{ formatAmount(category.amount) }}</span>
}}%</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 !w-full">
<div class="flex items-center gap-0">
<span>{{ category.percentage }}</span>
<span class="ml-0.5">%</span>
</div>
<span class="text-xs ml-1 !w-fit"
:class="category.changeDiffPercentage > 0 ? 'text-red-500' : 'text-green-500'">({{
(category.changeDiffPercentage > 0 ? '+' : '') + Math.round(category.changeDiffPercentage)
}}%)</span>
</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>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div v-for="week in weeklyData" :key="week.label" class="flex flex-col gap-2"> <div v-for="week in props.weeks" :key="week.startDate.toString()" class="flex flex-col gap-2">
<div class="flex items-center justify-between p-2 bg-surface-50 dark:bg-surface-800 rounded-lg"> <div class="flex items-center justify-between p-2 bg-surface-50 dark:bg-surface-800 rounded-lg">
<span class="font-medium text-surface-700 dark:text-surface-200">{{ week.label }}</span> <span class="font-medium text-surface-700 dark:text-surface-200">{{
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ formatAmount(week.amount) }} dayjs(week.startDate).format('D MMM') }} - {{ dayjs(week.endDate).format('D MMM') }}</span>
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ formatAmount(week.expenseSum) }}
</span> </span>
</div> </div>
<!-- Transactions List --> <!-- Categories List -->
<div class="flex flex-col gap-1 pl-2 border-l-2 border-surface-100 dark:border-surface-700 ml-2 h-fit"> <div
<div v-for="tx in week.transactions.slice(0, 5)" :key="tx.id" class="flex flex-col gap-1 pl-2 border-l-2 border-surface-100 dark:border-surface-700 ml-2 h-fit">
<div v-for="cat in week.categories.slice(0, 5)" :key="cat.categoryId"
class="flex items-center justify-between py-1 px-2 text-sm hover:bg-surface-50 dark:hover:bg-surface-800 rounded transition-colors"> class="flex items-center justify-between py-1 px-2 text-sm hover:bg-surface-50 dark:hover:bg-surface-800 rounded transition-colors">
<div class="flex items-center gap-2 overflow-hidden"> <div class="flex items-center gap-2 overflow-hidden">
<span class="text-lg">{{ tx.category.icon }}</span> <span class="text-lg">{{ cat.categoryIcon }}</span>
<span class="text-surface-600 dark:text-surface-300 truncate">{{ tx.category.name }}</span> <span class="text-surface-600 dark:text-surface-300 truncate">{{ cat.categoryName }}</span>
</div> </div>
<span class="font-medium text-surface-900 dark:text-surface-100 whitespace-nowrap">{{ <span class="font-medium text-surface-900 dark:text-surface-100 whitespace-nowrap">{{
formatAmount(tx.amount) }} </span> cat.sum ? formatAmount(cat.sum) : 0 }} </span>
</div> </div>
<div v-if="week.transactions.length > 5" class="text-xs text-surface-400 pl-2 pt-1"> <div v-if="week.categories.length > 5" class="text-xs text-surface-400 pl-2 pt-1">
+{{ week.transactions.length - 5 }} more +{{ week.categories.length - 5 }} more
</div> </div>
</div> </div>
</div> </div>
@@ -236,6 +252,13 @@ const weeklyChartOptions = computed(() => {
</div> </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>
</template> </template>
<style scoped> <style scoped>
@@ -255,4 +278,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

@@ -1,42 +1,171 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useTransactionStore } from '@/stores/transactions-store';
import { useSpaceStore } from '@/stores/spaceStore'; import { useSpaceStore } from '@/stores/spaceStore';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import { TransactionType } from '@/models/enums'; import { useRecurrentsStore } from '@/stores/recurrent-store';
import { TransactionType, TransactionKind } from '@/models/enums';
import { Transaction, UpdateTransactionDTO } from '@/models/transaction';
import { TransactionFilters, TransactionService } from '@/services/transactions-service';
import { DashboardService } from '@/services/dashboard-service';
import StatsCard from './StatsCard.vue'; import StatsCard from './StatsCard.vue';
import RecentTransactions from './RecentTransactions.vue'; import RecentTransactions from './RecentTransactions.vue';
import DashboardCharts from './DashboardCharts.vue'; import DashboardCharts from './DashboardCharts.vue';
import UpcomingTransactions from './UpcomingTransactions.vue'; import UpcomingTransactions from './UpcomingTransactions.vue';
import { useToast, ProgressSpinner } from 'primevue';
import Button from 'primevue/button';
import { DashboardData } from '@/models/dashboard';
const transactionStore = useTransactionStore();
import dayjs from 'dayjs';
const toast = useToast()
const spaceStore = useSpaceStore(); const spaceStore = useSpaceStore();
const userStore = useUserStore(); const userStore = useUserStore();
const recurrentsStore = useRecurrentsStore();
const transactionService = TransactionService
const dashboardService = DashboardService
const dashboardData = ref<DashboardData>()
const isAiSummaryExpanded = ref(false)
const dashboardTransactions = ref<Transaction[]>([]);
const plannedTransactions = ref<Transaction[]>([]);
const currentBaseDate = ref(dayjs());
const displayMonth = computed(() => {
const currentDay = currentBaseDate.value.date();
// Если текущая дата от 1 до 9, период начинается с 10-го числа предыдущего месяца
if (currentDay >= 1 && currentDay <= 9) {
return currentBaseDate.value.subtract(1, 'month').format('MMMM YYYY');
}
return currentBaseDate.value.format('MMMM YYYY');
});
const prevMonth = () => {
currentBaseDate.value = currentBaseDate.value.subtract(1, 'month');
fetchDashboardData();
};
const nextMonth = () => {
currentBaseDate.value = currentBaseDate.value.add(1, 'month');
fetchDashboardData();
};
const currentStartDate = computed(() => {
const currentDay = currentBaseDate.value.date();
// Если текущая дата от 1 до 9, период начинается с 10-го числа предыдущего месяца
if (currentDay >= 1 && currentDay <= 9) {
return currentBaseDate.value.subtract(1, 'month').date(10).toDate();
}
// Если текущая дата от 10 до конца месяца, период начинается с 10-го числа текущего месяца
return currentBaseDate.value.date(10).toDate();
});
const currentEndDate = computed(() => {
const currentDay = currentBaseDate.value.date();
// Если текущая дата от 1 до 9, период заканчивается 9-го числа текущего месяца
if (currentDay >= 1 && currentDay <= 9) {
return currentBaseDate.value.date(9).toDate();
}
// Если текущая дата от 10 до конца месяца, период заканчивается 9-го числа следующего месяца
return currentBaseDate.value.add(1, 'month').date(9).toDate();
});
const fetchDashboardData = async () => {
if (!spaceStore.selectedSpaceId) return;
const currentDay = currentBaseDate.value.date();
let startDate: Date;
let endDate: Date;
// Если текущая дата от 1 до 9, период: с 10-го предыдущего месяца до 9-го текущего месяца
if (currentDay >= 1 && currentDay <= 9) {
startDate = currentBaseDate.value.subtract(1, 'month').date(10).toDate();
endDate = currentBaseDate.value.date(9).toDate();
} else {
// Если текущая дата от 10 до конца месяца, период: с 10-го текущего месяца до 9-го следующего месяца
startDate = currentBaseDate.value.date(10).toDate();
endDate = currentBaseDate.value.add(1, 'month').date(9).toDate();
}
try {
dashboardData.value = await dashboardService.fetchDashboardData(spaceStore.selectedSpaceId, startDate, endDate)
dashboardTransactions.value = dashboardData.value.recentTransactions
plannedTransactions.value = dashboardData.value.upcomingTransactions
// console.log(plannedTransactions.value)
// console.log(dashboardTransactions.value)
// Fetch transactions for charts and stats (Done transactions)
// dashboardTransactions.value = await transactionService.getTransactions(spaceStore.selectedSpaceId, {
// kind: "INSTANT",
// limit: 10,
// sorts: [{ "sortBy": "t.date", "sortDirection": "DESC" }, { "sortBy": "t.id", "sortDirection": "DESC" }]
// } as TransactionFilters);
// // Fetch planned transactions for Upcoming Payments
// plannedTransactions.value = await TransactionService.getTransactions(spaceStore.selectedSpaceId, {
// kind: TransactionKind.PLANNING,
// isDone: false,
// offset: 0,
// limit: 5,
// } as TransactionFilters);
} catch (e) {
console.error("Failed to fetch dashboard data", e);
}
};
const setTransactionDone = async (transaction: Transaction): Promise<void> => {
// console.log(transaction)
const updateTransaction = {
type: transaction.type,
kind: transaction.kind,
categoryId: transaction.category.id,
comment: transaction.comment,
amount: transaction.amount,
fees: 0,
isDone: !transaction.isDone,
date: new Date(transaction.date),
} as UpdateTransactionDTO
// console.log(updateTransaction)
try {
await transactionService.updateTransaction(spaceStore.selectedSpaceId as number, transaction.id, updateTransaction)
} catch (error) {
toast.add({
severity: 'error',
summary: 'Failed to update transactions.',
detail: String(error),
life: 3000,
})
}
}
onMounted(async () => { onMounted(async () => {
if (spaceStore.selectedSpaceId) { if (spaceStore.selectedSpaceId) {
await Promise.all([ await Promise.all([
transactionStore.fetchTransactions(spaceStore.selectedSpaceId), fetchDashboardData(),
transactionStore.fetchPlannedTransactions(spaceStore.selectedSpaceId) // recurrentsStore.fetchRecurrents(spaceStore.selectedSpaceId)
]); ]);
} }
}); });
const totalIncome = computed(() => { watch(() => spaceStore.selectedSpaceId, async (newId) => {
return transactionStore.transactions if (newId) {
.filter(t => t.type === TransactionType.INCOME) await Promise.all([
.reduce((sum, t) => sum + t.amount, 0); fetchDashboardData(),
// recurrentsStore.fetchRecurrents(newId)
]);
}
}); });
const totalExpense = computed(() => {
return transactionStore.transactions
.filter(t => t.type === TransactionType.EXPENSE)
.reduce((sum, t) => sum + t.amount, 0);
});
const totalBalance = computed(() => {
return totalIncome.value - totalExpense.value;
});
const userName = computed(() => { const userName = computed(() => {
return userStore.user?.firstName || 'User'; return userStore.user?.firstName || 'User';
@@ -44,47 +173,151 @@ const userName = computed(() => {
</script> </script>
<template> <template>
<div class="flex flex-col gap-6 w-full pb-20"> <ProgressSpinner v-if="!dashboardData" />
<div v-else class="flex flex-col gap-6 w-full pb-20">
<!-- Header / Greeting --> <!-- Header / Greeting -->
<div class="flex flex-col gap-1 px-1"> <div class="flex flex-col gap-1 px-1">
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-0">
<h1 class="!text-2xl !font-semibold text-surface-900 dark:text-surface-0">
Hello, {{ userName }}! 👋 Hello, {{ userName }}! 👋
</h1> </h1>
<p class="text-surface-500 dark:text-surface-400"> <p class="text-surface-500 dark:text-surface-400">
Here is your financial overview. Here is your financial overview.
</p> </p>
<div class="flex items-center justify-between mb-4">
<Button icon="pi pi-chevron-left" text rounded @click="prevMonth" />
<span class="!text-lg !font-semibold !capitalize">{{ displayMonth }}</span>
<Button icon="pi pi-chevron-right" text rounded @click="nextMonth" />
</div>
</div> </div>
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-3 md:grid-cols-3 gap-4"> <div class="grid grid-cols-2 md:grid-cols-2 gap-4">
<StatsCard title="Total Balance" :amount="totalBalance" icon="pi pi-wallet" color="blue" /> <StatsCard title="Expense" :amount="dashboardData.totalExpense" icon="pi pi-arrow-up-right" color="red" />
<StatsCard title="Income" :amount="totalIncome" icon="pi pi-arrow-down-left" color="green" /> <StatsCard title="Income" :amount="dashboardData.totalIncome" icon="pi pi-arrow-down-left" color="green" />
<StatsCard title="Expense" :amount="totalExpense" icon="pi pi-arrow-up-right" color="red" /> <div class="col-span-2">
<StatsCard title="Total Balance" :amount="dashboardData.balance" icon="pi pi-wallet" color="blue" />
</div>
</div>
<div class="flex flex-col card">
<span class="text-xl !font-semibold pl-1 mb-2">AI Summary</span>
<div v-if="dashboardData.analyzedText" class="ai-summary-wrapper">
<div :class="['ai-summary-content', { 'expanded': isAiSummaryExpanded }]" class="">
<div class="flex flex-col gap-0">
<span class="text-lg bold">Общая оценка</span>
<span class="!whitespace-pre-wrap" v-html="dashboardData.analyzedText.common" />
</div>
<div class="h-4" />
<div class="flex flex-col gap-0">
<span class="text-lg bold">Анализ категорий</span>
<span class="!whitespace-pre-wrap" v-html="dashboardData.analyzedText.categoryAnalysis" />
</div>
<div class="h-4" />
<div class="flex flex-col gap-0">
<span class="text-lg bold">Ключевые инсайты</span>
<span class="!whitespace-pre-wrap" v-html="dashboardData.analyzedText.keyInsights" />
</div>
<div class="h-4" />
<div class="flex flex-col gap-0">
<span class="text-lg bold">Рекомендации</span>
<span class="!whitespace-pre-wrap" v-html="dashboardData.analyzedText.recommendations" />
</div>
</div>
<div v-if="!isAiSummaryExpanded" class="ai-summary-fade"></div>
</div>
<div v-else
class="flex flex-col items-center justify-center p-8 text-center bg-gray-50 dark:bg-gray-800 rounded-lg">
<i class="pi pi-sparkles text-4xl text-blue-400 mb-3"></i>
<span class="text-lg text-gray-500 font-medium">To see AI Analysis please add some comments to your transactions
</span>
</div>
<Button v-if="dashboardData.analyzedText" :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-2 gap-4"> <div class="grid grid-cols-1 lg:grid-cols-1 gap-4">
<!-- Charts --> <!-- Charts -->
<div <DashboardCharts :categories="dashboardData.categories" :transactions="dashboardTransactions"
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700"> :weeks="dashboardData.weeks" :startDate="currentStartDate" :endDate="currentEndDate" />
<h2 class="text-lg font-semibold mb-4 text-surface-900 dark:text-surface-0">Expenses by Category</h2>
<DashboardCharts :transactions="transactionStore.transactions" />
</div>
<!-- Upcoming Transactions --> <!-- Upcoming Transactions -->
<div class=" "> <div class=" ">
<div class="flex items-center justify-between mb-4"> <UpcomingTransactions :transactions="plannedTransactions" @set-tx-done="setTransactionDone" />
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-0">Upcoming Payments</h2>
<router-link to="/transactions" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">View
all</router-link>
</div>
<UpcomingTransactions :transactions="transactionStore.plannedTransactions" />
</div> </div>
</div> </div>
<!-- Recent Transactions --> <!-- Recent Transactions -->
<RecentTransactions :transactions="transactionStore.transactions" /> <RecentTransactions :transactions="dashboardTransactions" />
</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;
}
b {
font-weight: bold !important;
}
.expand-button:active {
transform: translateY(0);
}
</style>

View File

@@ -1,16 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { Transaction } from '@/models/transaction'; import { Transaction } from '@/models/transaction';
import { computed } from 'vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { formatAmount } from '@/utils/utils'; import { formatAmount } from '@/utils/utils';
import { useRouter } from 'vue-router';
import Divider from 'primevue/divider';
const router = useRouter();
const props = defineProps<{ const props = defineProps<{
transactions: Transaction[]; transactions: Transaction[];
}>(); }>();
const recentTransactions = computed(() => {
return props.transactions.slice(0, 5);
});
const formatDate = (date: string | Date) => { const formatDate = (date: string | Date) => {
return dayjs(date).format('MMM D'); return dayjs(date).format('MMM D');
@@ -29,41 +30,41 @@ const getAmountColor = (type: string) => {
</script> </script>
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-1">
<div class="flex items-center justify-between px-1"> <div class="flex flex-row justify-between items-end px-2">
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-0">Recent Transactions</h3> <span class="text-xl !font-semibold ">Recent transactions</span>
<router-link to="/transactions" class="text-sm text-primary-600 dark:text-primary-400 hover:underline"> <router-link to="/transactions" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">View
View All all</router-link>
</router-link>
</div> </div>
<div class="flex card">
<div v-if="recentTransactions.length === 0" class="text-center py-8 text-surface-500 dark:text-surface-400"> <span v-if="transactions.length == 0">Looks like you haven't record any transaction yet.<router-link
No recent transactions to="/transactions/create" class="!text-blue-400">Try to create some.</router-link></span>
</div> <div v-else v-for="key in transactions.keys()" :key="transactions[key].id"
@click="router.push(`/transactions/${transactions[key].id}/edit`)"
<div v-else class="flex flex-col gap-3"> class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center font-bold ">
<div v-for="tx in recentTransactions" :key="tx.id" <div class="flex flex-row w-full items-center justify-between">
class="flex items-center justify-between p-3 bg-white dark:bg-surface-900 rounded-xl border border-surface-100 dark:border-surface-800 shadow-sm"> <div class="flex flex-row items-center gap-2 ">
<div class="flex items-center gap-3 w-full"> <span v-if="transactions[key].category" class="text-3xl"> {{
<div transactions[key].category.icon
class="w-10 h-10 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-xl p-4">
{{ tx.category.icon }}
</div>
<div class="flex flex-col w-full">
<span class="font-medium text-surface-900 dark:text-surface-0 w-fit">{{ tx.comment }}</span>
<span class="text-xs text-surface-500 dark:text-surface-400">{{ tx.category.name }} | {{ formatDate(tx.date)
}}</span> }}</span>
</div> <i v-else class="pi pi-question !text-3xl" />
</div> <div class="flex flex-col !font-bold "> {{ transactions[key].comment }}
<div class="flex flex-col items-end w-full"> <div v-if="transactions[key].category" class="flex flex-row text-sm">
<span :class="['!font-semibold', getAmountColor(tx.type)]"> {{ transactions[key].category.name }}
{{ tx.type === 'EXPENSE' ? '-' : '+' }}{{ formatAmount(tx.amount) }}
</span>
<!-- <span v-if="tx.comment" class="text-xs text-surface-500 dark:text-surface-400 truncate !w-fit">
{{ tx.comment }}
</span> -->
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-row gap-2 items-center">
<div class="flex flex-col justify-between items-end !w-fit whitespace-nowrap shrink-0">
<span class="text-lg !font-bold ">{{ formatAmount(transactions[key].amount) }} </span>
<span class="text-sm !font-extralight"> {{ formatDate(transactions[key].date) }}</span>
</div> </div>
<i class="pi pi-angle-right !font-extralight" />
</div>
</div>
<Divider v-if="key + 1 !== transactions.length" class="!m-0 !py-3" />
</div>
</div>
</div>
</template> </template>

View File

@@ -19,13 +19,14 @@ const formatCurrency = (value: number, currency = 'RUB') => {
<div <div
class="bg-white dark:bg-surface-900 rounded-2xl p-4 shadow-sm flex flex-col gap-2 border border-surface-100 dark:border-surface-800"> class="bg-white dark:bg-surface-900 rounded-2xl p-4 shadow-sm flex flex-col gap-2 border border-surface-100 dark:border-surface-800">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div :class="`p-2 rounded-xl bg-${color}-50 dark:bg-${color}-500/10 text-${color}-500`"> <div :class="`p-2 rounded-xl !bg-${color}-50 !dark:bg-${color}-500/10 !text-${color}-500`">
<i :class="icon" class="text-xl"></i> <i :class="icon" class="text-xl"></i>
</div> </div>
<span class="text-surface-500 dark:text-surface-400 font-medium text-sm">{{ title }}</span> <span class="text-surface-500 dark:text-surface-400 font-medium text-sm">{{ title }}</span>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-2xl font-bold text-surface-900 dark:text-surface-0"> <span class="!text-2xl !font-bold text-surface-900 dark:text-surface-0"
:class="amount < 0 ? '!text-red-500' : ''">
{{ formatCurrency(amount, currency) }} {{ formatCurrency(amount, currency) }}
</span> </span>
</div> </div>

View File

@@ -3,10 +3,24 @@ import { Transaction } from '@/models/transaction';
import { computed } from 'vue'; import { computed } from 'vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { formatAmount } from '@/utils/utils'; import { formatAmount } from '@/utils/utils';
import { Checkbox } from 'primevue';
import { Divider } from 'primevue';
import { useRouter } from 'vue-router';
import { UpdateTransactionDTO } from '@/models/transaction';
import { useSpaceStore } from '@/stores/spaceStore';
import { TransactionService } from '@/services/transactions-service';
import { useToast } from 'primevue';
const toast = useToast()
const spaceStore = useSpaceStore()
const router = useRouter()
const props = defineProps<{ const props = defineProps<{
transactions: Transaction[]; transactions: Transaction[];
}>(); }>();
const emits = defineEmits(["set-tx-done"])
const upcomingTransactions = computed(() => { const upcomingTransactions = computed(() => {
return props.transactions return props.transactions
@@ -29,40 +43,71 @@ const getDaysUntilText = (date: string | Date) => {
const getAmountColor = (type: string) => { const getAmountColor = (type: string) => {
return type === 'INCOME' ? '!text-green-600 !dark:text-green-400' : '!text-red-600 !dark:text-red-400'; return type === 'INCOME' ? '!text-green-600 !dark:text-green-400' : '!text-red-600 !dark:text-red-400';
}; };
const setTransactionDone = async (transaction: Transaction): Promise<void> => {
const updateTransaction = {
type: transaction.type,
kind: transaction.kind,
categoryId: transaction.category.id,
comment: transaction.comment,
amount: transaction.amount,
fees: 0,
isDone: transaction.isDone,
date: new Date(transaction.date),
} as UpdateTransactionDTO
try {
await TransactionService.updateTransaction(spaceStore.selectedSpaceId as number, transaction.id, updateTransaction)
} catch (error) {
toast.add({
severity: 'error',
summary: 'Failed to update transactions.',
detail: String(error),
life: 3000,
})
}
}
</script> </script>
<template> <template>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-0 ">
<div v-if="upcomingTransactions.length === 0" class="text-center py-8 text-surface-500 dark:text-surface-400"> <div class="flex flex-row justify-between items-end px-2">
No upcoming planned transactions <span class="text-xl !font-semibold ">Upcoming transactions</span>
<router-link to="/transactions" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">View
all</router-link>
</div> </div>
<div class="flex card">
<div v-else class="flex flex-col gap-3"> <span v-if="transactions.length == 0">Looks like you haven't plan any transactions yet. <router-link
<div to="/transactions/create" class="!text-blue-400">Try to create some.</router-link></span>
v-for="tx in upcomingTransactions" <div v-else v-for="key in transactions.keys()" :key="transactions[key].id"
:key="tx.id" class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center font-bold "
class="flex items-center justify-between p-3 bg-white dark:bg-surface-900 rounded-xl border border-surface-100 dark:border-surface-800 shadow-sm" @click="router.push(`/transactions/${transactions[key].id}/edit`)">
> <div class="flex flex-row w-full items-center gap-4">
<div class="flex items-center gap-3 w-full"> <Checkbox v-model="transactions[key].isDone" binary class="text-3xl"
<div class="w-10 h-10 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-xl p-4"> @change="setTransactionDone(transactions[key])">
{{ tx.category.icon }} {{ transactions[key].category.icon }}
</div> </Checkbox>
<div class="flex flex-col w-full"> <div class="flex !flex-row !justify-between !w-full"
<span class="font-medium text-surface-900 dark:text-surface-0 w-full">{{ tx.comment }}</span> @click="router.push(`/transactions/${transactions[key].id}/edit`)">
<div class="flex items-center gap-1 text-xs"> <div class="flex flex-row items-center gap-2">
<span class="text-primary-500 font-medium">{{ getDaysUntilText(tx.date) }}</span> <div class="flex flex-col !font-bold "> {{ transactions[key].comment }}
<span class="text-surface-500 dark:text-surface-400">({{ formatDate(tx.date) }})</span> <div class="flex flex-row text-sm">{{ transactions[key].category.icon }}
{{ transactions[key].category.name }}
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col items-end w-full"> <div class="flex flex-row gap-2 items-center !w-fit">
<span :class="['!font-semibold', getAmountColor(tx.type)]"> <div class="flex flex-col justify-between items-end !w-fit whitespace-nowrap shrink-0">
{{ tx.type === 'EXPENSE' ? '-' : '+' }}{{ formatAmount(tx.amount) }} <span class="text-lg !font-bold">{{ formatAmount(transactions[key].amount) }} </span>
</span> <span class="text-sm !font-extralight"> {{ formatDate(transactions[key].date) }} {{
<!-- <span v-if="tx.comment" class="text-xs text-surface-500 dark:text-surface-400 truncate !w-fit"> getDaysUntilText(transactions[key].date) }}</span>
{{ tx.comment }}
</span> -->
</div> </div>
<i class="pi pi-angle-right !font-extralight" />
</div>
</div>
</div>
<Divider v-if="key + 1 !== transactions.length" class="!m-0 !py-3" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,14 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import {useSpaceStore} from "@/stores/spaceStore"; import { useSpaceStore } from "@/stores/spaceStore";
import {onMounted, ref} from "vue"; import { onMounted, ref, watch } from "vue";
import {Checkbox, Divider} from "primevue"; import { Checkbox, Divider, IconField, InputIcon, InputText, Drawer, DatePicker, Button } from "primevue";
import {useToast} from "primevue/usetoast"; import { useToast } from "primevue/usetoast";
import {Transaction, UpdateTransactionDTO} from "@/models/transaction"; import { Transaction, UpdateTransactionDTO } from "@/models/transaction";
import {TransactionFilters, TransactionService} from "@/services/transactions-service"; import { TransactionFilters, TransactionService } from "@/services/transactions-service";
import {formatAmount, formatDate} from "@/utils/utils"; import { formatAmount, formatDate, toDateOnly } from "@/utils/utils";
import {useRouter} from "vue-router"; import { useRouter } from "vue-router";
import {TransactionKind} from "@/models/enums"; import { TransactionKind } from "@/models/enums";
import {useToolbarStore} from "@/stores/toolbar-store"; import { useToolbarStore } from "@/stores/toolbar-store";
import { Category } from "@/models/category";
import { categoriesService } from "@/services/categories-service";
const toast = useToast(); const toast = useToast();
const router = useRouter(); const router = useRouter();
@@ -16,6 +19,53 @@ const spaceStore = useSpaceStore()
const toolbar = useToolbarStore() const toolbar = useToolbarStore()
const transactionService = TransactionService const transactionService = TransactionService
const searchQuery = ref<string>("")
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const isFilterSheetVisible = ref(false)
const filterDateFrom = ref<Date | null>(null)
const filterDateTo = ref<Date | null>(null)
const availableCategories = ref<Category[]>([])
const selectedCategoryIds = ref<number[]>([])
const fetchCategories = async () => {
if (spaceStore.selectedSpaceId) {
availableCategories.value = await categoriesService.fetchCategories(spaceStore.selectedSpaceId)
}
}
const toggleCategorySelection = (categoryId: number) => {
const index = selectedCategoryIds.value.indexOf(categoryId)
if (index === -1) {
selectedCategoryIds.value.push(categoryId)
} else {
selectedCategoryIds.value.splice(index, 1)
}
}
const applyFilters = () => {
plannedOffset.value = 0
instantOffset.value = 0
fetchData(true, true, true)
isFilterSheetVisible.value = false
}
const resetFilters = () => {
filterDateFrom.value = null
filterDateTo.value = null
selectedCategoryIds.value = []
applyFilters()
}
watch(searchQuery, () => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
plannedOffset.value = 0
instantOffset.value = 0
fetchData(true, true, true)
}, 500) // 500ms debounce
})
const showIsDone = ref(false) const showIsDone = ref(false)
const setTransactionDone = async (transaction: Transaction): Promise<void> => { const setTransactionDone = async (transaction: Transaction): Promise<void> => {
@@ -73,6 +123,11 @@ const fetchData = async (fetchPlanned: boolean = true, fetchInstant: boolean = t
isDone: showIsDone.value ? undefined : false, isDone: showIsDone.value ? undefined : false,
offset: plannedOffset.value, offset: plannedOffset.value,
limit: plannedLimit.value, limit: plannedLimit.value,
query: searchQuery.value || null,
categoriesIds: selectedCategoryIds.value.length > 0 ? selectedCategoryIds.value : null,
dateFrom: filterDateFrom.value ? toDateOnly(filterDateFrom.value) : null,
dateTo: filterDateTo.value ? toDateOnly(filterDateTo.value) : null,
sorts: [{ "sortBy": "date", "sortDirection": "ASC" }]
} as TransactionFilters) // никаких `as TransactionFilters`, если поля опциональные } as TransactionFilters) // никаких `as TransactionFilters`, если поля опциональные
: Promise.resolve(plannedTransactions.value) : Promise.resolve(plannedTransactions.value)
@@ -82,6 +137,10 @@ const fetchData = async (fetchPlanned: boolean = true, fetchInstant: boolean = t
kind: TransactionKind.INSTANT, kind: TransactionKind.INSTANT,
offset: instantOffset.value, offset: instantOffset.value,
limit: instantLimit.value, limit: instantLimit.value,
query: searchQuery.value || null,
categoriesIds: selectedCategoryIds.value.length > 0 ? selectedCategoryIds.value : null,
dateFrom: filterDateFrom.value ? toDateOnly(filterDateFrom.value) : null,
dateTo: filterDateTo.value ? toDateOnly(filterDateTo.value) : null,
} as TransactionFilters) } as TransactionFilters)
: Promise.resolve(instantTransactions.value) : Promise.resolve(instantTransactions.value)
@@ -115,6 +174,7 @@ const fetchData = async (fetchPlanned: boolean = true, fetchInstant: boolean = t
onMounted(async () => { onMounted(async () => {
await fetchCategories()
await fetchData() await fetchData()
toolbar.registerHandler('openTransactionCreation', () => { toolbar.registerHandler('openTransactionCreation', () => {
router.push('/transactions/create') router.push('/transactions/create')
@@ -129,15 +189,23 @@ onMounted(async () => {
</div> </div>
<div v-else class="flex flex-col gap-6 pb-10"> <div v-else class="flex flex-col gap-6 pb-10">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="card !w-full flex !flex-row gap-2">
<IconField iconPosition="left" class="!w-full !bg-white">
<InputIcon class="pi pi-search"> </InputIcon>
<InputText v-model="searchQuery" placeholder="Search" class="!w-full !bg-white !text-left" />
</IconField>
<Button icon="pi pi-filter" @click="isFilterSheetVisible = true" text rounded aria-label="Filter" />
</div>
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
<span class="text-xl !font-semibold !pl-2">Planned transactions</span> <span class="text-xl !font-semibold !pl-2">Planned transactions</span>
<div class="flex flex-row gap-2 items-center"> <div class="flex flex-row gap-2 items-center">
<Checkbox v-model="showIsDone" binary value=" Показывать выполненные" @change="plannedOffset = 0;fetchData(true, false, true)"/> <Checkbox v-model="showIsDone" binary value=" Показывать выполненные"
<span class="!text-sm">Выполненные</span> @change="plannedOffset = 0; fetchData(true, false, true)" />
<span class="text-sm">Выполненные</span>
</div> </div>
</div> </div>
<div class="flex card"> <div class="flex card">
<span v-if="plannedTransactions.length==0">Looks like you haven't plan any transactions yet. <router-link <span v-if="plannedTransactions.length == 0">Looks like you haven't plan any transactions yet. <router-link
to="/transactions/create" class="!text-blue-400">Try to create some.</router-link></span> to="/transactions/create" class="!text-blue-400">Try to create some.</router-link></span>
<div v-else v-for="key in plannedTransactions.keys()" :key="plannedTransactions[key].id" <div v-else v-for="key in plannedTransactions.keys()" :key="plannedTransactions[key].id"
class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center font-bold "> class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center font-bold ">
@@ -160,19 +228,61 @@ onMounted(async () => {
<span class="text-lg !font-bold">{{ formatAmount(plannedTransactions[key].amount) }} </span> <span class="text-lg !font-bold">{{ formatAmount(plannedTransactions[key].amount) }} </span>
<span class="text-sm !font-extralight"> {{ formatDate(plannedTransactions[key].date) }}</span> <span class="text-sm !font-extralight"> {{ formatDate(plannedTransactions[key].date) }}</span>
</div> </div>
<i class="pi pi-angle-right !font-extralight"/> <i class="pi pi-angle-right !font-extralight" />
</div> </div>
</div> </div>
</div> </div>
<Divider v-if="key+1 !== plannedTransactions.length" class="!m-0 !py-3"/> <Divider v-if="key + 1 !== plannedTransactions.length" class="!m-0 !py-3" />
</div> </div>
</div> </div>
<button v-if="!plannedLastBatch" class="card w-fit " @click="fetchMorePlanned">Load more...</button> <button v-if="!plannedLastBatch" class="card w-fit " @click="fetchMorePlanned">Load more...</button>
</div> </div>
<!-- Filter Sheet -->
<Drawer v-model:visible="isFilterSheetVisible" position="bottom" style="height: auto" :modal="true"
:dismissable="true" :showCloseIcon="false">
<div class="flex flex-col gap-4 pb-6">
<div class="flex justify-between items-center px-4 pt-2">
<span class="text-xl font-bold">Filter Transactions</span>
<Button icon="pi pi-times" text rounded @click="isFilterSheetVisible = false" />
</div>
<div class="flex flex-col px-4 gap-2">
<span class="font-semibold text-gray-600">Period</span>
<div class="flex flex-row gap-2 w-full">
<div class="flex flex-col w-1/2">
<label class="text-sm text-gray-500 mb-1">From</label>
<DatePicker v-model="filterDateFrom" showIcon fluid :maxDate="filterDateTo || undefined" />
</div>
<div class="flex flex-col w-1/2">
<label class="text-sm text-gray-500 mb-1">To</label>
<DatePicker v-model="filterDateTo" showIcon fluid :minDate="filterDateFrom || undefined" />
</div>
</div>
</div>
<div class="flex flex-col px-4 gap-2">
<span class="font-semibold text-gray-600">Categories</span>
<div class="flex flex-wrap gap-2">
<div v-for="cat in availableCategories" :key="cat.id" @click="toggleCategorySelection(cat.id)"
:class="['px-3 py-2 rounded-full border cursor-pointer transition-colors flex items-center gap-2',
selectedCategoryIds.includes(cat.id) ? 'bg-blue-100 border-blue-500 text-blue-700' : 'bg-white border-gray-200 hover:bg-gray-50']">
<span>{{ cat.icon }}</span>
<span class="text-sm">{{ cat.name }}</span>
</div>
</div>
</div>
<div class="flex flex-row gap-3 px-4 pt-4">
<Button label="Reset" severity="secondary" @click="resetFilters" class="w-1/3" outlined />
<Button label="Apply Filters" @click="applyFilters" class="w-2/3" />
</div>
</div>
</Drawer>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="text-xl !font-semibold !pl-2">Instant transactions</span> <span class="text-xl !font-semibold !pl-2">Instant transactions</span>
<div class="flex card"> <div class="flex card">
<span v-if="instantTransactions.length==0">Looks like you haven't record any transaction yet.<router-link <span v-if="instantTransactions.length == 0">Looks like you haven't record any transaction yet.<router-link
to="/transactions/create" class="!text-blue-400">Try to create some.</router-link></span> to="/transactions/create" class="!text-blue-400">Try to create some.</router-link></span>
<div v-else v-for="key in instantTransactions.keys()" :key="instantTransactions[key].id" <div v-else v-for="key in instantTransactions.keys()" :key="instantTransactions[key].id"
@click="router.push(`/transactions/${instantTransactions[key].id}/edit`)" @click="router.push(`/transactions/${instantTransactions[key].id}/edit`)"
@@ -182,7 +292,7 @@ onMounted(async () => {
<span v-if="instantTransactions[key].category" class="text-3xl"> {{ <span v-if="instantTransactions[key].category" class="text-3xl"> {{
instantTransactions[key].category.icon instantTransactions[key].category.icon
}}</span> }}</span>
<i v-else class="pi pi-question !text-3xl"/> <i v-else class="pi pi-question !text-3xl" />
<div class="flex flex-col !font-bold "> {{ instantTransactions[key].comment }} <div class="flex flex-col !font-bold "> {{ instantTransactions[key].comment }}
<div v-if="instantTransactions[key].category" class="flex flex-row text-sm"> <div v-if="instantTransactions[key].category" class="flex flex-row text-sm">
{{ instantTransactions[key].category.name }} {{ instantTransactions[key].category.name }}
@@ -194,10 +304,10 @@ onMounted(async () => {
<span class="text-lg !font-bold ">{{ formatAmount(instantTransactions[key].amount) }} </span> <span class="text-lg !font-bold ">{{ formatAmount(instantTransactions[key].amount) }} </span>
<span class="text-sm !font-extralight"> {{ formatDate(instantTransactions[key].date) }}</span> <span class="text-sm !font-extralight"> {{ formatDate(instantTransactions[key].date) }}</span>
</div> </div>
<i class="pi pi-angle-right !font-extralight"/> <i class="pi pi-angle-right !font-extralight" />
</div> </div>
</div> </div>
<Divider v-if="key+1 !== instantTransactions.length" class="!m-0 !py-3"/> <Divider v-if="key + 1 !== instantTransactions.length" class="!m-0 !py-3" />
</div> </div>
</div> </div>
<button v-if="!instantLastBatch" class="card w-fit " @click="fetchMoreInstant">Load more...</button> <button v-if="!instantLastBatch" class="card w-fit " @click="fetchMoreInstant">Load more...</button>
@@ -207,6 +317,4 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped></style>
</style>

43
src/models/dashboard.ts Normal file
View File

@@ -0,0 +1,43 @@
import { Category } from "./category"
import { Transaction } from "./transaction"
export interface DashboardData {
totalExpense: number,
totalIncome: number,
balance: number,
categories: DashboardCategory[],
upcomingTransactions: Transaction[],
recentTransactions: Transaction[],
weeks: DashboardWeek[],
analyzedText?: AISummary,
}
export interface AISummary {
common: string,
categoryAnalysis: string,
keyInsights: string,
recommendations: string
}
export interface DashboardWeek {
startDate: Date,
endDate: Date,
expenseSum: number,
categories: WeekCategory[],
}
export interface WeekCategory {
categoryId: number,
categoryName: string,
categoryIcon: string
sum: number,
}
export interface DashboardCategory {
category: Category,
currentPeriodAmount: number,
previousPeriodAmount: number,
changeDiff: number,
changeDiffPercentage: number,
}

View File

@@ -24,6 +24,7 @@ declare module 'vue-router' {
// ⚙️ Ленивая загрузка компонентов (code-splitting) // ⚙️ Ленивая загрузка компонентов (code-splitting)
const SettingsList = () => import('@/components/settings/SettingsList.vue') const SettingsList = () => import('@/components/settings/SettingsList.vue')
const CategoriesList = () => import('@/components/settings/CategoriesList.vue') const CategoriesList = () => import('@/components/settings/CategoriesList.vue')
const AnalyticsView = () => import('@/views/AnalyticsView.vue')
const RecurrentsList = () => import('@/components/settings/RecurrentsList.vue') const RecurrentsList = () => import('@/components/settings/RecurrentsList.vue')
const SpaceSettings = () => import('@/components/settings/SpaceSettings.vue') const SpaceSettings = () => import('@/components/settings/SpaceSettings.vue')
const NotificationSettings = () => import('@/components/settings/NotificationSettings.vue') const NotificationSettings = () => import('@/components/settings/NotificationSettings.vue')
@@ -32,6 +33,7 @@ const NotificationSettings = () => import('@/components/settings/NotificationSet
export const RouteName = { export const RouteName = {
Login: 'login', Login: 'login',
Dashboard: 'dashboard', Dashboard: 'dashboard',
Analytics: 'analytics',
TransactionList: 'transaction-list', TransactionList: 'transaction-list',
TransactionCreate: 'transaction-create', TransactionCreate: 'transaction-create',
TransactionUpdate: 'transaction-update', TransactionUpdate: 'transaction-update',
@@ -59,6 +61,12 @@ const routes: RouteRecordRaw[] = [
component: DashboardView, component: DashboardView,
meta: { requiresAuth: true, navStack: 'dashboard', title: "Home" } meta: { requiresAuth: true, navStack: 'dashboard', title: "Home" }
}, },
{
path: '/analytics',
name: RouteName.Analytics,
component: AnalyticsView,
meta: { requiresAuth: true, navStack: 'analytics', title: "Analytics" }
},
{ {
path: '/transactions', path: '/transactions',
name: RouteName.TransactionList, name: RouteName.TransactionList,

View File

@@ -0,0 +1,19 @@
import api from "@/network/axiosSetup"
import { toDateOnly } from "@/utils/utils"
async function fetchDashboardData(spaceId: number, startDate: Date, endDate: Date) {
try {
const result = await api.get(`/spaces/${spaceId}/dashboard?startDate=${toDateOnly(startDate)}&endDate=${toDateOnly(endDate)}`)
return result.data
} catch (error) {
console.error(error)
throw error
}
}
export const DashboardService = {
fetchDashboardData
}

View File

@@ -1,7 +1,7 @@
import {CreateTransactionDTO, Transaction, UpdateTransactionDTO} from "@/models/transaction"; import { CreateTransactionDTO, Transaction, UpdateTransactionDTO } from "@/models/transaction";
import {TransactionKind, TransactionType} from "@/models/enums"; import { TransactionKind, TransactionType } from "@/models/enums";
import {categoriesService} from "@/services/categories-service"; import { categoriesService } from "@/services/categories-service";
import {User} from "@/models/user"; import { User } from "@/models/user";
import api from "@/network/axiosSetup"; import api from "@/network/axiosSetup";
// const spaceStore = useSpaceStore(); // const spaceStore = useSpaceStore();
@@ -12,21 +12,29 @@ function toDateOnly(d: Date): string {
return `${y}-${m}-${day}`; return `${y}-${m}-${day}`;
} }
export interface TransactionFilters{ export interface TransactionFilters {
type : TransactionType | null query: string | 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: { sortBy: string; sortDirection: string }[]
}
enum SortDirection {
ASC = "ASC",
DESC = 'DESC'
} }
async function getTransactions(spaceId: number, filters: TransactionFilters): Promise<Transaction[]> { async function getTransactions(spaceId: number, filters: TransactionFilters): Promise<Transaction[]> {
try { try {
let response = await api.post(`/spaces/${spaceId}/transactions/_search`, filters ); let response = await api.post(`/spaces/${spaceId}/transactions/_search`, filters);
return response.data; return response.data;
}catch (error) { } catch (error) {
console.error(error); console.error(error);
throw error; throw error;
} }

View File

@@ -89,3 +89,9 @@ export const getRandomColor = () => {
} }
export const toDateOnly = (d : Date): string => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}

322
src/views/AnalyticsView.vue Normal file
View File

@@ -0,0 +1,322 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useSpaceStore } from '@/stores/spaceStore';
import { DashboardService } from '@/services/dashboard-service';
import { DashboardData } from '@/models/dashboard';
import Chart from 'primevue/chart';
import Button from 'primevue/button';
import dayjs from 'dayjs';
import ProgressSpinner from 'primevue/progressspinner';
import { formatAmount } from '@/utils/utils';
const spaceStore = useSpaceStore();
const period = ref<'3m' | '6m'>('3m');
const monthlyData = ref<{ label: string; data: DashboardData }[]>([]);
const isLoading = ref(false);
const expandedMonths = ref<Set<string>>(new Set());
const toggleMonth = (label: string) => {
if (expandedMonths.value.has(label)) {
expandedMonths.value.delete(label);
} else {
expandedMonths.value.add(label);
}
}
const getSortedCategories = (categories: DashboardData['categories']) => {
return categories
.filter(c => c.category.type === 'EXPENSE' && c.currentPeriodAmount > 0)
.sort((a, b) => b.currentPeriodAmount - a.currentPeriodAmount);
}
const calculateMonthDateRange = (monthsBack: number) => {
const baseDate = dayjs().subtract(monthsBack, 'month');
const currentDay = dayjs().date();
// Logic from DashboardView:
// If current date is 1-9, the "current" fiscal month is actually the previous calendar month.
// e.g. Dec 5 -> Fiscal Nov (Nov 10 - Dec 9).
// We want to generate ranges going back from "current fiscal month".
// Let's establish "Fiscal Month 0" (Current).
// If today is Dec 11: Fiscal Month is Dec (Dec 10 - Jan 9).
// If today is Dec 5: Fiscal Month is Nov (Nov 10 - Dec 9).
let fiscalMonthStart = dayjs();
if (currentDay >= 1 && currentDay <= 9) {
fiscalMonthStart = fiscalMonthStart.subtract(1, 'month').date(10);
} else {
fiscalMonthStart = fiscalMonthStart.date(10);
}
// Now move back `monthsBack` times
const start = fiscalMonthStart.subtract(monthsBack, 'month');
const end = start.add(1, 'month').date(9);
return { startDate: start.toDate(), endDate: end.toDate() };
};
const fetchAnalyticsData = async () => {
if (!spaceStore.selectedSpaceId) return;
isLoading.value = true;
monthlyData.value = [];
const monthsToFetch = period.value === '3m' ? 3 : 6;
const requests = [];
for (let i = monthsToFetch - 1; i >= 0; i--) {
const { startDate, endDate } = calculateMonthDateRange(i);
const label = dayjs(startDate).format('MMMM'); // e.g. "November"
requests.push(
DashboardService.fetchDashboardData(spaceStore.selectedSpaceId, startDate, endDate)
.then(data => ({ label, data }))
);
}
try {
monthlyData.value = await Promise.all(requests);
} catch (e) {
console.error("Failed to fetch analytics data", e);
} finally {
isLoading.value = false;
}
};
const totalExpense = computed(() => {
return monthlyData.value.reduce((acc, curr) => acc + curr.data.totalExpense, 0);
});
const averageExpense = computed(() => {
// Avoid division by zero
if (monthlyData.value.length === 0) return 0;
return Math.round(totalExpense.value / monthlyData.value.length);
});
const aggregatedCategories = computed(() => {
const categoryMap = new Map<number, {
name: string,
icon: string,
totalAmount: number,
count: number
}>();
monthlyData.value.forEach(month => {
month.data.categories.forEach(cat => {
// Check if it's an expense category
// We can infer it if currentPeriodAmount > 0 and type logic
// Default logic from DashboardCharts: filter type === TransactionType.EXPENSE
// But here we rely on DashboardCategory data.
// Typically positive amount in DashboardCategory means it exists.
// Let's assume we filter expense categories (usually most relevant for budget).
// DashboardCategory has 'category.type'. Let's check it.
if (cat.category.type === 'EXPENSE') { // using string 'EXPENSE' as enum might not be imported or used directly here
const existing = categoryMap.get(cat.category.id);
if (existing) {
existing.totalAmount += cat.currentPeriodAmount;
existing.count += 1;
} else {
categoryMap.set(cat.category.id, {
name: cat.category.name,
icon: cat.category.icon,
totalAmount: cat.currentPeriodAmount,
count: 1
});
}
}
});
});
const categories = Array.from(categoryMap.values()).map(c => ({
...c,
averageAmount: Math.round(c.totalAmount / monthlyData.value.length), // Avg over the selected period (3 or 6 months)
percentage: totalExpense.value ? Math.round((c.totalAmount / totalExpense.value) * 100) : 0
}));
return categories.sort((a, b) => b.totalAmount - a.totalAmount);
});
const chartData = computed(() => {
const labels = monthlyData.value.map(d => d.label);
const expenses = monthlyData.value.map(d => d.data.totalExpense);
return {
labels,
datasets: [
{
label: 'Expenses',
data: expenses,
backgroundColor: '#ef4444',
borderRadius: 8
}
]
};
});
const chartOptions = {
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
grid: {
display: false
}
}
}
};
onMounted(() => {
fetchAnalyticsData();
});
watch(() => period.value, () => {
fetchAnalyticsData();
});
watch(() => spaceStore.selectedSpaceId, (newId) => {
if (newId) fetchAnalyticsData();
});
</script>
<template>
<div class="flex flex-col gap-6 w-full pb-20 px-2 lg:px-0">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-surface-900 dark:text-surface-0">Analytics</h1>
<div class="bg-surface-100 dark:bg-surface-800 p-1 rounded-lg inline-flex">
<button @click="period = '3m'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
:class="period === '3m' ? 'bg-white dark:bg-surface-700 shadow-sm !text-primary-600 !dark:text-primary-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200'">
3 Months
</button>
<button @click="period = '6m'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
:class="period === '6m' ? 'bg-white dark:bg-surface-700 shadow-sm !text-primary-600 !dark:text-primary-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200'">
6 Months
</button>
</div>
</div>
<div v-if="isLoading" class="flex justify-center py-10">
<ProgressSpinner />
</div>
<div v-else class="flex flex-col gap-6">
<!-- Aggregated Stats Cards -->
<div class="grid grid-cols-2 gap-4">
<div
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700">
<span class="text-surface-500 dark:text-surface-400 text-sm">Total Expenses</span>
<div class="text-xl font-bold text-surface-900 dark:text-surface-0 mt-1">
{{ formatAmount(totalExpense) }}
</div>
</div>
<div
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700">
<span class="text-surface-500 dark:text-surface-400 text-sm">Average Monthly</span>
<div class="text-xl font-bold text-surface-900 dark:text-surface-0 mt-1">
{{ formatAmount(averageExpense) }}
</div>
</div>
</div>
<!-- Chart -->
<div
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700">
<h2 class="text-lg font-semibold mb-4 text-surface-900 dark:text-surface-0">Expenses Overview</h2>
<div class="h-[300px] w-full">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-full w-full" />
</div>
</div>
<!-- Category Breakdown -->
<div class="flex flex-col gap-3">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-0">Top Categories</h2>
<div
class="bg-surface-0 dark:bg-surface-900 p-2 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 flex flex-col">
<div v-for="cat in aggregatedCategories" :key="cat.name"
class="flex items-center justify-between p-3 hover:bg-surface-50 dark:hover:bg-surface-800 rounded-lg transition-colors">
<div class="flex items-center gap-3">
<div class="text-2xl">{{ cat.icon }}</div>
<div class="flex flex-col">
<span class="font-medium text-surface-700 dark:text-surface-200">{{ cat.name }}</span>
<span class="text-xs text-surface-500">avg. {{ formatAmount(cat.averageAmount) }}
/mo</span>
</div>
</div>
<div class="flex flex-col items-end">
<span class="font-semibold text-surface-900 dark:text-surface-0">{{
formatAmount(cat.totalAmount) }} </span>
<span class="text-xs text-surface-500">{{ cat.percentage }}%</span>
</div>
</div>
</div>
</div>
<!-- Detailed Stats -->
<div class="flex flex-col gap-3">
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-0">Monthly Breakdown</h2>
<div v-for="item in monthlyData" :key="item.label" class="flex flex-col gap-2">
<div @click="toggleMonth(item.label)"
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 flex justify-between items-center cursor-pointer hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors">
<div class="flex items-center gap-2">
<i :class="expandedMonths.has(item.label) ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
class="text-surface-500 text-sm"></i>
<span class="font-medium text-lg text-surface-700 dark:text-surface-200">{{ item.label
}}</span>
</div>
<div class="flex flex-col items-end">
<span class="font-bold text-red-500">- {{ formatAmount(item.data.totalExpense) }} </span>
<span class="text-sm text-green-500">+ {{ formatAmount(item.data.totalIncome) }} </span>
</div>
</div>
<!-- Expanded Category List -->
<div v-if="expandedMonths.has(item.label)" class="pl-4 pr-2">
<div
class="bg-surface-0 dark:bg-surface-900 p-2 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 flex flex-col">
<div v-for="cat in getSortedCategories(item.data.categories)" :key="cat.category.id"
class="flex items-center justify-between p-2 hover:bg-surface-50 dark:hover:bg-surface-800 rounded-lg transition-colors text-sm">
<div class="flex items-center gap-3">
<div class="text-xl">{{ cat.category.icon }}</div>
<span class="font-medium text-surface-700 dark:text-surface-200">{{
cat.category.name }}</span>
</div>
<div class="font-semibold text-surface-900 dark:text-surface-0">
{{ formatAmount(cat.currentPeriodAmount) }}
</div>
</div>
<div v-if="getSortedCategories(item.data.categories).length === 0"
class="p-2 text-center text-surface-500">
No expenses this month
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
```