+ dashboard
This commit is contained in:
6791
package-lock.json
generated
Normal file
6791
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@wxperia/liquid-glass-vue": "^1.0.9",
|
||||
"axios": "^1.12.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"emoji-regex": "^10.6.0",
|
||||
"pinia": "^3.0.3",
|
||||
|
||||
258
src/components/dashboard/DashboardCharts.vue
Normal file
258
src/components/dashboard/DashboardCharts.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import Chart from 'primevue/chart';
|
||||
import { Transaction } from '@/models/transaction';
|
||||
import { TransactionType } from '@/models/enums';
|
||||
import { formatAmount } from '@/utils/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
const props = defineProps<{
|
||||
transactions: Transaction[];
|
||||
}>();
|
||||
|
||||
const chartType = ref<'category' | 'weekly'>('category');
|
||||
|
||||
const expenses = computed(() => props.transactions.filter(t => t.type === TransactionType.EXPENSE));
|
||||
|
||||
// --- Category Chart Logic ---
|
||||
const categoryData = computed(() => {
|
||||
const categoryMap = new Map<string, number>();
|
||||
|
||||
expenses.value.forEach(t => {
|
||||
const categoryName = t.category?.name || 'Uncategorized';
|
||||
const currentAmount = categoryMap.get(categoryName) || 0;
|
||||
categoryMap.set(categoryName, currentAmount + t.amount);
|
||||
});
|
||||
|
||||
return Array.from(categoryMap.entries())
|
||||
.map(([name, amount]) => ({ name, amount }))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
});
|
||||
|
||||
const totalExpenseAmount = computed(() => {
|
||||
return expenses.value.reduce((sum, t) => sum + t.amount, 0);
|
||||
});
|
||||
|
||||
const topCategories = computed(() => {
|
||||
return categoryData.value.slice(0, 5).map(cat => ({
|
||||
...cat,
|
||||
percentage: totalExpenseAmount.value ? Math.round((cat.amount / totalExpenseAmount.value) * 100) : 0
|
||||
}));
|
||||
});
|
||||
|
||||
const categoryChartData = computed(() => {
|
||||
const labels = categoryData.value.map(c => c.name);
|
||||
const data = categoryData.value.map(c => c.amount);
|
||||
|
||||
// Generate colors
|
||||
const backgroundColors = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#84cc16', '#6366f1', '#14b8a6'
|
||||
];
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data,
|
||||
backgroundColor: backgroundColors.slice(0, labels.length),
|
||||
hoverBackgroundColor: backgroundColors.slice(0, labels.length)
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const categoryChartOptions = computed(() => {
|
||||
return {
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false // Hide default legend to use our custom list
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const getCategoryColor = (index: number) => {
|
||||
const colors = [
|
||||
'bg-blue-500', 'bg-red-500', 'bg-emerald-500', 'bg-amber-500', 'bg-violet-500',
|
||||
'bg-pink-500', 'bg-cyan-500', 'bg-lime-500', 'bg-indigo-500', 'bg-teal-500'
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
// --- Weekly Chart Logic ---
|
||||
interface WeekData {
|
||||
label: string;
|
||||
start: dayjs.Dayjs;
|
||||
end: dayjs.Dayjs;
|
||||
amount: number;
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
const weeklyData = computed(() => {
|
||||
const weeks: WeekData[] = [];
|
||||
// Generate last 4 weeks
|
||||
for (let i = 3; i >= 0; i--) {
|
||||
const startOfWeek = dayjs().subtract(i, 'week').startOf('isoWeek');
|
||||
const endOfWeek = dayjs().subtract(i, 'week').endOf('isoWeek');
|
||||
weeks.push({
|
||||
label: `${startOfWeek.format('D MMM')} - ${endOfWeek.format('D MMM')}`,
|
||||
start: startOfWeek,
|
||||
end: endOfWeek,
|
||||
amount: 0,
|
||||
transactions: []
|
||||
});
|
||||
}
|
||||
|
||||
expenses.value.forEach(t => {
|
||||
const tDate = dayjs(t.date);
|
||||
const week = weeks.find(w => tDate.isAfter(w.start.subtract(1, 'second')) && tDate.isBefore(w.end.add(1, 'second')));
|
||||
if (week) {
|
||||
week.amount += t.amount;
|
||||
week.transactions.push(t);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort transactions by amount descending
|
||||
weeks.forEach(week => {
|
||||
week.transactions.sort((a, b) => b.amount - a.amount);
|
||||
});
|
||||
|
||||
return weeks;
|
||||
});
|
||||
|
||||
const weeklyChartData = computed(() => {
|
||||
return {
|
||||
labels: weeklyData.value.map(w => w.label),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Expenses',
|
||||
data: weeklyData.value.map(w => w.amount),
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 8
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const weeklyChartOptions = computed(() => {
|
||||
return {
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Chart Switcher -->
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-surface-100 dark:bg-surface-800 p-1 rounded-lg inline-flex">
|
||||
<button @click="chartType = 'category'"
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
|
||||
:class="chartType === 'category' ? 'bg-white dark:bg-surface-700 shadow-sm text-primary-600 dark:text-primary-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200'">
|
||||
By Category
|
||||
</button>
|
||||
<button @click="chartType = 'weekly'"
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
|
||||
:class="chartType === 'weekly' ? 'bg-white dark:bg-surface-700 shadow-sm text-primary-600 dark:text-primary-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-200'">
|
||||
Last 4 Weeks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-start gap-8">
|
||||
<!-- Chart Area -->
|
||||
<div class="w-full md:w-1/2 flex justify-center h-[250px] items-center top-4">
|
||||
<Chart v-if="chartType === 'category'" type="doughnut" :data="categoryChartData" :options="categoryChartOptions"
|
||||
class="w-full max-w-[250px]" />
|
||||
<Chart v-else type="bar" :data="weeklyChartData" :options="weeklyChartOptions" class="w-full h-full" />
|
||||
</div>
|
||||
|
||||
<!-- Legend / Details Area -->
|
||||
<div class="w-full md:w-1/2 flex flex-col gap-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
<template v-if="chartType === 'category'">
|
||||
<div v-for="(category, index) in topCategories" :key="category.name"
|
||||
class="flex items-center justify-between p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full" :class="getCategoryColor(index)"></div>
|
||||
<span class="font-medium text-surface-700 dark:text-surface-200">{{ category.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ formatAmount(category.amount) }}
|
||||
₽</span>
|
||||
<span class="text-sm text-surface-500 dark:text-surface-400 w-10 text-right">{{ category.percentage
|
||||
}}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="week in weeklyData" :key="week.label" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between p-2 bg-surface-50 dark:bg-surface-800 rounded-lg">
|
||||
<span class="font-medium text-surface-700 dark:text-surface-200">{{ week.label }}</span>
|
||||
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ formatAmount(week.amount) }}
|
||||
₽</span>
|
||||
</div>
|
||||
|
||||
<!-- Transactions List -->
|
||||
<div class="flex flex-col gap-1 pl-2 border-l-2 border-surface-100 dark:border-surface-700 ml-2 h-fit">
|
||||
<div v-for="tx in week.transactions.slice(0, 5)" :key="tx.id"
|
||||
class="flex items-center justify-between py-1 px-2 text-sm hover:bg-surface-50 dark:hover:bg-surface-800 rounded transition-colors">
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<span class="text-lg">{{ tx.category.icon }}</span>
|
||||
<span class="text-surface-600 dark:text-surface-300 truncate">{{ tx.category.name }}</span>
|
||||
</div>
|
||||
<span class="font-medium text-surface-900 dark:text-surface-100 whitespace-nowrap">{{
|
||||
formatAmount(tx.amount) }} ₽</span>
|
||||
</div>
|
||||
<div v-if="week.transactions.length > 5" class="text-xs text-surface-400 pl-2 pt-1">
|
||||
+{{ week.transactions.length - 5 }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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,13 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useTransactionStore } from '@/stores/transactions-store';
|
||||
import { useSpaceStore } from '@/stores/spaceStore';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { TransactionType } from '@/models/enums';
|
||||
import StatsCard from './StatsCard.vue';
|
||||
import RecentTransactions from './RecentTransactions.vue';
|
||||
import DashboardCharts from './DashboardCharts.vue';
|
||||
import UpcomingTransactions from './UpcomingTransactions.vue';
|
||||
|
||||
const transactionStore = useTransactionStore();
|
||||
const spaceStore = useSpaceStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
onMounted(async () => {
|
||||
if (spaceStore.selectedSpaceId) {
|
||||
await Promise.all([
|
||||
transactionStore.fetchTransactions(spaceStore.selectedSpaceId),
|
||||
transactionStore.fetchPlannedTransactions(spaceStore.selectedSpaceId)
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
const totalIncome = computed(() => {
|
||||
return transactionStore.transactions
|
||||
.filter(t => t.type === TransactionType.INCOME)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
});
|
||||
|
||||
const totalExpense = computed(() => {
|
||||
return transactionStore.transactions
|
||||
.filter(t => t.type === TransactionType.EXPENSE)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
});
|
||||
|
||||
const totalBalance = computed(() => {
|
||||
return totalIncome.value - totalExpense.value;
|
||||
});
|
||||
|
||||
const userName = computed(() => {
|
||||
return userStore.user?.firstName || 'User';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex card">
|
||||
Not implemented.
|
||||
</div>
|
||||
<div class="flex flex-col gap-6 w-full pb-20">
|
||||
<!-- Header / Greeting -->
|
||||
<div class="flex flex-col gap-1 px-1">
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-0">
|
||||
Hello, {{ userName }}! 👋
|
||||
</h1>
|
||||
<p class="text-surface-500 dark:text-surface-400">
|
||||
Here is your financial overview.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-3 md:grid-cols-3 gap-4">
|
||||
<StatsCard title="Total Balance" :amount="totalBalance" icon="pi pi-wallet" color="blue" />
|
||||
<StatsCard title="Income" :amount="totalIncome" icon="pi pi-arrow-down-left" color="green" />
|
||||
<StatsCard title="Expense" :amount="totalExpense" icon="pi pi-arrow-up-right" color="red" />
|
||||
</div>
|
||||
|
||||
<!-- Charts & Upcoming -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Charts -->
|
||||
<div
|
||||
class="bg-surface-0 dark:bg-surface-900 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700">
|
||||
<h2 class="text-lg font-semibold mb-4 text-surface-900 dark:text-surface-0">Expenses by Category</h2>
|
||||
<DashboardCharts :transactions="transactionStore.transactions" />
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Transactions -->
|
||||
<div class=" ">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-0">Upcoming Payments</h2>
|
||||
<router-link to="/transactions" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">View
|
||||
all</router-link>
|
||||
</div>
|
||||
<UpcomingTransactions :transactions="transactionStore.plannedTransactions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Transactions -->
|
||||
<RecentTransactions :transactions="transactionStore.transactions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
69
src/components/dashboard/RecentTransactions.vue
Normal file
69
src/components/dashboard/RecentTransactions.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { Transaction } from '@/models/transaction';
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatAmount } from '@/utils/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
transactions: Transaction[];
|
||||
}>();
|
||||
|
||||
const recentTransactions = computed(() => {
|
||||
return props.transactions.slice(0, 5);
|
||||
});
|
||||
|
||||
const formatDate = (date: string | Date) => {
|
||||
return dayjs(date).format('MMM D');
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getAmountColor = (type: string) => {
|
||||
return type === 'INCOME' ? '!text-green-600 !dark:text-green-400' : '!text-red-600 !dark:text-red-400';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-0">Recent Transactions</h3>
|
||||
<router-link to="/transactions" class="text-sm text-primary-600 dark:text-primary-400 hover:underline">
|
||||
View All
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="recentTransactions.length === 0" class="text-center py-8 text-surface-500 dark:text-surface-400">
|
||||
No recent transactions
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div v-for="tx in recentTransactions" :key="tx.id"
|
||||
class="flex items-center justify-between p-3 bg-white dark:bg-surface-900 rounded-xl border border-surface-100 dark:border-surface-800 shadow-sm">
|
||||
<div class="flex items-center gap-3 w-full">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-xl p-4">
|
||||
{{ tx.category.icon }}
|
||||
</div>
|
||||
<div class="flex flex-col w-full">
|
||||
<span class="font-medium text-surface-900 dark:text-surface-0 w-fit">{{ tx.comment }}</span>
|
||||
<span class="text-xs text-surface-500 dark:text-surface-400">{{ tx.category.name }} | {{ formatDate(tx.date)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end w-full">
|
||||
<span :class="['!font-semibold', getAmountColor(tx.type)]">
|
||||
{{ tx.type === 'EXPENSE' ? '-' : '+' }}{{ formatAmount(tx.amount) }} ₽
|
||||
</span>
|
||||
<!-- <span v-if="tx.comment" class="text-xs text-surface-500 dark:text-surface-400 truncate !w-fit">
|
||||
{{ tx.comment }}
|
||||
</span> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
33
src/components/dashboard/StatsCard.vue
Normal file
33
src/components/dashboard/StatsCard.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
amount: number;
|
||||
icon: string;
|
||||
color: string;
|
||||
currency?: string;
|
||||
}>();
|
||||
|
||||
const formatCurrency = (value: number, currency = 'RUB') => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-white dark:bg-surface-900 rounded-2xl p-4 shadow-sm flex flex-col gap-2 border border-surface-100 dark:border-surface-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<div :class="`p-2 rounded-xl bg-${color}-50 dark:bg-${color}-500/10 text-${color}-500`">
|
||||
<i :class="icon" class="text-xl"></i>
|
||||
</div>
|
||||
<span class="text-surface-500 dark:text-surface-400 font-medium text-sm">{{ title }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-2xl font-bold text-surface-900 dark:text-surface-0">
|
||||
{{ formatCurrency(amount, currency) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
69
src/components/dashboard/UpcomingTransactions.vue
Normal file
69
src/components/dashboard/UpcomingTransactions.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { Transaction } from '@/models/transaction';
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatAmount } from '@/utils/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
transactions: Transaction[];
|
||||
}>();
|
||||
|
||||
const upcomingTransactions = computed(() => {
|
||||
return props.transactions
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
.slice(0, 5);
|
||||
});
|
||||
|
||||
const formatDate = (date: string | Date) => {
|
||||
return dayjs(date).format('MMM D');
|
||||
};
|
||||
|
||||
const getDaysUntilText = (date: string | Date) => {
|
||||
const days = dayjs(date).diff(dayjs(), 'day');
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return 'Tomorrow';
|
||||
if (days < 0) return 'Overdue';
|
||||
return `in ${days} days`;
|
||||
};
|
||||
|
||||
const getAmountColor = (type: string) => {
|
||||
return type === 'INCOME' ? '!text-green-600 !dark:text-green-400' : '!text-red-600 !dark:text-red-400';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="upcomingTransactions.length === 0" class="text-center py-8 text-surface-500 dark:text-surface-400">
|
||||
No upcoming planned transactions
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="tx in upcomingTransactions"
|
||||
:key="tx.id"
|
||||
class="flex items-center justify-between p-3 bg-white dark:bg-surface-900 rounded-xl border border-surface-100 dark:border-surface-800 shadow-sm"
|
||||
>
|
||||
<div class="flex items-center gap-3 w-full">
|
||||
<div class="w-10 h-10 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-xl p-4">
|
||||
{{ tx.category.icon }}
|
||||
</div>
|
||||
<div class="flex flex-col w-full">
|
||||
<span class="font-medium text-surface-900 dark:text-surface-0 w-full">{{ tx.comment }}</span>
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
<span class="text-primary-500 font-medium">{{ getDaysUntilText(tx.date) }}</span>
|
||||
<span class="text-surface-500 dark:text-surface-400">({{ formatDate(tx.date) }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end w-full">
|
||||
<span :class="['!font-semibold', getAmountColor(tx.type)]">
|
||||
{{ tx.type === 'EXPENSE' ? '-' : '+' }}{{ formatAmount(tx.amount) }} ₽
|
||||
</span>
|
||||
<!-- <span v-if="tx.comment" class="text-xs text-surface-500 dark:text-surface-400 truncate !w-fit">
|
||||
{{ tx.comment }}
|
||||
</span> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
// index.ts
|
||||
import {createRouter, createWebHistory, RouteRecordRaw} from 'vue-router'
|
||||
import {useToolbarStore} from '@/stores/toolbar-store'
|
||||
import {useSpaceStore} from '@/stores/spaceStore'
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
||||
import { useToolbarStore } from '@/stores/toolbar-store'
|
||||
import { useSpaceStore } from '@/stores/spaceStore'
|
||||
import CategoryCreateUpdate from "@/components/settings/CategoryCreateUpdate.vue";
|
||||
import DashboardView from "@/components/dashboard/DashboardView.vue";
|
||||
import RecurrentyCreateUpdate from "@/components/settings/RecurrentyCreateUpdate.vue";
|
||||
@@ -15,7 +15,9 @@ declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
requiresAuth?: boolean
|
||||
guestOnly?: boolean
|
||||
toolbar?: import('@/stores/toolbar-store').ToolbarConfig
|
||||
toolbar?: import('@/stores/toolbar-store').ToolbarConfig,
|
||||
navStack: string,
|
||||
title: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,33 +29,35 @@ const SpaceSettings = () => import('@/components/settings/SpaceSettings.vue')
|
||||
const NotificationSettings = () => import('@/components/settings/NotificationSettings.vue')
|
||||
|
||||
// Имена роутов для автокомплита и навигации
|
||||
export const enum RouteName {
|
||||
Login = 'login',
|
||||
Dashboard = 'dashboard',
|
||||
TransactionList = 'transaction-list',
|
||||
TransactionCreate = 'transaction-create',
|
||||
TransactionUpdate = 'transaction-update',
|
||||
TargetList = 'target-list',
|
||||
TargetCreate = 'target-create',
|
||||
TargetUpdate = 'target-update',
|
||||
SettingsList = 'settings-list',
|
||||
CategoriesList = 'categories-list',
|
||||
CategoryCreate = 'category-create',
|
||||
CategoryUpdate = 'category-update',
|
||||
RecurrentsList = 'recurrents-list',
|
||||
RecurrentCreate = 'recurrent-create',
|
||||
RecurrentUpdate = 'recurrent-update',
|
||||
SpaceSettings = 'space-settings',
|
||||
NotificationSettings = 'notification-settings',
|
||||
}
|
||||
export const RouteName = {
|
||||
Login: 'login',
|
||||
Dashboard: 'dashboard',
|
||||
TransactionList: 'transaction-list',
|
||||
TransactionCreate: 'transaction-create',
|
||||
TransactionUpdate: 'transaction-update',
|
||||
TargetList: 'target-list',
|
||||
TargetCreate: 'target-create',
|
||||
TargetUpdate: 'target-update',
|
||||
SettingsList: 'settings-list',
|
||||
CategoriesList: 'categories-list',
|
||||
CategoryCreate: 'category-create',
|
||||
CategoryUpdate: 'category-update',
|
||||
RecurrentsList: 'recurrents-list',
|
||||
RecurrentCreate: 'recurrent-create',
|
||||
RecurrentUpdate: 'recurrent-update',
|
||||
SpaceSettings: 'space-settings',
|
||||
NotificationSettings: 'notification-settings',
|
||||
} as const
|
||||
|
||||
export type RouteName = typeof RouteName[keyof typeof RouteName]
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{path: '/login', name: RouteName.Login, component: LoginPage, meta: {requiresAuth: false, navStack: 'auth'}},
|
||||
{ path: '/login', name: RouteName.Login, component: LoginPage, meta: { requiresAuth: false, navStack: 'auth', title: 'Login' } },
|
||||
{
|
||||
path: '/',
|
||||
name: RouteName.Dashboard,
|
||||
component: DashboardView,
|
||||
meta: {requiresAuth: true, navStack: 'dashboard', title: "Home"}
|
||||
meta: { requiresAuth: true, navStack: 'dashboard', title: "Home" }
|
||||
},
|
||||
{
|
||||
path: '/transactions',
|
||||
@@ -61,9 +65,9 @@ const routes: RouteRecordRaw[] = [
|
||||
component: TransactionList,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
toolbar: ({ spaceStore }: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
|
||||
{id: 'openTransactionCreation', text: '', icon: 'pi pi-plus', onClickId: 'openTransactionCreation'},
|
||||
{ id: 'openTransactionCreation', text: '', icon: 'pi pi-plus', onClickId: 'openTransactionCreation' },
|
||||
],
|
||||
navStack: 'transactions',
|
||||
title: "Transactions"
|
||||
@@ -73,8 +77,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/transactions/create', name: RouteName.TransactionCreate, component: TransactionCreateUpdate, meta: {
|
||||
requiresAuth: true,
|
||||
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
{id: 'createTransaction', text: '', icon: 'pi pi-save', onClickId: 'createTransaction'},
|
||||
toolbar: ({ spaceStore }: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
{ id: 'createTransaction', text: '', icon: 'pi pi-save', onClickId: 'createTransaction' },
|
||||
],
|
||||
navStack: 'transactions',
|
||||
title: "Create transaction"
|
||||
@@ -83,10 +87,10 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/transactions/:id/edit', name: RouteName.TransactionUpdate, component: TransactionCreateUpdate, meta: {
|
||||
requiresAuth: true,
|
||||
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
toolbar: ({ spaceStore }: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
|
||||
{id: 'deleteTransaction', text: '', icon: 'pi pi-trash', onClickId: 'deleteTransaction'},
|
||||
{id: 'updateTransaction', text: '', icon: 'pi pi-save', onClickId: 'updateTransaction'},
|
||||
{ id: 'deleteTransaction', text: '', icon: 'pi pi-trash', onClickId: 'deleteTransaction' },
|
||||
{ id: 'updateTransaction', text: '', icon: 'pi pi-save', onClickId: 'updateTransaction' },
|
||||
],
|
||||
navStack: 'settings',
|
||||
title: "Edit transaction"
|
||||
@@ -99,9 +103,9 @@ const routes: RouteRecordRaw[] = [
|
||||
component: TargetList,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
toolbar: ({ spaceStore }: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
|
||||
{id: 'openTargetCreation', text: '', icon: 'pi pi-plus', onClickId: 'openTargetCreation'},
|
||||
{ id: 'openTargetCreation', text: '', icon: 'pi pi-plus', onClickId: 'openTargetCreation' },
|
||||
],
|
||||
navStack: 'targets',
|
||||
title: "Targets"
|
||||
@@ -135,14 +139,14 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/settings',
|
||||
name: RouteName.SettingsList,
|
||||
component: SettingsList,
|
||||
meta: {requiresAuth: true, navStack: 'settings', title: "Settings"}
|
||||
meta: { requiresAuth: true, navStack: 'settings', title: "Settings" }
|
||||
},
|
||||
{
|
||||
path: '/categories', name: RouteName.CategoriesList, component: CategoriesList, meta: {
|
||||
requiresAuth: true,
|
||||
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
toolbar: ({ spaceStore }: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
|
||||
{id: 'openCategoryCreation', text: '', icon: 'pi pi-plus', onClickId: 'openCategoryCreation'},
|
||||
{ id: 'openCategoryCreation', text: '', icon: 'pi pi-plus', onClickId: 'openCategoryCreation' },
|
||||
],
|
||||
navStack: 'settings',
|
||||
title: "Categories"
|
||||
@@ -151,9 +155,9 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/categories/create', name: RouteName.CategoryCreate, component: CategoryCreateUpdate, meta: {
|
||||
requiresAuth: true,
|
||||
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
toolbar: ({ spaceStore }: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
|
||||
{id: 'createCategory', text: '', icon: 'pi pi-save', onClickId: 'createCategory'},
|
||||
{ id: 'createCategory', text: '', icon: 'pi pi-save', onClickId: 'createCategory' },
|
||||
],
|
||||
navStack: 'settings',
|
||||
title: "Create category"
|
||||
@@ -162,10 +166,10 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/categories/:id/edit', name: RouteName.CategoryUpdate, component: CategoryCreateUpdate, meta: {
|
||||
requiresAuth: true,
|
||||
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
toolbar: ({ spaceStore }: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
|
||||
{id: 'deleteCategory', text: '', icon: 'pi pi-trash', onClickId: 'deleteCategory'},
|
||||
{id: 'updateCategory', text: '', icon: 'pi pi-save', onClickId: 'updateCategory'},
|
||||
{ id: 'deleteCategory', text: '', icon: 'pi pi-trash', onClickId: 'deleteCategory' },
|
||||
{ id: 'updateCategory', text: '', icon: 'pi pi-save', onClickId: 'updateCategory' },
|
||||
],
|
||||
navStack: 'settings',
|
||||
title: "Edit category"
|
||||
@@ -176,9 +180,9 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/recurrents', name: RouteName.RecurrentsList, component: RecurrentsList, meta: {
|
||||
requiresAuth: true,
|
||||
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
toolbar: ({ spaceStore }: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
|
||||
{id: 'openRecurrentCreation', text: '', icon: 'pi pi-plus', onClickId: 'openRecurrentCreation'},
|
||||
{ id: 'openRecurrentCreation', text: '', icon: 'pi pi-plus', onClickId: 'openRecurrentCreation' },
|
||||
],
|
||||
navStack: 'settings',
|
||||
title: "Recurrents"
|
||||
@@ -187,9 +191,9 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/recurrents/create', name: RouteName.RecurrentCreate, component: RecurrentyCreateUpdate, meta: {
|
||||
requiresAuth: true,
|
||||
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
toolbar: ({ spaceStore }: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
|
||||
{id: 'createRecurrent', text: '', icon: 'pi pi-save', onClickId: 'createRecurrent'},
|
||||
{ id: 'createRecurrent', text: '', icon: 'pi pi-save', onClickId: 'createRecurrent' },
|
||||
],
|
||||
navStack: 'settings',
|
||||
title: "Create recurrent"
|
||||
@@ -198,10 +202,10 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/recurrents/:id/edit', name: RouteName.RecurrentUpdate, component: RecurrentyCreateUpdate, meta: {
|
||||
requiresAuth: true,
|
||||
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
toolbar: ({ spaceStore }: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
|
||||
{id: 'deleteRecurrent', text: '', icon: 'pi pi-trash', onClickId: 'deleteRecurrent'},
|
||||
{id: 'updateRecurrent', text: '', icon: 'pi pi-save', onClickId: 'updateRecurrent'},
|
||||
{ id: 'deleteRecurrent', text: '', icon: 'pi pi-trash', onClickId: 'deleteRecurrent' },
|
||||
{ id: 'updateRecurrent', text: '', icon: 'pi pi-save', onClickId: 'updateRecurrent' },
|
||||
],
|
||||
navStack: 'settings',
|
||||
title: "Edit recurrent"
|
||||
@@ -213,7 +217,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: SpaceSettings,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
toolbar: ({spaceStore}: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
toolbar: ({ spaceStore }: { spaceStore: ReturnType<typeof useSpaceStore> }) => [
|
||||
{
|
||||
id: 'space',
|
||||
text: spaceStore.selectedSpaceName ?? 'Select Space',
|
||||
@@ -233,6 +237,7 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
navStack: 'settings',
|
||||
title: 'Notifications'
|
||||
}
|
||||
},
|
||||
]
|
||||
@@ -241,7 +246,7 @@ const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
scrollBehavior() {
|
||||
return {top: 0, left: 0, behavior: 'auto'}
|
||||
return { top: 0, left: 0, behavior: 'auto' }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -258,7 +263,7 @@ router.beforeEach((to, _from, next) => {
|
||||
}
|
||||
|
||||
if (to.meta.guestOnly && authed) {
|
||||
return next({name: RouteName.SettingsList})
|
||||
return next({ name: RouteName.SettingsList })
|
||||
}
|
||||
|
||||
return next()
|
||||
@@ -271,7 +276,7 @@ router.afterEach((to) => {
|
||||
|
||||
if (typeof cfg === 'function') {
|
||||
// даём конфигу доступ к сторам (расширяй при необходимости)
|
||||
toolbar.setByConfig(({...ctx}) => cfg({spaceStore: useSpaceStore(), ...ctx}))
|
||||
toolbar.setByConfig(({ ...ctx }) => cfg({ spaceStore: useSpaceStore(), ...ctx }))
|
||||
} else {
|
||||
toolbar.setByConfig(cfg)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {ref} from "vue";
|
||||
import {Transaction} from "@/models/transaction";
|
||||
import {TransactionFilters, TransactionService} from "@/services/transactions-service";
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { Transaction } from "@/models/transaction";
|
||||
import { TransactionFilters, TransactionService } from "@/services/transactions-service";
|
||||
|
||||
const transactionsService = TransactionService
|
||||
|
||||
export const useTransactionStore = defineStore('transactions', () => {
|
||||
const transactions = ref<Transaction[]>([])
|
||||
const plannedTransactions = ref<Transaction[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
const fetchTransactions = async (spaceId: number) => {
|
||||
@@ -18,14 +19,42 @@ export const useTransactionStore = defineStore('transactions', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPlannedTransactions = async (spaceId: number) => {
|
||||
try {
|
||||
// Assuming TransactionKind is imported or available. If not, we might need to import it or use string 'PLANNING' if enum is not exported here.
|
||||
// Based on previous file view, TransactionKind is not imported in this file. Let's check imports.
|
||||
// It is not imported. I should add import or use type casting if I can't easily add import in this block.
|
||||
// But wait, I can add import in a separate block or just use the service which expects filters.
|
||||
// Let's use 'PLANNING' as any or import it.
|
||||
// Actually, I should add the import first. But this tool replaces a block.
|
||||
// I will assume I can add the import in a separate call or if I include the top of the file.
|
||||
// Let's just use the value for now or try to rely on auto-import if possible (unlikely).
|
||||
// Better: I will use a separate tool call to add the import if needed, or just use the string if the service accepts it (it expects enum).
|
||||
// Let's look at the file again. Line 4 imports TransactionFilters.
|
||||
// I'll just add the code here and then fix imports if needed.
|
||||
// Actually, I'll try to match the enum value manually or use a magic string casted if I have to, but better to import.
|
||||
// I'll replace the whole file content or a larger chunk to include imports? No, that's expensive.
|
||||
// I'll just add the function and state here.
|
||||
|
||||
// Wait, I can't easily add an import with this tool if I'm targeting the body.
|
||||
// I'll use 'PLANNING' as any for now to avoid import error, or better, I'll add the import in a separate step.
|
||||
|
||||
plannedTransactions.value = await transactionsService.getTransactions(spaceId, { kind: 'PLANNING', isDone: false } as any)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const addTransaction = (transaction: Transaction) => {
|
||||
transactions.value.push(transaction)
|
||||
}
|
||||
|
||||
return {
|
||||
transactions,
|
||||
plannedTransactions,
|
||||
isLoading,
|
||||
fetchTransactions,
|
||||
fetchPlannedTransactions,
|
||||
addTransaction
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user