Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
84
src/App.vue
84
src/App.vue
@@ -3,13 +3,13 @@ import SpaceList from "@/components/space-list/SpaceList.vue";
|
|||||||
import Toolbar from "@/components/Toolbar.vue";
|
import Toolbar from "@/components/Toolbar.vue";
|
||||||
import Toast from "primevue/toast";
|
import Toast from "primevue/toast";
|
||||||
import ProgressSpinner from "primevue/progressspinner";
|
import ProgressSpinner from "primevue/progressspinner";
|
||||||
import {useSpaceStore} from "@/stores/spaceStore";
|
import { useSpaceStore } from "@/stores/spaceStore";
|
||||||
import {useToolbarStore} from "@/stores/toolbar-store";
|
import { useToolbarStore } from "@/stores/toolbar-store";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import {useRoute} from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import {computed, onBeforeUnmount, onMounted, ref, watch} from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import {useToast} from "primevue/usetoast";
|
import { useToast } from "primevue/usetoast";
|
||||||
import {useUserStore} from "@/stores/userStore";
|
import { useUserStore } from "@/stores/userStore";
|
||||||
|
|
||||||
const spaceStore = useSpaceStore();
|
const spaceStore = useSpaceStore();
|
||||||
const toolbarStore = useToolbarStore();
|
const toolbarStore = useToolbarStore();
|
||||||
@@ -29,9 +29,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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
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">
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
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)
|
// ⚙️ Ленивая загрузка компонентов (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,
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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