1080 lines
43 KiB
Vue
1080 lines
43 KiB
Vue
<script setup lang="ts" xmlns="http://www.w3.org/1999/html">
|
||
import {computed, onMounted, ref, watch, reactive} from 'vue';
|
||
import Chart from 'primevue/chart';
|
||
import BudgetTransactionView from "@/components/budgets/BudgetTransactionView.vue";
|
||
import {
|
||
getBudgetCategories,
|
||
getBudgetInfo,
|
||
getBudgetTransactions,
|
||
updateBudgetCategoryRequest,
|
||
getWarns,
|
||
hideWarnRequest
|
||
} from "@/services/budgetsService";
|
||
import {Budget, BudgetCategory, Warn} from "@/models/Budget";
|
||
import {useRoute} from "vue-router";
|
||
import {formatAmount, formatDate} from "@/utils/utils";
|
||
import ProgressBar from "primevue/progressbar";
|
||
import ProgressSpinner from "primevue/progressspinner";
|
||
import BudgetCategoryView from "@/components/budgets/BudgetCategoryView.vue";
|
||
import {Transaction} from "@/models/Transaction";
|
||
import Toast from "primevue/toast";
|
||
import Button from "primevue/button";
|
||
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 TransactionForm from "@/components/transactions/TransactionForm.vue";
|
||
import Checkbox from "primevue/checkbox";
|
||
|
||
// Зарегистрируем плагин
|
||
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(() => {
|
||
console.log(warns?.value && warns.value.length > 0 ? "true" : "false");
|
||
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 openDrawer = (selectedTransactionType = null, selectedCategoryType = null) => {
|
||
if (selectedTransactionType && selectedCategoryType) {
|
||
transactionType.value = selectedTransactionType;
|
||
categoryType.value = selectedCategoryType;
|
||
} else if (selectedTransactionType) {
|
||
transactionType.value = selectedTransactionType;
|
||
categoryType.value = 'EXPENSE'
|
||
}
|
||
|
||
drawerOpened.value = 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 budgetInfo = ref<Budget>();
|
||
const fetchBudgetInfo = async () => {
|
||
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
|
||
}
|
||
)
|
||
|
||
}
|
||
|
||
|
||
const updateBudgetCategory = async (category) => {
|
||
|
||
// 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;
|
||
console.log(`Категория ${categoryId}: isOpened = ${incomeCategoriesState[categoryId].isOpened}`);
|
||
}
|
||
}
|
||
|
||
// Обновляем состояние для категорий расходов
|
||
for (const categoryId in expenseCategoriesState) {
|
||
if (Object.prototype.hasOwnProperty.call(expenseCategoriesState, categoryId)) {
|
||
expenseCategoriesState[categoryId].isOpened = value;
|
||
console.log(`Категория ${categoryId}: isOpened = ${expenseCategoriesState[categoryId].isOpened}`);
|
||
}
|
||
}
|
||
};
|
||
|
||
onMounted(async () => {
|
||
|
||
try {
|
||
await Promise.all([
|
||
fetchBudgetInfo(),
|
||
fetchWarns()
|
||
// budget.value = await getBudgetInfo(route.params.id),
|
||
// fetchPlannedIncomes(),
|
||
// fetchPlannedExpenses(),
|
||
// fetchBudgetCategories(),
|
||
// fetchBudgetTransactions(),
|
||
]);
|
||
} catch (error) {
|
||
console.error('Error during fetching data:', error);
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<Toast/>
|
||
<LoadingView v-if="loading"/>
|
||
|
||
<div v-else class="px-4 bg-gray-100 h-full ">
|
||
<div v-if="updateLoading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-50">
|
||
<ProgressSpinner
|
||
v-if="updateLoading"
|
||
class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-50"
|
||
style="width: 50px; height: 50px;"
|
||
strokeWidth="8"
|
||
fill="transparent"
|
||
animationDuration=".5s"
|
||
aria-label="Custom ProgressSpinner"
|
||
/>
|
||
</div>
|
||
<div :class="!updateLoading ? '' : 'h-fit bg-white opacity-50 z-0 '" 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>
|
||
<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-full 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', 'INCOME')">
|
||
<span class="font-light text-sm">+ Добавить</span>
|
||
</button>
|
||
</div>
|
||
<ul class="grid 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">
|
||
<span class="text-2xl font-bold line-clamp-1">{{
|
||
category.name.category.icon
|
||
}} {{ category.name.category.name }}</span>
|
||
<div class="flex flex-row line-clamp-1">
|
||
|
||
<span>{{ formatAmount(category.name.currentSpent) }} / </span>
|
||
<!-- Если редактируемая категория — показываем input -->
|
||
<input
|
||
v-if="editingCategoryId === category.name.category.id"
|
||
v-model.number="category.name.currentLimit"
|
||
@blur="(category)"
|
||
@keyup.enter="updateBudgetCategory(category.name)"
|
||
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-full 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> |