Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
|
||||
ssh root@213.226.71.138 "
|
||||
cd /root/luminic/space/app &&
|
||||
ssh root@31.59.58.220 "
|
||||
cd /root/luminic/app/front &&
|
||||
git pull &&
|
||||
npm run build &&
|
||||
cp -r dist/* /var/www/app.luminic.space/
|
||||
|
||||
50
src/App.vue
50
src/App.vue
@@ -30,6 +30,7 @@ const isSpaceSelected = computed(
|
||||
|
||||
const menu = [
|
||||
{ name: "Dashboard", icon: "pi pi-chart-bar", link: "/", navStack: 'dashboard' },
|
||||
{ name: "Analytics", icon: "pi pi-chart-line", link: "/analytics", navStack: 'analytics' },
|
||||
{ name: "Transactions", icon: "pi pi-list", link: "/transactions", navStack: 'transactions' },
|
||||
{ name: "Settings", icon: "pi pi-cog", link: "/settings", navStack: 'settings' },
|
||||
];
|
||||
@@ -44,23 +45,33 @@ let backHandler: (() => void) | null = null;
|
||||
function setupBackButton() {
|
||||
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();
|
||||
|
||||
// снять старый обработчик
|
||||
if (backHandler) tgApp.BackButton.offClick(backHandler);
|
||||
|
||||
// навесить новый
|
||||
backHandler = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
tgApp.BackButton.hide();
|
||||
tgApp.close();
|
||||
}
|
||||
};
|
||||
|
||||
tgApp.BackButton.onClick(backHandler);
|
||||
} else {
|
||||
tgApp.BackButton.hide();
|
||||
// tgApp.BackButton.show();
|
||||
|
||||
backHandler = () => {
|
||||
tgApp.close();
|
||||
};
|
||||
|
||||
tgApp.BackButton.onClick(backHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,27 +169,16 @@ onBeforeUnmount(() => {
|
||||
<div class="flex flex-col w-full h-full items-end px-4 gap-4 pb-6">
|
||||
<router-view class=" w-full" />
|
||||
</div>
|
||||
<button
|
||||
v-if="isInputFocused"
|
||||
@click="blurAllInputs"
|
||||
class="fixed bottom-4 right-4 z-50 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg"
|
||||
>
|
||||
<button v-if="isInputFocused" @click="blurAllInputs"
|
||||
class="fixed bottom-4 right-4 z-50 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
Готово
|
||||
</button>
|
||||
<nav v-if="isNavVisible"
|
||||
class="fixed inset-x-0 bottom-4 z-50 w-full flex justify-center items-center "
|
||||
style="padding-bottom: var(--tg-content-safe-area-inset-bottom) !important;"
|
||||
>
|
||||
|
||||
<div class="flex h-full items-center justify-between py-2 bg-white rounded-4xl px-6 w-fit shadow">
|
||||
<!-- <div class="flex h-full justify-items-center items-center justify-between py-2 bg-white rounded-4xl !px-6 w-fit">-->
|
||||
<router-link
|
||||
v-for="item in menu"
|
||||
:key="item.link"
|
||||
:to="item.link"
|
||||
<nav v-if="isNavVisible" class="fixed bottom-4 left-1/2 -translate-x-1/2 z-50"
|
||||
style="padding-bottom: var(--tg-content-safe-area-inset-bottom) !important;">
|
||||
<div class="flex items-center justify-between py-2 bg-white rounded-4xl px-6 shadow">
|
||||
<router-link v-for="item in menu" :key="item.link" :to="item.link"
|
||||
class="flex w-fit h-full flex-col items-center gap-2 !py-2 !px-4"
|
||||
:class="route.meta.navStack === item.navStack ? 'bg-green-100 rounded-2xl ' : ''"
|
||||
>
|
||||
:class="route.meta.navStack === item.navStack ? 'bg-green-100 rounded-2xl ' : ''">
|
||||
<i class="!text-lg" :class="item.icon" />
|
||||
<span class="font-medium text-gray-900">{{ item.name }}</span>
|
||||
</router-link>
|
||||
|
||||
@@ -164,7 +164,7 @@ body {
|
||||
width: 100% !important;
|
||||
background-color: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
font-family: 'Inter', sans-serif;
|
||||
/* font-family: 'Inter', sans-serif; */
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
@@ -181,6 +181,18 @@ body {
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.p-button-rounded{
|
||||
color: gray !important;
|
||||
background-color: white !important;
|
||||
}
|
||||
.p-button-rounded:hover{
|
||||
color: white !important;
|
||||
background-color: lightgray !important;
|
||||
}
|
||||
.p-menu .p-menuitem{
|
||||
color: gray !important;
|
||||
}
|
||||
|
||||
|
||||
.p-menu .p-menuitem:hover {
|
||||
background-color: var(--menu-item-hover-bg-color) !important;
|
||||
@@ -200,7 +212,4 @@ body {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
span, a, i {
|
||||
color: var(--text-color) !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
128
src/components/dashboard/CategoryTransactionsModal.vue
Normal file
128
src/components/dashboard/CategoryTransactionsModal.vue
Normal 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>
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import Chart from 'primevue/chart';
|
||||
import Button from 'primevue/button';
|
||||
import CategoryTransactionsModal from './CategoryTransactionsModal.vue';
|
||||
import { DashboardCategory, DashboardWeek } from '@/models/dashboard';
|
||||
import { Transaction } from '@/models/transaction';
|
||||
import { TransactionType } from '@/models/enums';
|
||||
import { formatAmount } from '@/utils/utils';
|
||||
@@ -10,39 +13,57 @@ import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
const props = defineProps<{
|
||||
categories: DashboardCategory[];
|
||||
transactions: Transaction[];
|
||||
weeks: DashboardWeek[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}>();
|
||||
|
||||
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 ---
|
||||
const categoryData = computed(() => {
|
||||
const categoryMap = new Map<string, number>();
|
||||
|
||||
expenses.value.forEach(t => {
|
||||
const categoryName = t.category?.name || 'Uncategorized';
|
||||
const currentAmount = categoryMap.get(categoryName) || 0;
|
||||
categoryMap.set(categoryName, currentAmount + t.amount);
|
||||
});
|
||||
|
||||
return Array.from(categoryMap.entries())
|
||||
.map(([name, amount]) => ({ name, amount }))
|
||||
return expenses.value
|
||||
.map(c => ({
|
||||
...c,
|
||||
name: c.category.name,
|
||||
amount: c.currentPeriodAmount,
|
||||
}))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
});
|
||||
|
||||
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(() => {
|
||||
return categoryData.value.slice(0, 5).map(cat => ({
|
||||
return categoryData.value.map(cat => ({
|
||||
...cat,
|
||||
percentage: totalExpenseAmount.value ? Math.round((cat.amount / totalExpenseAmount.value) * 100) : 0
|
||||
}));
|
||||
});
|
||||
|
||||
const displayedCategories = computed(() => {
|
||||
return isCategoriesExpanded.value ? topCategories.value : topCategories.value.slice(0, 5);
|
||||
});
|
||||
|
||||
const categoryChartData = computed(() => {
|
||||
const labels = categoryData.value.map(c => c.name);
|
||||
const data = categoryData.value.map(c => c.amount);
|
||||
@@ -84,53 +105,14 @@ const getCategoryColor = (index: number) => {
|
||||
};
|
||||
|
||||
// --- 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(() => {
|
||||
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: [
|
||||
{
|
||||
label: 'Expenses',
|
||||
data: weeklyData.value.map(w => w.amount),
|
||||
data: props.weeks.map(w => w.expenseSum),
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 8
|
||||
}
|
||||
@@ -163,18 +145,24 @@ const weeklyChartOptions = computed(() => {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
|
||||
<!-- Chart Switcher -->
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-surface-100 dark:bg-surface-800 p-1 rounded-lg inline-flex">
|
||||
<button @click="chartType = 'category'"
|
||||
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
|
||||
</button>
|
||||
<button @click="chartType = 'weekly'"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
@@ -183,51 +171,79 @@ const weeklyChartOptions = computed(() => {
|
||||
<div class="flex flex-col md:flex-row items-start gap-8">
|
||||
<!-- Chart Area -->
|
||||
<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"
|
||||
class="w-full max-w-[250px]" />
|
||||
<Chart v-if="chartType === 'category'" type="doughnut" :data="categoryChartData"
|
||||
:options="categoryChartOptions" class="w-full max-w-[250px]" />
|
||||
<Chart v-else type="bar" :data="weeklyChartData" :options="weeklyChartOptions" class="w-full h-full" />
|
||||
</div>
|
||||
|
||||
<!-- Legend / Details Area -->
|
||||
<div class="w-full md:w-1/2 flex flex-col gap-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div class="w-full md:w-1/2 flex flex-col gap-3">
|
||||
<template v-if="chartType === 'category'">
|
||||
<div v-for="(category, index) in topCategories" :key="category.name"
|
||||
class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors">
|
||||
<div class="categories-wrapper">
|
||||
<div :class="['categories-content', { 'expanded': isCategoriesExpanded }]">
|
||||
<div v-for="(category, index) in displayedCategories" :key="category.name"
|
||||
class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors cursor-pointer"
|
||||
@click="openCategoryModal(category)">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full" :class="getCategoryColor(index)"></div>
|
||||
<span class="font-medium text-surface-700 dark:text-surface-200">{{ category.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ formatAmount(category.amount) }}
|
||||
₽</span>
|
||||
<span class="text-sm text-surface-500 dark:text-surface-400 w-10 text-right">{{ category.percentage
|
||||
}}%</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
|
||||
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 v-if="!isCategoriesExpanded && topCategories.length > 5" class="categories-fade"></div>
|
||||
</div>
|
||||
<Button v-if="topCategories.length > 5" :label="isCategoriesExpanded ? 'Show less' : 'Show more'"
|
||||
:icon="isCategoriesExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" class="expand-button"
|
||||
@click="isCategoriesExpanded = !isCategoriesExpanded" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<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">
|
||||
<span class="font-medium text-surface-700 dark:text-surface-200">{{ week.label }}</span>
|
||||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ formatAmount(week.amount) }}
|
||||
<span class="font-medium text-surface-700 dark:text-surface-200">{{
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Transactions 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 v-for="tx in week.transactions.slice(0, 5)" :key="tx.id"
|
||||
<!-- 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 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">
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<span class="text-lg">{{ tx.category.icon }}</span>
|
||||
<span class="text-surface-600 dark:text-surface-300 truncate">{{ tx.category.name }}</span>
|
||||
<span class="text-lg">{{ cat.categoryIcon }}</span>
|
||||
<span class="text-surface-600 dark:text-surface-300 truncate">{{ cat.categoryName }}</span>
|
||||
</div>
|
||||
<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 v-if="week.transactions.length > 5" class="text-xs text-surface-400 pl-2 pt-1">
|
||||
+{{ week.transactions.length - 5 }} more
|
||||
<div v-if="week.categories.length > 5" class="text-xs text-surface-400 pl-2 pt-1">
|
||||
+{{ week.categories.length - 5 }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,6 +252,13 @@ const weeklyChartOptions = computed(() => {
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
@@ -255,4 +278,63 @@ const weeklyChartOptions = computed(() => {
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--surface-700);
|
||||
}
|
||||
|
||||
.categories-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.categories-content {
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.categories-content.expanded {
|
||||
max-height: 2000px;
|
||||
}
|
||||
|
||||
.categories-fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80px;
|
||||
background: linear-gradient(to bottom,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.7) 50%,
|
||||
rgba(255, 255, 255, 0.95) 100%);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .categories-fade {
|
||||
background: linear-gradient(to bottom,
|
||||
transparent 0%,
|
||||
rgba(24, 24, 27, 0.7) 50%,
|
||||
rgba(24, 24, 27, 0.95) 100%);
|
||||
}
|
||||
|
||||
.expand-button {
|
||||
margin-top: 1rem;
|
||||
align-self: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
padding: 0.625rem 1.5rem !important;
|
||||
border-radius: 2rem !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 0.875rem !important;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.expand-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4) !important;
|
||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%) !important;
|
||||
}
|
||||
|
||||
.expand-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,42 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useTransactionStore } from '@/stores/transactions-store';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useSpaceStore } from '@/stores/spaceStore';
|
||||
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 RecentTransactions from './RecentTransactions.vue';
|
||||
import DashboardCharts from './DashboardCharts.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 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 () => {
|
||||
if (spaceStore.selectedSpaceId) {
|
||||
await Promise.all([
|
||||
transactionStore.fetchTransactions(spaceStore.selectedSpaceId),
|
||||
transactionStore.fetchPlannedTransactions(spaceStore.selectedSpaceId)
|
||||
fetchDashboardData(),
|
||||
// recurrentsStore.fetchRecurrents(spaceStore.selectedSpaceId)
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
const totalIncome = computed(() => {
|
||||
return transactionStore.transactions
|
||||
.filter(t => t.type === TransactionType.INCOME)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
watch(() => spaceStore.selectedSpaceId, async (newId) => {
|
||||
if (newId) {
|
||||
await Promise.all([
|
||||
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(() => {
|
||||
return userStore.user?.firstName || 'User';
|
||||
@@ -44,47 +173,151 @@ const userName = computed(() => {
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<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 }}! 👋
|
||||
</h1>
|
||||
<p class="text-surface-500 dark:text-surface-400">
|
||||
Here is your financial overview.
|
||||
</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>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-3 md:grid-cols-3 gap-4">
|
||||
<StatsCard title="Total Balance" :amount="totalBalance" icon="pi pi-wallet" color="blue" />
|
||||
<StatsCard title="Income" :amount="totalIncome" icon="pi pi-arrow-down-left" color="green" />
|
||||
<StatsCard title="Expense" :amount="totalExpense" icon="pi pi-arrow-up-right" color="red" />
|
||||
<div class="grid grid-cols-2 md:grid-cols-2 gap-4">
|
||||
<StatsCard title="Expense" :amount="dashboardData.totalExpense" icon="pi pi-arrow-up-right" color="red" />
|
||||
<StatsCard title="Income" :amount="dashboardData.totalIncome" icon="pi pi-arrow-down-left" color="green" />
|
||||
<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>
|
||||
|
||||
<!-- 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 -->
|
||||
<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 by Category</h2>
|
||||
<DashboardCharts :transactions="transactionStore.transactions" />
|
||||
</div>
|
||||
<DashboardCharts :categories="dashboardData.categories" :transactions="dashboardTransactions"
|
||||
:weeks="dashboardData.weeks" :startDate="currentStartDate" :endDate="currentEndDate" />
|
||||
|
||||
<!-- Upcoming Transactions -->
|
||||
<div class=" ">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<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" />
|
||||
<UpcomingTransactions :transactions="plannedTransactions" @set-tx-done="setTransactionDone" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Transactions -->
|
||||
<RecentTransactions :transactions="transactionStore.transactions" />
|
||||
<RecentTransactions :transactions="dashboardTransactions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.ai-summary-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ai-summary-content {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
transition: all 0.3s ease;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ai-summary-content.expanded {
|
||||
display: block;
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
|
||||
.ai-summary-fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3em;
|
||||
background: linear-gradient(to bottom,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.7) 50%,
|
||||
rgba(255, 255, 255, 0.95) 100%);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .ai-summary-fade {
|
||||
background: linear-gradient(to bottom,
|
||||
transparent 0%,
|
||||
rgba(24, 24, 27, 0.7) 50%,
|
||||
rgba(24, 24, 27, 0.95) 100%);
|
||||
}
|
||||
|
||||
.expand-button {
|
||||
margin-top: 1rem;
|
||||
align-self: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
padding: 0.625rem 1.5rem !important;
|
||||
border-radius: 2rem !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 0.875rem !important;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.expand-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4) !important;
|
||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%) !important;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: bold !important;
|
||||
}
|
||||
|
||||
.expand-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { Transaction } from '@/models/transaction';
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatAmount } from '@/utils/utils';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Divider from 'primevue/divider';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps<{
|
||||
transactions: Transaction[];
|
||||
}>();
|
||||
|
||||
const recentTransactions = computed(() => {
|
||||
return props.transactions.slice(0, 5);
|
||||
});
|
||||
|
||||
|
||||
const formatDate = (date: string | Date) => {
|
||||
return dayjs(date).format('MMM D');
|
||||
@@ -29,41 +30,41 @@ const getAmountColor = (type: string) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-0">Recent Transactions</h3>
|
||||
<router-link to="/transactions" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">
|
||||
View All
|
||||
</router-link>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-row justify-between items-end px-2">
|
||||
<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">View
|
||||
all</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="recentTransactions.length === 0" class="text-center py-8 text-surface-500 dark:text-surface-400">
|
||||
No recent transactions
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div v-for="tx in recentTransactions" :key="tx.id"
|
||||
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 items-center gap-3 w-full">
|
||||
<div
|
||||
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)
|
||||
<div class="flex card">
|
||||
<span v-if="transactions.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>
|
||||
<div v-else v-for="key in transactions.keys()" :key="transactions[key].id"
|
||||
@click="router.push(`/transactions/${transactions[key].id}/edit`)"
|
||||
class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center font-bold ">
|
||||
<div class="flex flex-row w-full items-center justify-between">
|
||||
<div class="flex flex-row items-center gap-2 ">
|
||||
<span v-if="transactions[key].category" class="text-3xl"> {{
|
||||
transactions[key].category.icon
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end w-full">
|
||||
<span :class="['!font-semibold', getAmountColor(tx.type)]">
|
||||
{{ 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> -->
|
||||
<i v-else class="pi pi-question !text-3xl" />
|
||||
<div class="flex flex-col !font-bold "> {{ transactions[key].comment }}
|
||||
<div v-if="transactions[key].category" class="flex flex-row text-sm">
|
||||
{{ transactions[key].category.name }}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -19,13 +19,14 @@ const formatCurrency = (value: number, currency = 'RUB') => {
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<span class="text-surface-500 dark:text-surface-400 font-medium text-sm">{{ title }}</span>
|
||||
</div>
|
||||
<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) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,24 @@ import { Transaction } from '@/models/transaction';
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
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<{
|
||||
transactions: Transaction[];
|
||||
}>();
|
||||
const emits = defineEmits(["set-tx-done"])
|
||||
|
||||
const upcomingTransactions = computed(() => {
|
||||
return props.transactions
|
||||
@@ -29,40 +43,71 @@ const getDaysUntilText = (date: string | Date) => {
|
||||
const getAmountColor = (type: string) => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="upcomingTransactions.length === 0" class="text-center py-8 text-surface-500 dark:text-surface-400">
|
||||
No upcoming planned transactions
|
||||
<div class="flex flex-col gap-0 ">
|
||||
<div class="flex flex-row justify-between items-end px-2">
|
||||
<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 v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="tx in upcomingTransactions"
|
||||
:key="tx.id"
|
||||
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 items-center gap-3 w-full">
|
||||
<div 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-full">{{ tx.comment }}</span>
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
<span class="text-primary-500 font-medium">{{ getDaysUntilText(tx.date) }}</span>
|
||||
<span class="text-surface-500 dark:text-surface-400">({{ formatDate(tx.date) }})</span>
|
||||
<div class="flex card">
|
||||
<span v-if="transactions.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>
|
||||
<div v-else v-for="key in transactions.keys()" :key="transactions[key].id"
|
||||
class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center font-bold "
|
||||
@click="router.push(`/transactions/${transactions[key].id}/edit`)">
|
||||
<div class="flex flex-row w-full items-center gap-4">
|
||||
<Checkbox v-model="transactions[key].isDone" binary class="text-3xl"
|
||||
@change="setTransactionDone(transactions[key])">
|
||||
{{ transactions[key].category.icon }}
|
||||
</Checkbox>
|
||||
<div class="flex !flex-row !justify-between !w-full"
|
||||
@click="router.push(`/transactions/${transactions[key].id}/edit`)">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="flex flex-col !font-bold "> {{ transactions[key].comment }}
|
||||
<div class="flex flex-row text-sm">{{ transactions[key].category.icon }}
|
||||
{{ transactions[key].category.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end w-full">
|
||||
<span :class="['!font-semibold', getAmountColor(tx.type)]">
|
||||
{{ 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 class="flex flex-row gap-2 items-center !w-fit">
|
||||
<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) }} {{
|
||||
getDaysUntilText(transactions[key].date) }}</span>
|
||||
</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>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { useSpaceStore } from "@/stores/spaceStore";
|
||||
import {onMounted, ref} from "vue";
|
||||
import {Checkbox, Divider} from "primevue";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { Checkbox, Divider, IconField, InputIcon, InputText, Drawer, DatePicker, Button } from "primevue";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { Transaction, UpdateTransactionDTO } from "@/models/transaction";
|
||||
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 { TransactionKind } from "@/models/enums";
|
||||
import { useToolbarStore } from "@/stores/toolbar-store";
|
||||
import { Category } from "@/models/category";
|
||||
import { categoriesService } from "@/services/categories-service";
|
||||
|
||||
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
@@ -16,6 +19,53 @@ const spaceStore = useSpaceStore()
|
||||
const toolbar = useToolbarStore()
|
||||
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 setTransactionDone = async (transaction: Transaction): Promise<void> => {
|
||||
@@ -73,6 +123,11 @@ const fetchData = async (fetchPlanned: boolean = true, fetchInstant: boolean = t
|
||||
isDone: showIsDone.value ? undefined : false,
|
||||
offset: plannedOffset.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`, если поля опциональные
|
||||
: Promise.resolve(plannedTransactions.value)
|
||||
|
||||
@@ -82,6 +137,10 @@ const fetchData = async (fetchPlanned: boolean = true, fetchInstant: boolean = t
|
||||
kind: TransactionKind.INSTANT,
|
||||
offset: instantOffset.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)
|
||||
: Promise.resolve(instantTransactions.value)
|
||||
|
||||
@@ -115,6 +174,7 @@ const fetchData = async (fetchPlanned: boolean = true, fetchInstant: boolean = t
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchCategories()
|
||||
await fetchData()
|
||||
toolbar.registerHandler('openTransactionCreation', () => {
|
||||
router.push('/transactions/create')
|
||||
@@ -129,11 +189,19 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-6 pb-10">
|
||||
<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">
|
||||
<span class="text-xl !font-semibold !pl-2">Planned transactions</span>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Checkbox v-model="showIsDone" binary value=" Показывать выполненные" @change="plannedOffset = 0;fetchData(true, false, true)"/>
|
||||
<span class="!text-sm">Выполненные</span>
|
||||
<Checkbox v-model="showIsDone" binary value=" Показывать выполненные"
|
||||
@change="plannedOffset = 0; fetchData(true, false, true)" />
|
||||
<span class="text-sm">Выполненные</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex card">
|
||||
@@ -169,6 +237,48 @@ onMounted(async () => {
|
||||
</div>
|
||||
<button v-if="!plannedLastBatch" class="card w-fit " @click="fetchMorePlanned">Load more...</button>
|
||||
</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">
|
||||
<span class="text-xl !font-semibold !pl-2">Instant transactions</span>
|
||||
<div class="flex card">
|
||||
@@ -207,6 +317,4 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
43
src/models/dashboard.ts
Normal file
43
src/models/dashboard.ts
Normal 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,
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ declare module 'vue-router' {
|
||||
// ⚙️ Ленивая загрузка компонентов (code-splitting)
|
||||
const SettingsList = () => import('@/components/settings/SettingsList.vue')
|
||||
const CategoriesList = () => import('@/components/settings/CategoriesList.vue')
|
||||
const AnalyticsView = () => import('@/views/AnalyticsView.vue')
|
||||
const RecurrentsList = () => import('@/components/settings/RecurrentsList.vue')
|
||||
const SpaceSettings = () => import('@/components/settings/SpaceSettings.vue')
|
||||
const NotificationSettings = () => import('@/components/settings/NotificationSettings.vue')
|
||||
@@ -32,6 +33,7 @@ const NotificationSettings = () => import('@/components/settings/NotificationSet
|
||||
export const RouteName = {
|
||||
Login: 'login',
|
||||
Dashboard: 'dashboard',
|
||||
Analytics: 'analytics',
|
||||
TransactionList: 'transaction-list',
|
||||
TransactionCreate: 'transaction-create',
|
||||
TransactionUpdate: 'transaction-update',
|
||||
@@ -59,6 +61,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: DashboardView,
|
||||
meta: { requiresAuth: true, navStack: 'dashboard', title: "Home" }
|
||||
},
|
||||
{
|
||||
path: '/analytics',
|
||||
name: RouteName.Analytics,
|
||||
component: AnalyticsView,
|
||||
meta: { requiresAuth: true, navStack: 'analytics', title: "Analytics" }
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
name: RouteName.TransactionList,
|
||||
|
||||
19
src/services/dashboard-service.ts
Normal file
19
src/services/dashboard-service.ts
Normal 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
|
||||
}
|
||||
@@ -13,13 +13,21 @@ function toDateOnly(d: Date): string {
|
||||
}
|
||||
|
||||
export interface TransactionFilters {
|
||||
query: string | null
|
||||
type: TransactionType | null
|
||||
kind: TransactionKind | null
|
||||
categoriesIds: number[] | null
|
||||
dateFrom: string | Date | null
|
||||
dateTo: string | Date | null
|
||||
isDone: boolean | null
|
||||
offset: number | null
|
||||
limit: number | null
|
||||
sorts: { sortBy: string; sortDirection: string }[]
|
||||
}
|
||||
|
||||
enum SortDirection {
|
||||
ASC = "ASC",
|
||||
DESC = 'DESC'
|
||||
}
|
||||
|
||||
async function getTransactions(spaceId: number, filters: TransactionFilters): Promise<Transaction[]> {
|
||||
|
||||
@@ -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
322
src/views/AnalyticsView.vue
Normal 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>
|
||||
```
|
||||
Reference in New Issue
Block a user