feat: Implement dedicated dashboard service and data models, revamp upcoming transactions UI, and update dashboard charts to use new category data.
This commit is contained in:
@@ -181,6 +181,12 @@ 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{
|
||||||
|
background-color: white !important;
|
||||||
|
}
|
||||||
|
.p-button-rounded:hover{
|
||||||
|
background-color: lightgray !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;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<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 { DashboardCategory } 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,30 +11,29 @@ import isoWeek from 'dayjs/plugin/isoWeek';
|
|||||||
dayjs.extend(isoWeek);
|
dayjs.extend(isoWeek);
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
categories: DashboardCategory[];
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const chartType = ref<'category' | 'weekly'>('category');
|
const chartType = ref<'category' | 'weekly'>('category');
|
||||||
|
|
||||||
const expenses = computed(() => props.transactions.filter(t => t.type === TransactionType.EXPENSE));
|
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 => {
|
name: c.category.name,
|
||||||
const categoryName = t.category?.name || 'Uncategorized';
|
amount: c.currentPeriodAmount,
|
||||||
const currentAmount = categoryMap.get(categoryName) || 0;
|
currentPeriodAmount: c.currentPeriodAmount,
|
||||||
categoryMap.set(categoryName, currentAmount + t.amount);
|
previousPeriodAmount: c.previousPeriodAmount,
|
||||||
});
|
changeDiffPercentage: c.changeDiffPercentage
|
||||||
|
}))
|
||||||
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(() => {
|
||||||
@@ -107,7 +107,10 @@ const weeklyData = computed(() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
expenses.value.forEach(t => {
|
// Filter expense transactions from the transactions prop
|
||||||
|
const expenseTransactions = props.transactions;
|
||||||
|
|
||||||
|
expenseTransactions.forEach(t => {
|
||||||
const tDate = dayjs(t.date);
|
const tDate = dayjs(t.date);
|
||||||
const week = weeks.find(w => tDate.isAfter(w.start.subtract(1, 'second')) && tDate.isBefore(w.end.add(1, 'second')));
|
const week = weeks.find(w => tDate.isAfter(w.start.subtract(1, 'second')) && tDate.isBefore(w.end.add(1, 'second')));
|
||||||
if (week) {
|
if (week) {
|
||||||
@@ -163,18 +166,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,8 +192,8 @@ 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>
|
||||||
|
|
||||||
@@ -198,10 +207,25 @@ const weeklyChartOptions = computed(() => {
|
|||||||
<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">
|
||||||
|
<div class="flex items-center gap-0">
|
||||||
|
<span>{{ category.percentage }}</span>
|
||||||
|
<span class="ml-0.5">%</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-surface-500 dark:text-surface-400 ml-1"> ({{
|
||||||
|
category.changeDiffPercentage > 0 ? '+' : category.changeDiffPercentage < 0 ? '-' : '' +
|
||||||
|
Math.round(category.changeDiffPercentage) }})</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -216,7 +240,8 @@ const weeklyChartOptions = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Transactions List -->
|
<!-- Transactions List -->
|
||||||
<div class="flex flex-col gap-1 pl-2 border-l-2 border-surface-100 dark:border-surface-700 ml-2 h-fit">
|
<div
|
||||||
|
class="flex flex-col gap-1 pl-2 border-l-2 border-surface-100 dark:border-surface-700 ml-2 h-fit">
|
||||||
<div v-for="tx in week.transactions.slice(0, 5)" :key="tx.id"
|
<div v-for="tx in week.transactions.slice(0, 5)" :key="tx.id"
|
||||||
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">
|
||||||
@@ -236,6 +261,8 @@ const weeklyChartOptions = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,42 +1,129 @@
|
|||||||
<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 dashboardTransactions = ref<Transaction[]>([]);
|
||||||
|
const plannedTransactions = ref<Transaction[]>([]);
|
||||||
|
|
||||||
|
const currentBaseDate = ref(dayjs());
|
||||||
|
|
||||||
|
const displayMonth = computed(() => {
|
||||||
|
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 fetchDashboardData = async () => {
|
||||||
|
if (!spaceStore.selectedSpaceId) return;
|
||||||
|
|
||||||
|
const startDate = currentBaseDate.value.date(10).toDate();
|
||||||
|
const 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,10 +131,17 @@ 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">
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<Button icon="pi pi-chevron-left" text rounded @click="prevMonth" />
|
||||||
|
<span class="text-xl font-semibold capitalize">{{ displayMonth }}</span>
|
||||||
|
<Button icon="pi pi-chevron-right" text rounded @click="nextMonth" />
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
@@ -56,34 +150,27 @@ const userName = computed(() => {
|
|||||||
</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>
|
||||||
|
|
||||||
<!-- 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">
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 @@ 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';
|
||||||
|
|
||||||
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 +32,51 @@ 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 setTxDone = (tx: Transaction) => {
|
||||||
|
emits("set-tx-done", tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
</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"
|
<div class="flex flex-row w-full items-center gap-4">
|
||||||
>
|
<Checkbox v-model="transactions[key].isDone" binary class="text-3xl"
|
||||||
<div class="flex items-center gap-3 w-full">
|
@change="setTransactionDone(transactions[key])">
|
||||||
<div class="w-10 h-10 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-xl p-4">
|
{{ transactions[key].category.icon }}
|
||||||
{{ tx.category.icon }}
|
</Checkbox>
|
||||||
</div>
|
<div class="flex !flex-row !justify-between !w-full"
|
||||||
<div class="flex flex-col w-full">
|
@click="router.push(`/transactions/${transactions[key].id}/edit`)">
|
||||||
<span class="font-medium text-surface-900 dark:text-surface-0 w-full">{{ tx.comment }}</span>
|
<div class="flex flex-row items-center gap-2">
|
||||||
<div class="flex items-center gap-1 text-xs">
|
<div class="flex flex-col !font-bold "> {{ transactions[key].comment }}
|
||||||
<span class="text-primary-500 font-medium">{{ getDaysUntilText(tx.date) }}</span>
|
<div class="flex flex-row text-sm">{{ transactions[key].category.icon }}
|
||||||
<span class="text-surface-500 dark:text-surface-400">({{ formatDate(tx.date) }})</span>
|
{{ 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>
|
||||||
|
|||||||
19
src/models/dashboard.ts
Normal file
19
src/models/dashboard.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Category } from "./category"
|
||||||
|
import { Transaction } from "./transaction"
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
totalExpense: number,
|
||||||
|
totalIncome: number,
|
||||||
|
balance: number,
|
||||||
|
categories: DashboardCategory[],
|
||||||
|
upcomingTransactions: Transaction[],
|
||||||
|
recentTransactions: Transaction[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardCategory {
|
||||||
|
category: Category,
|
||||||
|
currentPeriodAmount: number,
|
||||||
|
previousPeriodAmount: number,
|
||||||
|
changeDiff: number,
|
||||||
|
changeDiffPercentage: number,
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ export interface TransactionFilters{
|
|||||||
isDone: boolean | null
|
isDone: boolean | null
|
||||||
offset: number | null
|
offset: number | null
|
||||||
limit: number | null
|
limit: number | null
|
||||||
|
sorts: Map<string, 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[]> {
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user