Files
luminic-front/src/components/budgets/BudgetView2.vue
2025-02-18 16:16:09 +03:00

1227 lines
48 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts" xmlns="http://www.w3.org/1999/html">
import {computed, onMounted, ref, watch, reactive, onUnmounted} from 'vue';
import Chart from 'primevue/chart';
import BudgetTransactionView from "@/components/budgets/BudgetTransactionView.vue";
import {
getBudgetCategories,
getBudgetInfo,
getBudgetTransactions,
updateBudgetCategoryRequest,
getWarns,
hideWarnRequest, getBudgetInfos
} from "@/services/budgetsService";
import {Budget, BudgetCategory, Warn} from "@/models/Budget";
import {useRoute} from "vue-router";
import {formatAmount, formatDate, getMonthName, getMonthName2} from "@/utils/utils";
import ProgressBar from "primevue/progressbar";
import {Transaction} from "@/models/Transaction";
import Toast from "primevue/toast";
import LoadingView from "@/components/LoadingView.vue";
import ChartDataLabels from 'chartjs-plugin-datalabels';
import {Chart as ChartJS} from 'chart.js/auto';
import SelectButton from "primevue/selectbutton";
import Divider from "primevue/divider";
import {useDrawerStore} from "@/stores/drawerStore";
import {EventBus} from '@/utils/EventBus.ts';
import {useToast} from "primevue/usetoast";
import {useSpaceStore} from "@/stores/spaceStore";
// Зарегистрируем плагин
ChartJS.register(ChartDataLabels);
const loading = ref(true);
const updateLoading = ref(false);
const route = useRoute()
const detailedShowed = ref(false);
const selectedChart = ref({"label": "bar", "icon": "pi pi-chart-bar", "value": "bar"});
const modes = [
{label: 'bar', icon: 'pi pi-chart-bar', value: 'bar'},
{label: 'pie', icon: 'pi pi-chart-pie', value: 'pie'}
];
const value = ref(50)
const warnsOpened = ref(false);
const hideWarn = async (warnId: string) => {
await hideWarnRequest(route.params.id, warnId)
await fetchWarns()
}
const leftForUnplanned = ref(0)
const budget = ref<Budget>()
const warns = ref<[Warn]>()
const checkWarnsExists = computed(() => {
return warns?.value?.length > 0;
});
const categories = ref<BudgetCategory[]>([])
const incomeCategories = ref<BudgetCategory[]>([])
const plannedIncomes = ref<Transaction[]>([])
const totalIncomes = computed(() => {
let totalIncome = 0;
plannedIncomes.value.forEach((i) => {
totalIncome += i.amount
})
return totalIncome
})
const totalInstantIncomes = computed(() => {
let totalIncome = 0;
transactions.value.filter(t => t.type.code == 'INSTANT' && t.category.type.code == 'INCOME').forEach((i) => {
totalIncome += i.amount
})
return totalIncome
})
const totalIncomeLeftToGet = computed(() => {
let totalIncomeLeftToGet = 0;
plannedIncomes.value.forEach(i => {
if (!i.isDone) {
totalIncomeLeftToGet += i.amount
}
})
return totalIncomeLeftToGet
})
const totalLoans = computed(() => {
let value = 0
categories.value.filter((cat) => cat.category.id == "677bc767c7857460a491bd49").forEach(cat => {
value += cat.currentLimit
})
return value
})
const loansRatio = computed(() => {
return totalExpenses.value == 0 ? 0 : totalLoans.value / totalExpenses.value * 100
})
const savingRatio = computed(() => {
return totalExpenses.value == 0 ? 0 : totalSaving.value / totalExpenses.value * 100
})
const totalSaving = computed(() => {
let value = 0
categories.value.filter((cat) => cat.category.id == "677bc767c7857460a491bd4f").forEach(cat => {
value += cat.currentLimit
})
return value
})
const drawerOpened = ref(false)
const transactionType = ref('')
const categoryType = ref('')
const drawerStore = useDrawerStore()
const openDrawer = (selectedTransactionType = null, selectedCategoryType = null, categoryId = null) => {
if (selectedTransactionType) {
drawerStore.setTransactionType(selectedCategoryType)
} else drawerStore.setCategoryType("INSTANT")
if (selectedCategoryType) {
drawerStore.setCategoryType(selectedCategoryType)
} else drawerStore.setCategoryType("EXPENSE")
if (categoryId) {
drawerStore.setCategoryId(categoryId)
} else drawerStore.setCategoryId(null)
drawerOpened.value = true;
drawerStore.visible = true
}
const closeDrawer = async () => {
drawerOpened.value = false;
// await updateTransactions()
}
const dailyRatio = computed(() => {
const value = totalExpenses.value == 0 ? 0 : (totalExpenses.value - totalLoans.value - totalSaving.value) / totalExpenses.value
return value * 100
})
const fetchPlannedIncomes = async () => {
plannedIncomes.value = await getBudgetTransactions(route.params.id, 'PLANNED', 'INCOME')
}
const plannedExpenses = ref<Transaction[]>([])
const totalExpenses = computed(() => {
let totalExpense = 0;
categories.value.forEach((cat) => {
let catValue = cat.currentLimit
// plannedExpenses.value.filter(t => t.category.id == cat.category.id).forEach((i) => {
//
// catValue += i.amount
// })
//
totalExpense += catValue
})
return totalExpense
})
const totalPlannedExpenses = computed(() => {
let expenses = 0
plannedExpenses.value.forEach((t) => {
expenses += t.amount
})
return expenses
})
const totalInstantExpenses = computed(() => {
let totalExpenses = 0;
transactions.value.filter(t => t.type.code == 'INSTANT' && t.category.type.code == 'EXPENSE').forEach((i) => {
totalExpenses += i.amount
})
return totalExpenses
})
const totalExpenseLeftToSpend = computed(() => {
let totalExpenseLeftToSpend = 0;
plannedExpenses.value.forEach(i => {
if (!i.isDone) {
totalExpenseLeftToSpend += i.amount
}
})
return totalExpenseLeftToSpend
})
const fetchPlannedExpenses = async () => {
plannedExpenses.value = await getBudgetTransactions(route.params.id, 'PLANNED', 'EXPENSE')
updateLoading.value = false
}
const transactions = ref<Transaction[]>([])
const selectedCategoryId = ref()
const selectCategoryType = (categoryId) => {
if (selectedCategoryId.value == categoryId) {
selectedCategoryId.value = null
} else {
selectedCategoryId.value = categoryId
}
}
const filteredTransactions = computed(() => {
return selectedCategoryId.value ? transactions.value.filter(i => i.category.id == selectedCategoryId.value) : transactions.value
})
const fetchBudgetTransactions = async () => {
transactions.value = await getBudgetTransactions(route.params.id, 'INSTANT')
updateLoading.value = false
}
// 1⃣ Создаём отдельное состояние для категорий расходов и доходов
const expenseCategoriesState = reactive<Record<string, { isOpened: boolean }>>({});
const incomeCategoriesState = reactive<Record<string, { isOpened: boolean }>>({});
// 2⃣ Инициализируем состояние для категорий расходов по plannedExpenses
watch(
() => categories.value,
(newExpenses) => {
newExpenses.forEach((expense) => {
// console.log(expense.category.id);
const categoryId = expense.category.id;
if (!(categoryId in expenseCategoriesState)) {
expenseCategoriesState[categoryId] = {isOpened: true};
}
});
},
{immediate: true}
);
// 3⃣ Инициализируем состояние для категорий доходов по plannedIncomes
watch(
() => plannedIncomes.value,
(newIncomes) => {
newIncomes.forEach((income) => {
const categoryId = income.category.id;
if (!(categoryId in incomeCategoriesState)) {
incomeCategoriesState[categoryId] = {isOpened: true};
}
});
},
{immediate: true}
);
// 4⃣ Computed для группировки транзакций расходов по категориям
const categoriesTransactions = computed(() => {
const grouped: Record<string, { name: any; transactions: any[]; isOpened: boolean }> = {};
// Создаем группу для каждой категории из списка категорий расходов (categories)
categories.value.forEach((category) => {
grouped[category.category.id] = {
name: category,
transactions: [],
isOpened: expenseCategoriesState[category.category.id]?.isOpened ?? true,
};
});
// Добавляем транзакции расходов (plannedExpenses)
plannedExpenses.value.forEach((plannedExpense) => {
const categoryId = plannedExpense.category.id;
if (!grouped[categoryId]) {
grouped[categoryId] = {
name: plannedExpense.category,
transactions: [],
isOpened: expenseCategoriesState[categoryId]?.isOpened ?? true,
};
}
grouped[categoryId].transactions.push(plannedExpense);
});
// Преобразуем объект в массив, сортируем, затем превращаем обратно в объект
return Object.fromEntries(
Object.entries(grouped).sort(([, a], [, b]) => {
// Сортируем по количеству транзакций (по убыванию)
if (b.transactions.length !== a.transactions.length) {
return b.transactions.length - a.transactions.length;
}
// Если количество транзакций одинаковое, сортируем по currentLimit (по убыванию)
return (b.name.currentLimit ?? 0) - (a.name.currentLimit ?? 0);
})
);
});
// 5⃣ Computed для группировки транзакций доходов по категориям
const categoriesIncomeTransactions = computed(() => {
const grouped: Record<string, { name: any; transactions: any[]; isOpened: boolean }> = {};
plannedIncomes.value.forEach((plannedIncome) => {
const categoryId = plannedIncome.category.id;
if (!grouped[categoryId]) {
const categoryDetails = incomeCategories.value.find(
(cat) => cat.category.id === categoryId
);
grouped[categoryId] = {
name: categoryDetails,
transactions: [],
isOpened: incomeCategoriesState[categoryId]?.isOpened ?? true,
};
}
grouped[categoryId].transactions.push(plannedIncome);
});
return grouped;
});
const updateTransactions = async () => {
setTimeout(async () => {
await Promise.all([fetchBudgetInfo(), fetchWarns()])
},)
}
const fetchBudgetCategories = async () => {
categories.value = await getBudgetCategories(route.params.id)
categories.value = [...categories.value];
updateLoading.value = false
}
const transactionCategoriesSums = computed(() => {
// Используем reduce для создания массива сумм по категориям
return transactions.value.reduce((acc, transaction) => {
const category = transaction.category;
// Проверяем, существует ли категория в аккумуляторе
const existingCategory = acc.find(item => item.category.id === category.id);
if (existingCategory) {
// Если категория существует, добавляем сумму к текущей
existingCategory.sum += transaction.amount;
} else {
// Если категории еще нет в аккумуляторе, добавляем ее
acc.push({category: category, sum: transaction.amount});
}
return acc;
}, []);
});
// const fetchBudgetTransactionCategoriesSums = async () => {
//
// transactionCategoriesSums.value = await getBudgetCategoriesSums(route.params.id)
// updateLoading.value = false
// }
const editingCategoryId = ref<string | null>(null);
const editableLimit = ref<Record<string, number>>({});
const startEditing = (category) => {
editingCategoryId.value = category.name.category.id;
editableLimit.value[category.name.category.id] = category.name.currentLimit;
};
const saveLimit = async (category) => {
const categoryId = category.name.id;
if (editableLimit.value[categoryId] !== undefined) {
try {
await updateLimitOnBackend(categoryId, editableLimit.value[categoryId]);
category.name.currentLimit = editableLimit.value[categoryId]; // Обновляем локально
} catch (error) {
console.error("Ошибка обновления лимита:", error);
}
}
editingCategoryId.value = null; // Закрываем input
};
// Функция для отправки данных на бэкенд (замени URL и метод)
const updateLimitOnBackend = async (categoryId, newLimit) => {
await fetch(`/api/categories/${categoryId}/limit`, {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({limit: newLimit}),
});
};
const toast = useToast();
const budgetInfo = ref<Budget>();
const fetchBudgetInfo = async (test) => {
try {
await getBudgetInfo(route.params.id).then((data) => {
budget.value = data
plannedExpenses.value = budget.value?.plannedExpenses
plannedIncomes.value = budget.value?.plannedIncomes
transactions.value = budget.value?.transactions
categories.value = budget.value?.categories
incomeCategories.value = budget.value?.incomeCategories
updateLoading.value = false
}
).catch((error) => {
loading.value = false
updateLoading.value = false
toast.add({
severity: 'error',
summary: 'Ошибка!',
detail: error.response?.data?.message || 'Ошибка при создании транзакции',
life: 3000
});
})
} catch (error) {
toast.add({severity: 'error', summary: "Ошибка при получении бюджета", detail: error.message, life: 3000});
}
updateLoading.value = false
loading.value = false
}
const updateBudgetCategory = async (category, newValue) => {
category.currentLimit = newValue
// loading.value = true
await updateBudgetCategoryRequest(budget.value.id, category)
await fetchBudgetInfo()
setTimeout(async () => {
await fetchWarns()
}, 0)
editingCategoryId.value = null
}
const twentyFour = computed(() => {
let twentyFour = new Date(budget.value?.dateFrom)
twentyFour.setDate(24)
return twentyFour
})
const incomesByPeriod = computed(() => {
let incomesUntil25 = 0
let incomesFrom25 = 0
plannedIncomes.value.forEach((i) => {
i.date = new Date(i.date)
if (i.date >= budget.value?.dateFrom && i.date <= twentyFour.value) {
incomesUntil25 += i.amount
} else {
incomesFrom25 += i.amount
}
})
return [incomesUntil25, incomesFrom25]
})
const expensesByPeriod = computed(() => {
let expensesUntil25 = 0
let expensesFrom25 = 0
let totalPlannedExpensesSum = 0
plannedExpenses.value.forEach((i) => {
i.date = new Date(i.date)
if (i.date >= budget.value?.dateFrom && i.date <= twentyFour.value) {
expensesUntil25 += i.amount
} else {
expensesFrom25 += i.amount
}
totalPlannedExpensesSum += i.amount
})
categories.value.forEach((i) => {
expensesUntil25 += (i.currentLimit - i.currentPlanned) / 2
expensesFrom25 += (i.currentLimit - i.currentPlanned) / 2
})
return [expensesUntil25, expensesFrom25]
})
const pieChartData = computed(() => {
let labels = []
let values = []
categories.value.forEach((i) => {
labels.push(i.category.name)
values.push(i.currentLimit / totalExpenses.value)
})
return {
labels: labels,
datasets: [
{
clip: {left: 100, top: false, right: 50, bottom: 0},
radius: '100%',
data: values,
backgroundColor: [
'rgba(6, 182, 212, 0.2)', 'rgba(249, 115, 22, 0.2)', 'rgba(50, 205, 50, 0.2)',
'rgba(255, 99, 132, 0.2)', 'rgba(54, 162, 235, 0.2)', 'rgba(255, 206, 86, 0.2)',
'rgba(75, 192, 192, 0.2)', 'rgba(153, 102, 255, 0.2)', 'rgba(255, 159, 64, 0.2)',
'rgba(123, 239, 178, 0.2)', 'rgba(255, 105, 180, 0.2)', 'rgba(147, 112, 219, 0.2)',
'rgba(60, 179, 113, 0.2)', 'rgba(100, 149, 237, 0.2)', 'rgba(220, 20, 60, 0.2)',
'rgba(255, 140, 0, 0.2)', 'rgba(72, 61, 139, 0.2)', 'rgba(210, 105, 30, 0.2)',
'rgba(106, 90, 205, 0.2)', 'rgba(199, 21, 133, 0.2)', 'rgba(32, 178, 170, 0.2)',
'rgba(65, 105, 225, 0.2)', 'rgba(218, 165, 32, 0.2)', 'rgba(255, 127, 80, 0.2)',
'rgba(46, 139, 87, 0.2)', 'rgba(139, 69, 19, 0.2)', 'rgba(75, 0, 130, 0.2)',
'rgba(255, 69, 0, 0.2)', 'rgba(244, 164, 96, 0.2)'
],
hoverBackgroundColor: [
'rgba(6, 182, 212, 1)', 'rgba(249, 115, 22, 1)', 'rgba(50, 205, 50, 1)',
'rgba(255, 99, 132, 1)', 'rgba(54, 162, 235, 1)', 'rgba(255, 206, 86, 1)',
'rgba(75, 192, 192, 1)', 'rgba(153, 102, 255, 1)', 'rgba(255, 159, 64, 1)',
'rgba(123, 239, 178, 1)', 'rgba(255, 105, 180, 1)', 'rgba(147, 112, 219, 1)',
'rgba(60, 179, 113, 1)', 'rgba(100, 149, 237, 1)', 'rgba(220, 20, 60, 1)',
'rgba(255, 140, 0, 1)', 'rgba(72, 61, 139, 1)', 'rgba(210, 105, 30, 1)',
'rgba(106, 90, 205, 1)', 'rgba(199, 21, 133, 1)', 'rgba(32, 178, 170, 1)',
'rgba(65, 105, 225, 1)', 'rgba(218, 165, 32, 1)', 'rgba(255, 127, 80, 1)',
'rgba(46, 139, 87, 1)', 'rgba(139, 69, 19, 1)', 'rgba(75, 0, 130, 1)',
'rgba(255, 69, 0, 1)', 'rgba(244, 164, 96, 1)'
]
}
]
};
})
const pieChartOptions = ref({
layout: {
padding: {
left: 50, // Добавляем отступ слева
right: 50 // Добавляем отступ справа
}
},
plugins: {
legend: {
display: false // Отключаем легенду
},
datalabels: {
anchor: 'end', // Позиция метки относительно данных
align: 'top', // Выравнивание метки
formatter: (value, context) => {
const label = context.chart.data.labels[context.dataIndex];
const percentage = (value * 100).toFixed(0);
return percentage >= 2 ? `${label} ${percentage}%` : '';
},
color: 'black', // Цвет текста метки
font: {
size: 12 // Устанавливаем меньший размер шрифта
}
},
tooltip: {
enabled: true, // Включить tooltips
callbacks: {
title: function (tooltipItems) {
return tooltipItems[0].dataset.label;
},
label: function (tooltipItems) {
return Number(tooltipItems.formattedValue * 100).toFixed(0) + '%';
}
}
}
}
});
const incomeExpenseChartData = computed(() => {
return {
labels: ['до 25 ', 'с 25'],
datasets: [
{
label: 'Пополнения',
data: incomesByPeriod.value,
backgroundColor: ['rgba(6, 182, 212, 0.2)', 'rgba(6, 182, 212, 0.2)'],
borderColor: ['rgb(6, 182, 212)', 'rgb(6, 182, 212)'],
borderWidth: 1
},
{
label: 'Расходы',
data: expensesByPeriod.value,
backgroundColor: ['rgba(249, 115, 22, 0.2)', 'rgba(249, 115, 22, 0.2)'],
borderColor: ['rgb(249, 115, 22)', 'rgb(249, 115, 22)'],
borderWidth: 1
}
]
}
})
const incomeExpenseChartOptions = ref({
plugins: {
legend: {
display: false // Отключаем легенду
},
datalabels: {
anchor: 'end', // Позиция метки относительно данных
align: 'top', // Выравнивание метки
formatter: (value) => {
// const label = context.chart.data.labels[context.dataIndex];
return formatAmount(value) + '₽';
// return percentage >= 2 ? `${percentage} ` : '';
},
color: 'black', // Цвет текста метки
font: {
size: 12 // Устанавливаем меньший размер шрифта
}
},
tooltip: {
enabled: true, // Включить tooltips
callbacks: {
title: function (tooltipItems) {
return tooltipItems[0].dataset.label;
},
label: function (tooltipItems) {
return formatAmount(tooltipItems.raw) + '₽';
}
}
}
}
})
const fetchWarns = async (hidden: Boolean = null) => {
warns.value = await getWarns(route.params.id, hidden)
}
const catsExpanded = ref(true);
const expandCats = (value: boolean) => {
catsExpanded.value = value;
// Обновляем состояние для категорий доходов
for (const categoryId in incomeCategoriesState) {
if (Object.prototype.hasOwnProperty.call(incomeCategoriesState, categoryId)) {
incomeCategoriesState[categoryId].isOpened = value;
}
}
// Обновляем состояние для категорий расходов
for (const categoryId in expenseCategoriesState) {
if (Object.prototype.hasOwnProperty.call(expenseCategoriesState, categoryId)) {
expenseCategoriesState[categoryId].isOpened = value;
}
}
};
const calendarOpened = ref(localStorage.getItem('budgetCalendarOpened') === "1");
// Используем computed для получения значения
const calendarExpanded = computed(() => calendarOpened.value);
// Функция для переключения состояния
const toggleCalendar = () => {
// Переключаем состояние
calendarOpened.value = !calendarOpened.value;
// Сохраняем в localStorage
localStorage.setItem('budgetCalendarOpened', calendarOpened.value ? "1" : "0");
};
const calendar = ref<{ date: Date, dateStr: string, expenses: any[], expensesSum: number }[]>([]);
watch([budget, plannedExpenses], () => {
if (!budget.value?.dateFrom || !budget.value?.dateTo) {
calendar.value = [];
return;
}
const result: { date: Date, dateStr: string, expenses: any[], expensesSum: number, }[] = [];
const startDate = new Date(budget.value.dateFrom);
const endDate = new Date(budget.value.dateTo);
let currentDate = new Date(startDate);
let lastDateWithExpenses = null;
let periodStart = null;
while (currentDate <= endDate) {
const formattedDate = currentDate.toISOString().split("T")[0];
const expenses = plannedExpenses.value.filter((transaction) => {
return transaction.date === formattedDate;
});
const expensesSum = expenses.reduce((sum, expense) => {
return !expense.isDone ? sum + (expense.amount || 0) : sum;
}, 0);
if (expenses.length == 0) {
if (!periodStart) periodStart = new Date(currentDate); // Сохраняем начало периода без трат
} else {
if (lastDateWithExpenses && periodStart) {
// Добавляем период без трат, если он был
result.push({
date: currentDate,
dateStr: periodStart.toLocaleDateString('ru-RU') === new Date(new Date(currentDate).setDate(currentDate.getDate() - 1)).toLocaleDateString('ru-RU')
? periodStart.getUTCDate() + " " + getMonthName2(periodStart.getMonth(), "род") + " " + periodStart.getUTCFullYear() + ' трат нет.'
: `В период с ${periodStart.toLocaleDateString('ru-RU')} по ${new Date(new Date(currentDate).setDate(currentDate.getDate() - 1)).toLocaleDateString('ru-RU')} трат нет.`,
expenses: [],
expensesSum: 0,
});
periodStart = null;
}
// Добавляем день с тратами
lastDateWithExpenses = new Date(currentDate)
result.push({
date: currentDate,
dateStr: currentDate.getUTCDate() + " " + getMonthName2(currentDate.getMonth(), "род") + " " + currentDate.getUTCFullYear(),
expenses: expenses,
expensesSum: expensesSum,
});
// lastDateWithExpenses = new Date(currentDate); // Сохраняем дату с тратами
}
currentDate.setDate(currentDate.getDate() + 1);
}
// Добавляем последний период без трат, если он был
if (lastDateWithExpenses && periodStart) {
result.push({
date: currentDate,
dateStr: `В период с ${periodStart.toLocaleDateString('ru-RU')} по ${lastDateWithExpenses.toLocaleDateString('ru-RU')} трат нет.`,
expenses: [],
expensesSum: 0
});
periodStart = null;
}
calendar.value = result;
}, {immediate: true});
const spaceStore = useSpaceStore()
const selectedSpace = computed(() => spaceStore.space)
watch(
() => selectedSpace.value,
async (newValue, oldValue) => {
if (newValue != oldValue || !oldValue) {
try {
loading.value = true;
// Если выбранный space изменился, получаем новую информацию о бюджете
fetchBudgetInfo()
} catch (error) {
console.error('Error fetching budget infos:', error);
}
}
}
);
onMounted(() => {
updateLoading.value = true;
if (selectedSpace.value) {
fetchBudgetInfo()
fetchWarns()
}
EventBus.on('transactions-updated', fetchBudgetInfo, true);
})
onUnmounted(async () => {
EventBus.off('transactions-updated', fetchBudgetInfo);
})
</script>
<template>
<Toast/>
<LoadingView v-if="loading"/>
<div v-else class="px-4 bg-gray-100 h-full ">
<div class=" flex flex-col gap-3">
<div class="flex flex-row justify-between ">
<div class="flex flex-col gap-2">
<h2 class="text-4xl font-bold">Бюджет {{ budget.name }} </h2>
<div class="flex flex-row gap-2 text-xl">{{ formatDate(budget.dateFrom) }} -
{{ formatDate(budget.dateTo) }}
</div>
</div>
<div class="flex flex-col gap-2 justify-center">
<button class="flex flex-row bg-white py-6 px-4 shadow-lg rounded-lg items-center h-6 justify-center gap-2"
@click="warnsOpened = !warnsOpened">
<span class="bg-gray-300 p-1 rounded font-bold">{{
warns ? warns.length : 0
}}</span><span>Уведомлений</span>
</button>
<div v-if="warnsOpened"
class="absolute h-fit max-h-128 w-128 overflow-auto bg-white shadow-lg rounded-lg top-32 right-4 z-50">
<div v-if="checkWarnsExists" class="flex flex-col p-4">
<div v-for="warn in warns">
<div class="flex flex-row items-center gap-2 justify-between">
<div class="flex flex-row items-center gap-2">
<div class="text-2xl">{{ warn.message.icon }}</div>
<div class="flex flex-col">
<span class="font-bold">{{ warn.message.title }}</span>
<span v-html="warn.message.body"></span>
</div>
</div>
<button @click="hideWarn(warn.id)"><i class="pi pi-times" style="font-size: 0.7rem"></i></button>
</div>
<Divider/>
</div>
</div>
<div v-else class="flex items-center justify-center p-16">
<button @click="fetchWarns(true)">Показать скрытые</button>
</div>
</div>
</div>
</div>
<!-- hui {{calendar}}-->
<div class="flex flex-col gap-2">
<!-- Аналитика и плановые доходы/расходы -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start ">
<div class="flex flex-col gap-4">
<!-- Блок Аналитики (25%) -->
<div
class="bg-white p-4 shadow-lg rounded-lg flex flex-col gap-4 items-start ">
<h3 class="text-xl font-bold">Аналитика</h3>
<SelectButton v-model="selectedChart" :options="modes" optionLabel="label" optionIcon="icon">
<template #option="slotProps">
<i :class="slotProps.option.icon"></i>
</template>
</SelectButton>
<Chart v-if="selectedChart.value=='bar'" type="bar" :data="incomeExpenseChartData"
:options="incomeExpenseChartOptions" class="!w-full"
style="width: 100%"/>
<Chart v-if="selectedChart.value=='pie'" type="pie" :data="pieChartData" :options="pieChartOptions"
class="chart "/>
<div class="flex gap-5 items-center justify-items-center w-full ">
<div class="w-full">
<button class="grid grid-cols-2 gap-5 items-center w-full" @click="detailedShowed = !detailedShowed">
<div class="flex flex-col items-center font-bold ">
<h4 class="text-lg font-bold">Поступления</h4>
<div class="font-bold bg-gray-100 p-1 rounded-lg box-shadow-inner w-full text-center">
+{{ formatAmount(totalIncomes) }}
</div>
<!-- <p>Total Incomes</p>-->
</div>
<div class="flex flex-col items-center ">
<h4 class="text-lg font-bold ">Расходы</h4>
<div class="font-bold bg-gray-100 p-1 rounded-lg box-shadow-inner w-full text-center"
:class="totalExpenses > totalIncomes ? ' text-red-700' : ''">
-{{ formatAmount(totalExpenses) }} ({{ formatAmount(totalExpenses - totalIncomes) }})
</div>
</div>
</button>
<div class="grid grid-cols-2 !gap-1 mt-4" :class="detailedShowed ? 'block' : 'hidden'">
<div class="flex flex-col items-center font-bold ">
<p class="font-light ">в первый период</p>
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner text-center w-full ">
+{{ formatAmount(incomesByPeriod[0]) }}
</div>
</div>
<div class="flex flex-col items-center">
<p class="font-light ">в первый период</p>
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner text-center w-full ">
-{{ formatAmount(expensesByPeriod[0]) }}
</div>
</div>
<div class="flex flex-col items-center">
<p class="font-light ">во второй период</p>
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner text-center w-full ">
+{{ formatAmount(incomesByPeriod[1]) }}
</div>
</div>
<div class="flex flex-col items-center">
<p class="font-light ">во второй период</p>
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner text-center w-full ">
-{{ formatAmount(expensesByPeriod[1]) }}
</div>
</div>
</div>
</div>
</div>
<div class="grid gap-5 items-center justify-items-center w-full">
<div class="w-full">
<button class="grid grid-cols-3 justify-between gap-5 items-end w-full"
@click="detailedShowed = !detailedShowed">
<div class="flex flex-col items-center">
<h4 class="text-sm lg:text-base">Долги</h4>
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner w-full text-center">
{{ loansRatio.toFixed(0) }} %
</div>
<!-- <p>Total Incomes</p>-->
</div>
<div class="flex flex-col items-center ">
<span class="text-sm lg:text-base">Сбережения</span>
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner w-full text-center"
:class="savingRatio < 30 ? '!font-bold text-red-700' : ''">
{{ savingRatio.toFixed(0) }} %
</div>
</div>
<div class="flex flex-col items-center ">
<h4 class="text-sm lg:text-base">Ежедневные</h4>
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner w-full text-center">
{{ dailyRatio.toFixed(0) }} %
</div>
</div>
</button>
</div>
<div class="w-full">
<button class="grid grid-cols-2 justify-between gap-5 items-end w-full"
@click="detailedShowed = !detailedShowed">
<div class="flex flex-col items-center">
<h4 class="text-sm lg:text-base">Факт. поступления </h4>
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner w-full text-center">
{{ formatAmount(totalInstantIncomes) }}
</div>
<!-- <p>Total Incomes</p>-->
</div>
<div class="flex flex-col items-center ">
<span class="text-sm lg:text-base">Факт. траты 📛</span>
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner w-full text-center">
{{ formatAmount(totalInstantExpenses) }}
</div>
</div>
</button>
<div class="flex flex-col items-center w-full ">
<span class="text-sm lg:text-base">Остаток на траты</span>
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner w-full text-center">
{{ formatAmount(totalInstantIncomes - totalInstantExpenses) }}
</div>
</div>
<div class="grid grid-cols-2 !gap-1 mt-4" :class="detailedShowed ? 'block' : 'hidden'">
<div v-for="categorySum in transactionCategoriesSums" class="flex flex-col items-center font-bold ">
<p class="font-light ">{{ categorySum.category.icon }} {{ categorySum.category.name }}</p>
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner text-center w-full ">
{{ formatAmount(categorySum.sum) }}
</div>
</div>
</div>
</div>
</div>
<ProgressBar :value="value" class="mt-2 col-span-2" style="height: 1rem !important;"></ProgressBar>
</div>
<div class=" h-fit overflow-y-auto gap-4 flex-col row-span-6 hidden lg:flex">
<div class="flex flex-row gap-4">
<h3 class="text-2xl font-bold">Транзакции</h3>
<button @click="openDrawer('INSTANT', 'EXPENSE')">
<span class="font-light text-sm">+ Добавить</span>
</button>
</div>
<div class=" flex gap-2 overflow-x-auto ">
<button v-for="categorySum in transactionCategoriesSums"
@click="selectCategoryType(categorySum.category.id)"
class="rounded-xl border p-1 bg-white border-gray-300 mb-2 min-w-fit px-2"
:class="selectedCategoryId == categorySum.category.id ? '!bg-blue-100' : ''">
<p><span class="text-sm font-bold">{{ categorySum.category.name }}</span>: {{ categorySum.sum }} </p>
</button>
</div>
<div class="flex flex-col gap-1 max-h-tlist overflow-y-auto ">
<BudgetTransactionView v-for="transaction in filteredTransactions" :key="transaction.id"
:transaction="transaction"
:is-list="true"
@transaction-updated="updateTransactions"
@transaction-checked="updateTransactions"
/>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 row-span-3 col-span-2">
<div>
<!-- Планируемые доходы -->
<div>
<div class="flex flex-row gap-4 items-center mb-4">
<h3 class="text-2xl font-bold text-emerald-500 ">Поступления</h3>
<button @click="openDrawer('PLANNED', 'INCOME')">
<!-- <i class="pi pi-plus-circle text-green-500" style="font-size: 1rem;"/>-->
<span class="font-light text-sm">+ Добавить</span>
</button>
<button class="font-light text-sm" @click="catsExpanded ? expandCats(false) : expandCats(true)">
{{ catsExpanded ? 'Скрыть все' : 'Раскрыть все' }}
</button>
</div>
<ul class="space-y-2">
<li v-for="(category, categoryId) in categoriesIncomeTransactions" :key="categoryId"
class="flex flex-col p-4 shadow-lg rounded-lg bg-white transition-transform duration-300 ">
<div class="flex flex-row justify-between w-full items-center transition-transform duration-300">
<!-- {{category}}-->
<span class="text-lg font-bold items-center">{{
category.name.category.name
}} {{ category.name.icon }}</span>
<div class="flex flex-row gap-4">
<span>{{
formatAmount(category.name.currentSpent)
}} / {{ formatAmount(category.name.currentLimit) }} </span>
<button :class="{'rotate-90': incomeCategoriesState[categoryId].isOpened}"
@click="incomeCategoriesState[categoryId].isOpened = !incomeCategoriesState[categoryId].isOpened">
<i class="pi pi-angle-right transition-transform duration-300 text-5xl"></i>
</button>
</div>
</div>
<transition
enter-active-class="transition ease-out duration-300"
enter-from-class="opacity-0 transform -translate-y-2"
enter-to-class="opacity-100 transform translate-y-0"
leave-active-class="transition ease-in duration-300"
leave-from-class="opacity-100 transform translate-y-0"
leave-to-class="opacity-0 transform -translate-y-2"
>
<div v-if="incomeCategoriesState[categoryId].isOpened">
<BudgetTransactionView
v-for="transaction in category.transactions"
:key="transaction._id"
:transaction="transaction"
:is-list="false"
@transaction-updated="updateTransactions"
@transaction-checked="updateTransactions"
/>
</div>
</transition>
</li>
</ul>
</div>
</div>
<div>
<!-- Планируемые расходы -->
<div class="pb-4">
<div class="flex flex-row gap-4 items-center mb-4">
<h3 class="text-2xl font-bold text-rose-500 ">Расходы</h3>
<button @click="openDrawer('PLANNED', 'EXPENSE')">
<span class="font-light text-sm">+ Добавить</span>
</button>
</div>
<div class="flex flex-col gap-4 pb-4">
<div class="flex flex-row gap-2"><span class="text-lg font-bold items-center">Календарь</span>
<button class="font-light text-sm" @click="toggleCalendar">
{{ calendarExpanded ? 'Скрыть' : 'Раскрыть' }}
</button>
</div>
<div v-for="day, dayOne in calendar" class="flex flex-col justify-between p-4 shadow-lg rounded-lg "
v-if="calendarExpanded == '1'"
:class="day.date.toISOString().split('T')[0] == new Date().toISOString().split('T')[0]? 'bg-emerald-200' : 'bg-white '">
<div class="flex flex-row gap-2 items-center ">
<span class="font-bold text-xl">{{ day.dateStr }} </span>
<span
v-if="day.expensesSum>0">Трат по плану: {{ formatAmount(day.expensesSum) }} </span>
</div>
<BudgetTransactionView v-for="expense in day.expenses" :key="expense.id"
:transaction="expense"
:is-list="false"
@transaction-updated="updateTransactions"
@transaction-checked="updateTransactions"
/>
</div>
</div>
<ul class="grid grid-cols-1 md:grid-cols-2 gap-4">
<li v-for="(category, categoryId) in categoriesTransactions" :key="categoryId"
class="flex flex-col justify-between p-4 shadow-lg rounded-lg bg-white ">
<div class="">
<div class="flex flex-row justify-between w-full items-center">
<div class="flex flex-row col-span-2 gap-2 items-center">
<span class=" font-bold line-clamp-1" style="font-size: 1.25rem">{{
category.name.category.icon
}} {{ category.name.category.name }}</span>
<button @click="openDrawer('INSTANT', 'EXPENSE', category.name.category.id)">
<i class="pi pi-plus-circle"/>
</button>
</div>
<div class="flex flex-row w-fit gap-1 justify-between">
<span>{{ formatAmount(category.name.currentSpent) }} </span>
<span> / </span>
<!-- Если редактируемая категория показываем input -->
<input
v-if="editingCategoryId === category.name.category.id"
v-model.number="editableLimit[category.name.category.id]"
@blur="(category)"
@keyup.enter="updateBudgetCategory(category.name, editableLimit[category.name.category.id])"
type="number"
class=" border-b w-20 p-0 "
autofocus
/>
<!-- Если НЕ редактируем, показываем текст -->
<span v-else @click="startEditing(category)"
class=" p-0 cursor-pointer hover:underline text-left"> {{
formatAmount(category.name.currentLimit)
}} </span>
<button v-if="category.transactions.length>0"
:class="{'rotate-90': expenseCategoriesState[categoryId].isOpened}"
@click="expenseCategoriesState[categoryId].isOpened = !expenseCategoriesState[categoryId].isOpened">
<i class="pi pi-angle-right transition-transform duration-300 text-5xl"></i>
</button>
</div>
</div>
<transition
enter-active-class="transition ease-out duration-300"
enter-from-class="opacity-0 transform -translate-y-2"
enter-to-class="opacity-100 transform translate-y-0"
leave-active-class="transition ease-in duration-300"
leave-from-class="opacity-100 transform translate-y-0"
leave-to-class="opacity-0 transform -translate-y-2"
>
<div v-if="expenseCategoriesState[categoryId].isOpened" class="pt-2 pl-2">
<BudgetTransactionView
v-for="transaction in category.transactions"
:key="transaction._id"
:transaction="transaction"
:is-list="false"
@transaction-updated="updateTransactions"
@transaction-checked="updateTransactions"
/>
<div
class="flex justify-between bg-white min-w-fit max-h-fit flex-row items-center gap-4 w-full ">
<div>
<p class=" font-bold text-gray-700 dark:text-gray-400">
🗓 Остаток на внеплановые
</p>
</div>
<div class="text-lg line-clamp-1 ">
{{
formatAmount(category.name.currentLimit - category.name.currentPlanned)
}}
</div>
</div>
</div>
</transition>
</div>
<div class="flex flex-col gap-0">
<div class="flex flex-row justify-between w-full items-center">
<span class="font-bold">🗓 Остаток всего:</span>
<span class="font-bold text-lg">{{
formatAmount(category.name.currentLimit - category.name.currentSpent)
}} </span>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class=" h-fit gap-4 flex-col row-span-6 lg:hidden ">
<div class="flex flex-row ">
<h3 class="text-2xl font-bold">Транзакции</h3>
<button @click="openDrawer('INSTANT', 'EXPENSE')">
<span class="font-light text-sm">+ Добавить</span>
</button>
</div>
<div class=" flex gap-2 overflow-x-auto">
<button v-for="categorySum in transactionCategoriesSums"
@click="selectCategoryType(categorySum.category.id)"
class="rounded-xl border p-1 bg-white border-gray-300 mb-2 min-w-fit px-2"
:class="selectedCategoryId == categorySum.category.id ? '!bg-blue-100' : ''">
<p><span class="text-sm font-bold">{{ categorySum.category.name }}</span>: {{ categorySum.sum }} </p>
</button>
</div>
<div class="grid grid-cols-1 gap-1 max-h-tlist overflow-y-auto ">
<BudgetTransactionView v-for="transaction in filteredTransactions" :key="transaction.id"
:transaction="transaction"
:is-list="true"
@transaction-updated="updateTransactions"
/>
</div>
</div>
</div>
</div>
</div>
<!-- <TransactionForm v-if="drawerOpened" :visible="drawerOpened" :transaction-type="transactionType"-->
<!-- :category-type="categoryType" @close-drawer="closeDrawer" @transaction-updated="updateTransactions"-->
<!-- @delete-transaction="updateTransactions"-->
<!-- @create-transaction="updateTransactions"/>-->
</div>
<div id="footer" class="h-24 bg-gray-100"/>
</template>
<style scoped>
/* Добавляем стили для стилизации */
.card {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.max-h-tlist {
//max-height: 1170px; /* Ограничение высоты списка */
}
.box-shadow-inner {
box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.3);
}
.chart {
width: 70%;
}
</style>