tags and new analytics new in budget
This commit is contained in:
@@ -137,9 +137,12 @@ const chartOptions = {
|
||||
// Преобразование данных для таблицы
|
||||
const prepareTableData = (categories) => {
|
||||
// 1. Собираем все уникальные значения date из monthlySums
|
||||
|
||||
const onlyExpenses = categories.filter((it) => it.categoryType == "EXPENSE")
|
||||
|
||||
const allDates = [
|
||||
...new Set(
|
||||
categories.flatMap((category) =>
|
||||
onlyExpenses.flatMap((category) =>
|
||||
category.monthlySums.map((sumItem) => sumItem.date)
|
||||
)
|
||||
),
|
||||
@@ -164,7 +167,7 @@ const prepareTableData = (categories) => {
|
||||
// console.log(tableColumns.value[0].field);
|
||||
const sums = {}
|
||||
// 4. Формируем строки (для каждой категории)
|
||||
const rows = categories.map((category) => {
|
||||
const rows = onlyExpenses.map((category) => {
|
||||
// Начинаем со строки, где есть поле с именем категории
|
||||
const row = {category: category.categoryIcon + " " + category.categoryName};
|
||||
let categorySum = 0
|
||||
@@ -218,6 +221,7 @@ const selectedSpace = computed(() => spaceStore.space)
|
||||
|
||||
watch( selectedSpace, async (newValue, oldValue) => {
|
||||
if (newValue != oldValue) {
|
||||
await fetchCategoriesCatalog()
|
||||
await fetchCategoriesSums()
|
||||
}
|
||||
})
|
||||
@@ -249,26 +253,30 @@ const fetchCategoriesCatalog = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([ fetchCategoriesCatalog()])
|
||||
|
||||
if (selectedSpace.value) {
|
||||
await fetchCategoriesCatalog()
|
||||
await fetchCategoriesSums();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<LoadingView v-if="loading"/>
|
||||
|
||||
<div v-else class="p-4 pb-20 lg:pb-4 bg-gray-100 flex flex-col gap-4 h-full items-center justify-items-center ">
|
||||
<div v-if="!selectedSpace" class="flex w-full h-full items-center justify-center">
|
||||
<p>Сперва нужно выбрать Пространство.
|
||||
<button class="text-blue-500 hover:underline" @click="router.push('/spaces').then((res) => router.go(0))">Перейти</button>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="dataTableCategories.length==0">
|
||||
|
||||
<div v-else-if="dataTableCategories.length <= 1 && dataTableCategories.keys().next()">
|
||||
Начните записывать траты и здесь появится информация.
|
||||
</div>
|
||||
<div v-else class="!items-center w-5/6 bg-white">
|
||||
<Accordion value="1" class=" " @tab-open="isChartOpen=true"
|
||||
<div v-else class="!flex !flex-col !items-center w-5/6 !gap-4 ">
|
||||
<Accordion value="1" class="!w-full " @tab-open="isChartOpen=true"
|
||||
@tab-close="closeChart">
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader>График</AccordionHeader>
|
||||
@@ -291,7 +299,7 @@ onMounted(async () => {
|
||||
<span v-html="catType"/>
|
||||
<ul>
|
||||
<li v-for="cat in category" class="px-2 py-2 hover:bg-blue-50 rounded-md line-clamp- w-full"
|
||||
:class="selectedCategory.id == cat.id? '!bg-emerald-50 text-emerald-700': '' " @click="selectedCategory=cat"> {{cat.name}}</li>
|
||||
:class="selectedCategory.id == cat.id? '!bg-emerald-50 text-emerald-700': '' " @click="selectedCategory=cat"> {{cat.icon}} {{cat.name}}</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -314,9 +322,8 @@ onMounted(async () => {
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
|
||||
|
||||
<DataTable :value="dataTableCategories" responsiveLayout="scroll" filter stripedRows class="w-5/6 items-center">
|
||||
<div class="items-center !w-full bg-white">
|
||||
<DataTable :value="dataTableCategories" responsiveLayout="scroll" filter stripedRows class="">
|
||||
<Column
|
||||
:field="tableColumns[0].field"
|
||||
:header="tableColumns[0].header"
|
||||
@@ -337,6 +344,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -59,28 +59,26 @@ const errors = ref({ username: '', password: '' })
|
||||
|
||||
// Получение tg_id (Telegram ID)
|
||||
const tg_id = computed(() => {
|
||||
|
||||
if (window.Telegram?.WebApp) {
|
||||
const tg = window.Telegram.WebApp
|
||||
tg.expand() // Разворачиваем WebApp
|
||||
return tg.initDataUnsafe.user?.id ?? null
|
||||
}
|
||||
return null
|
||||
return ''
|
||||
})
|
||||
|
||||
// Авто-вход по Telegram ID
|
||||
const autoLoginWithTgId = async () => {
|
||||
if (tg_id.value) {
|
||||
try {
|
||||
const response = await apiClient.post('/auth/token/tg', { tg_id: tg_id.value })
|
||||
const token = response.data.access_token
|
||||
localStorage.setItem('token', token)
|
||||
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
await userStore.login(null, null, tg_id.value)
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Вход выполнен', detail: 'Добро пожаловать!', life: 3000 })
|
||||
// toast.add({ severity: 'success', summary: 'Вход выполнен', detail: 'Добро пожаловать!', life: 3000 })
|
||||
await router.replace(route.query['back'] ? route.query['back'].toString() : '/')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.add({ severity: 'error', summary: 'Ошибка входа', detail: 'Ошибка Telegram авторизации', life: 3000 })
|
||||
// toast.add({ severity: 'error', summary: 'Ошибка входа', detail: 'Ошибка Telegram авторизации', life: 3000 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import Button from "primevue/button";
|
||||
import InputText from "primevue/inputtext";
|
||||
import FloatLabel from "primevue/floatlabel";
|
||||
import DatePicker from "primevue/datepicker";
|
||||
import {onMounted, ref} from "vue";
|
||||
import {computed, onMounted, ref, watch} from "vue";
|
||||
import {getMonthName} from "@/utils/utils";
|
||||
import {Budget} from "@/models/Budget";
|
||||
import {getCategories} from "@/services/categoryService";
|
||||
import {useSpaceStore} from "@/stores/spaceStore";
|
||||
|
||||
const props = defineProps({
|
||||
opened: {
|
||||
@@ -63,9 +64,31 @@ const resetForm = () => {
|
||||
}
|
||||
budget.value.name = getMonthName(budget.value.dateFrom.getMonth()) + ' ' + budget.value.dateFrom.getFullYear();
|
||||
}
|
||||
const spaceStore = useSpaceStore()
|
||||
const selectedSpace = computed(() => spaceStore.space)
|
||||
|
||||
watch(
|
||||
() => selectedSpace.value,
|
||||
async (newValue, oldValue) => {
|
||||
|
||||
if (newValue != oldValue || !oldValue) {
|
||||
try {
|
||||
// loading.value = true;
|
||||
// Если выбранный space изменился, получаем новую информацию о бюджете
|
||||
await fetchCategories()
|
||||
} catch (error) {
|
||||
console.error('Error fetching budget infos:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
resetForm()
|
||||
if (selectedSpace.value) {
|
||||
fetchCategories()
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -91,6 +114,7 @@ onMounted(() => {
|
||||
<Checkbox v-model="createRecurrentPayments" binary/>
|
||||
Создать ежемесячные платежи?
|
||||
</div>
|
||||
<div v-if="categories.length ==0" class="text-red-500 font-bold">Сперва лучше создать категории</div>
|
||||
<div class="flex flex-row gap-2 justify-end items-center">
|
||||
<Button label="Создать" severity="success" icon="pi pi-save" @click="create"/>
|
||||
<Button label="Отмена" severity="secondary" icon="pi pi-times-circle" @click="cancel"/>
|
||||
|
||||
@@ -39,10 +39,10 @@
|
||||
|
||||
<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 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>
|
||||
@@ -50,7 +50,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center p-16">
|
||||
<button @click="fetchWarns(true)">Показать скрытые</button></div>
|
||||
<button @click="fetchWarns(true)">Показать скрытые</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -352,7 +353,7 @@
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref} from 'vue';
|
||||
import {computed, onMounted, ref, watch} from 'vue';
|
||||
import Chart from 'primevue/chart';
|
||||
import BudgetTransactionView from "@/components/budgets/BudgetTransactionView.vue";
|
||||
import {
|
||||
@@ -361,7 +362,7 @@ import {
|
||||
getBudgetTransactions,
|
||||
updateBudgetCategoryRequest,
|
||||
getWarns,
|
||||
hideWarnRequest
|
||||
hideWarnRequest
|
||||
} from "@/services/budgetsService";
|
||||
import {Budget, BudgetCategory, Warn} from "@/models/Budget";
|
||||
import {useRoute} from "vue-router";
|
||||
@@ -378,6 +379,7 @@ 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 {useSpaceStore} from "@/stores/spaceStore";
|
||||
|
||||
// Зарегистрируем плагин
|
||||
ChartJS.register(ChartDataLabels);
|
||||
@@ -563,9 +565,9 @@ const fetchBudgetTransactions = async () => {
|
||||
|
||||
const updateTransactions = async () => {
|
||||
setTimeout(async () => {
|
||||
await Promise.all([fetchBudgetInfo(),fetchWarns()])
|
||||
await Promise.all([fetchBudgetInfo(), fetchWarns()])
|
||||
|
||||
}, )
|
||||
},)
|
||||
|
||||
}
|
||||
|
||||
@@ -607,14 +609,14 @@ const transactionCategoriesSums = computed(() => {
|
||||
|
||||
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
|
||||
updateLoading.value = false
|
||||
}
|
||||
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
|
||||
updateLoading.value = false
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
@@ -623,7 +625,7 @@ const fetchBudgetInfo = async () => {
|
||||
const updateBudgetCategory = async (category) => {
|
||||
|
||||
// loading.value = true
|
||||
await updateBudgetCategoryRequest(budget.value.id, category)
|
||||
await updateBudgetCategoryRequest(budget.value.id, category)
|
||||
await fetchBudgetInfo()
|
||||
setTimeout(async () => {
|
||||
await fetchWarns()
|
||||
@@ -844,19 +846,42 @@ const fetchWarns = async (hidden: Boolean = null) => {
|
||||
warns.value = await getWarns(route.params.id, hidden)
|
||||
}
|
||||
|
||||
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(async () => {
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchBudgetInfo(),
|
||||
if (selectedSpace.value) {
|
||||
fetchBudgetInfo()
|
||||
fetchWarns()
|
||||
// budget.value = await getBudgetInfo(route.params.id),
|
||||
// fetchPlannedIncomes(),
|
||||
// fetchPlannedExpenses(),
|
||||
// fetchBudgetCategories(),
|
||||
// fetchBudgetTransactions(),
|
||||
]);
|
||||
|
||||
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 {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {useDrawerStore} from "@/stores/drawerStore";
|
||||
import {EventBus} from '@/utils/EventBus.ts';
|
||||
import {useToast} from "primevue/usetoast";
|
||||
import {useSpaceStore} from "@/stores/spaceStore";
|
||||
import {CircleProgressBar} from 'circle-progress.vue';
|
||||
|
||||
// Зарегистрируем плагин
|
||||
ChartJS.register(ChartDataLabels);
|
||||
@@ -108,9 +109,17 @@ 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 => {
|
||||
|
||||
categories.value.filter((cat) =>
|
||||
cat.category.tags.some(tag => {
|
||||
|
||||
return tag.code == "savings";
|
||||
})
|
||||
).forEach(cat => {
|
||||
// console.log(cat)
|
||||
value += cat.currentLimit
|
||||
})
|
||||
return value
|
||||
@@ -763,14 +772,14 @@ onUnmounted(async () => {
|
||||
<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) }} -
|
||||
<h2 class="text-4xl font-bold text-gray-700">Бюджет {{ budget.name }} </h2>
|
||||
<div class="flex flex-row gap-2 text-xl text-gray-700">{{ 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"
|
||||
<button class="flex flex-row bg-white py-6 px-4 shadow-md 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
|
||||
@@ -778,7 +787,7 @@ onUnmounted(async () => {
|
||||
</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">
|
||||
class="absolute h-fit max-h-128 max-w-128 overflow-auto bg-white shadow-md 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">
|
||||
@@ -807,143 +816,214 @@ onUnmounted(async () => {
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Аналитика и плановые доходы/расходы -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start ">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 lg: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>
|
||||
<h3 class="text-2xl font-bold text-gray-700">Аналитика</h3>
|
||||
<div class=" flex flex-col gap-3 items-start ">
|
||||
<div class=" bg-white flex p-4 flex-col shadow-md rounded-lg w-full h-full">
|
||||
<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%"/>
|
||||
|
||||
<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>
|
||||
<div></div>
|
||||
<div class=" flex flex-col gap-2 relative border-[0.1rem] rounded-xl p-2 w-full h-full pt-4">
|
||||
<p class="absolute -top-3 left-1/2 -translate-x-1/2 bg-gray-100 px-2 text-gray-700 font-semibold">
|
||||
Запланировано</p>
|
||||
<div class="flex gap-5 items-center justify-items-center w-full ">
|
||||
<button class="flex flex-row gap-1 items-center w-full" @click="detailedShowed = !detailedShowed">
|
||||
<div
|
||||
class="flex flex-col items-center font-bold bg-white p-2 shadow-md rounded-lg w-full h-full ">
|
||||
<div class="group justify-end flex w-full">
|
||||
<i class="pi pi-info-circle w-full text-end" style="font-size: 0.7rem"
|
||||
v-tooltip="'Рассчитывается по принципу сумма плановых поступлений'"/>
|
||||
<!-- <div class="absolute w-fit bg-gray-800 text-white text-xs px-2 py-1 rounded hidden group-hover:block transition-opacity cursor-pointer">-->
|
||||
<!-- Рассчитывается по принципу сумма плановых поступлений-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
<h4 class="text-lg font-bold">Приходы</h4>
|
||||
|
||||
<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 class="font-light 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="flex flex-col items-center bg-white p-2 shadow-md rounded-lg w-full h-full ">
|
||||
<div class="group justify-end flex w-full">
|
||||
<i class="pi pi-info-circle w-full text-end" style="font-size: 0.7rem"
|
||||
v-tooltip="'Рассчитывается по принципу сумма плановых трат + разница между суммой плана категории и лимитом категории'"/>
|
||||
|
||||
<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 class="flex flex-col items-center">
|
||||
<h4 class="text-lg font-bold ">Расходы</h4>
|
||||
|
||||
<div class="font-light text-center ">
|
||||
{{ formatAmount(totalExpenses) }}
|
||||
₽
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
=
|
||||
<div
|
||||
class="flex flex-col items-center font-bold bg-white p-2 shadow-md rounded-lg w-full h-full ">
|
||||
<div class="group justify-end flex w-full">
|
||||
<i class="pi pi-info-circle w-full text-end" style="font-size: 0.7rem"
|
||||
v-tooltip="'Разница между плановыми приходами и расходами'"/>
|
||||
|
||||
</div>
|
||||
<h4 class="text-lg font-bold">Остаток</h4>
|
||||
|
||||
<div class="font-light text-center w-full "
|
||||
:class="totalIncomes - totalExpenses < 0 ? ' text-red-700 !font-bold' : ''">
|
||||
{{ formatAmount(totalIncomes - totalExpenses) }}
|
||||
₽
|
||||
</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 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 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 text-center w-full ">-->
|
||||
<!-- +{{ formatAmount(incomesByPeriod[1]) }} ₽-->
|
||||
<!-- </div>-->
|
||||
|
||||
<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 text-center w-full ">-->
|
||||
<!-- -{{ formatAmount(expensesByPeriod[1]) }} ₽-->
|
||||
<!-- </div>-->
|
||||
<!-- </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 class="grid gap-5 items-center justify-items-center w-full">
|
||||
<div class="w-full ">
|
||||
<button class="flex flex-row justify-between gap-2 items-end w-full"
|
||||
@click="detailedShowed = !detailedShowed">
|
||||
<div
|
||||
class="flex flex-col items-center font-bold bg-white p-2 shadow-md rounded-lg w-full h-full">
|
||||
<div class="group justify-end flex w-full">
|
||||
<i class="pi pi-info-circle w-full text-end" style="font-size: 0.7rem"
|
||||
v-tooltip="'Сумма категории Кредиты и долги в сумме лимитов категорий'"/>
|
||||
|
||||
</div>
|
||||
<h4 class="text-sm lg:text-base">Долги</h4>
|
||||
<div class="font-light text-center w-full ">
|
||||
|
||||
</div>
|
||||
<CircleProgressBar :value="loansRatio.toFixed(0)" :max="100" rounded percentage strokeWidth="12"
|
||||
size="64"/>
|
||||
<!-- <p>Total Incomes</p>-->
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center font-bold bg-white p-2 shadow-md rounded-lg w-full h-full">
|
||||
<div class="group justify-end flex w-full">
|
||||
<i class="pi pi-info-circle w-full text-end" style="font-size: 0.7rem"
|
||||
v-tooltip="'Для корректного подсчета нужно выставить тэг savings на категориях сбережений'"/>
|
||||
|
||||
</div>
|
||||
<span class="text-sm lg:text-base font-bold">Сбережения</span>
|
||||
<div class="font-light text-center w-full text-red-400 "
|
||||
>
|
||||
|
||||
<CircleProgressBar :value="savingRatio.toFixed(0)" :max="100"
|
||||
:colorUnfilled="savingRatio < 30 ? '#FF5533' : '#3BB44A'" rounded
|
||||
percentage strokeWidth="12" size="64"/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center font-bold bg-white p-2 shadow-md rounded-lg w-full h-full">
|
||||
<div class="group justify-end flex w-full">
|
||||
<i class="pi pi-info-circle w-full text-end" style="font-size: 0.7rem"
|
||||
v-tooltip="'Сумма остатка лимитов по остальным категориям'"/>
|
||||
|
||||
</div>
|
||||
<h4 class="text-sm lg:text-base">Ежедневные</h4>
|
||||
<div class="font-light text-center w-full ">
|
||||
<CircleProgressBar :value="dailyRatio.toFixed(0)" :max="100" rounded percentage
|
||||
strokeWidth="12" size="64"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-5 items-center justify-items-center w-full">
|
||||
<div class=" flex flex-col gap-2 relative border-[0.1rem] rounded-xl p-2 w-full h-full pt-4">
|
||||
<p class="absolute -top-3 left-1/2 -translate-x-1/2 bg-gray-100 px-2 text-gray-700 font-semibold">
|
||||
Фактические</p>
|
||||
<div class="w-full">
|
||||
<button class="grid grid-cols-3 justify-between gap-5 items-end w-full"
|
||||
<button class="flex flex-row justify-between gap-1 items-center 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">
|
||||
<div class="flex flex-col items-center font-bold bg-white p-2 shadow-md rounded-lg w-full h-full">
|
||||
<h4 class="text-sm lg:text-base">Приходы</h4>
|
||||
<div class="font-light text-center w-full ">
|
||||
{{ 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">
|
||||
-
|
||||
<div class="flex flex-col items-center font-bold bg-white p-2 shadow-md rounded-lg w-full h-full">
|
||||
<span class="text-sm lg:text-base font-bold">Траты </span>
|
||||
<div class="font-light text-center w-full ">
|
||||
{{ 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 ">
|
||||
=
|
||||
<div class="flex flex-col items-center font-bold bg-white p-2 shadow-md rounded-lg w-full h-full">
|
||||
<span class="text-sm lg:text-base font-bold">Остаток</span>
|
||||
<div class="font-light text-center w-full "
|
||||
:class="totalIncomes - totalExpenses < 0 ? ' text-red-700 !font-bold' : ''">
|
||||
{{ formatAmount(totalInstantIncomes - totalInstantExpenses) }} ₽
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="grid grid-cols-2 !gap-2 mt-4" :class="detailedShowed ? 'block' : 'hidden'">
|
||||
<div v-for="categorySum in transactionCategoriesSums"
|
||||
class="lex flex-col items-center font-bold bg-white p-2 shadow-md rounded-lg w-full h-full">
|
||||
<p class="font-light text-center line-clamp-1">{{ categorySum.category.icon }}
|
||||
{{ categorySum.category.name }}</p>
|
||||
<div class="font-light 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">
|
||||
@@ -995,7 +1075,7 @@ onUnmounted(async () => {
|
||||
|
||||
<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 ">
|
||||
class="flex flex-col p-4 shadow-md rounded-lg bg-white transition-transform duration-300 ">
|
||||
|
||||
<div class="flex flex-row justify-between w-full items-center transition-transform duration-300">
|
||||
<!-- {{category}}-->
|
||||
@@ -1056,7 +1136,7 @@ onUnmounted(async () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-for="day, dayOne in calendar" class="flex flex-col justify-between p-4 shadow-lg rounded-lg "
|
||||
<div v-for="day, dayOne in calendar" class="flex flex-col justify-between p-4 shadow-md 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 ">
|
||||
@@ -1076,7 +1156,7 @@ onUnmounted(async () => {
|
||||
</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 ">
|
||||
class="flex flex-col justify-between p-4 shadow-md 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">
|
||||
|
||||
178
src/components/settings/CommonSettings.vue
Normal file
178
src/components/settings/CommonSettings.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref, watch} from "vue";
|
||||
import {useToast} from "primevue/usetoast";
|
||||
import FloatLabel from "primevue/floatlabel";
|
||||
import InputText from "primevue/inputtext";
|
||||
import InputNumber from "primevue/inputnumber";
|
||||
import Tag from "primevue/tag";
|
||||
import {createTagRequest, deleteTagRequest, getTagsRequest} from "@/services/categoryService";
|
||||
import {useSpaceStore} from "@/stores/spaceStore";
|
||||
import {CategoryTag} from "@/models/Category";
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const loading = ref(true);
|
||||
|
||||
const firstDay = ref(1)
|
||||
const secondDay = ref(25)
|
||||
const tags = ref([])
|
||||
const spaceStore = useSpaceStore()
|
||||
const selectedSpace = computed(() => spaceStore.space)
|
||||
const creationShowed = ref(false)
|
||||
const newTagCode = ref()
|
||||
const codeError = ref()
|
||||
const newTagName = ref()
|
||||
const nameError = ref()
|
||||
|
||||
const fetchTags = async () => {
|
||||
await getTagsRequest().then(res => tags.value = res)
|
||||
.catch(err => {
|
||||
console.log(err);
|
||||
toast.add({severity: 'error', summary: 'Ошибка получения тэгов', detail: err.response.data.message, life: 3000})
|
||||
});
|
||||
}
|
||||
const createTag = async () => {
|
||||
if (!newTagCode.value) {
|
||||
codeError.value = "Не может быть пустым"
|
||||
if (newTagCode.value.includes(" ")) {
|
||||
codeError.value = "Не может содержать пробелы"
|
||||
return;
|
||||
}
|
||||
return;
|
||||
} else codeError.value = null
|
||||
if (!newTagCode.value || newTagName.value == '') {
|
||||
nameError.value = "Не может быть пустым"
|
||||
return;
|
||||
} else nameError.value = null
|
||||
const tag = new CategoryTag(newTagCode.value, newTagName.value);
|
||||
await createTagRequest(tag).then((res) => {
|
||||
creationShowed.value = false;
|
||||
toast.add({severity: 'success', summary: 'Успех!', detail: 'Тэг создан', life: 3000})
|
||||
fetchTags();
|
||||
newTagCode.value = '';
|
||||
newTagName.value = '';
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.add({severity: 'error', summary: 'Ошибка', detail: err.response.data.message, life: 3000})
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
const deleteTag = async (tag: CategoryTag) => {
|
||||
await deleteTagRequest(tag).then((res) => {
|
||||
creationShowed.value = false;
|
||||
toast.add({severity: 'success', summary: 'Успех!', detail: 'Тэг удален!', life: 3000})
|
||||
fetchTags();
|
||||
newTagCode.value = '';
|
||||
newTagName.value = '';
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.add({severity: 'error', summary: 'Ошибка', detail: err.response.data.message, life: 3000})
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
watch(
|
||||
() => selectedSpace.value,
|
||||
async (newValue, oldValue) => {
|
||||
|
||||
if (newValue != oldValue || !oldValue) {
|
||||
try {
|
||||
loading.value = true;
|
||||
// Если выбранный space изменился, получаем новую информацию о бюджете
|
||||
await fetchTags()
|
||||
} catch (error) {
|
||||
console.error('Error fetching budget infos:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (selectedSpace.value) {
|
||||
await fetchTags();
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 pb-5">
|
||||
<h1 class="font-bold text-2xl leading-tight">Общие настройки</h1>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="font-bold text-xl leading-tight">Настройки периодов</h2>
|
||||
<form class="flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center gap-2 ">
|
||||
<label for="username">День первого поступления средств</label>
|
||||
<InputNumber id="username" v-model="firstDay" class="w-10" inputId="horizontal-buttons" showButtons
|
||||
buttonLayout="horizontal" :min="1" :max="31">
|
||||
<template #incrementbuttonicon>
|
||||
<span class="pi pi-plus"/>
|
||||
</template>
|
||||
<template #decrementbuttonicon>
|
||||
<span class="pi pi-minus"/>
|
||||
</template>
|
||||
</InputNumber>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<label for="username">День второго поступления средств</label>
|
||||
<InputNumber id="username" v-model="secondDay" inputId="horizontal-buttons" showButtons
|
||||
buttonLayout="horizontal" :min="1" :max="31">
|
||||
<template #incrementbuttonicon>
|
||||
<span class="pi pi-plus"/>
|
||||
</template>
|
||||
<template #decrementbuttonicon>
|
||||
<span class="pi pi-minus"/>
|
||||
</template>
|
||||
</InputNumber>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 justify-start">
|
||||
<h2 class="font-bold text-xl leading-tight">Тэги категорий</h2>
|
||||
<div class="flex flex-row items-center gap-2 flex-wrap">
|
||||
<Tag v-for="tag in tags" :key="tag.id" class="w-fit group">{{ tag.name }} ({{ tag.code }})<i
|
||||
class="pi pi-trash !hidden group-hover:!block" @click="deleteTag(tag)"/></Tag>
|
||||
<button v-if=!creationShowed class="bg-emerald-100 border border-emerald-700 p-0.5 px-1 rounded"
|
||||
@click="creationShowed = !creationShowed"><span
|
||||
class="text-gray-500 font-bold flex flex-row gap-1 items-center"><i
|
||||
class="pi pi-plus-circle"/> Добавить</span></button>
|
||||
<div v-else class=" bg-emerald-50 border-spacing-0.5 border-emerald-700 rounded p-0.5">
|
||||
<div class="flex flex-row gap-4 px-1">
|
||||
<div class="flex flex-col items-center gap- ">
|
||||
<div class="flex flex-row items-center gap-2 ">
|
||||
<label for="code">Код</label>
|
||||
<InputText id="name" class="rounded !h-6 " v-model="newTagCode" :invalid="!newTagCode || newTagCode==''"
|
||||
v-tooltip="'Не может содержать пробелов'"/>
|
||||
</div>
|
||||
<span v-if="codeError" class="text-red-400">{{ codeError }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-">
|
||||
<div class="flex flex-row items-center gap-2 ">
|
||||
<label for="name">Название</label>
|
||||
|
||||
<InputText id="name" class="rounded !h-6 " v-model="newTagName" :invalid="!newTagName || newTagName==''"
|
||||
v-tooltip="'Должно быть введено'"/>
|
||||
</div>
|
||||
<span v-if="nameError" class="text-sm font-light text-red-400">{{ nameError }}</span>
|
||||
</div>
|
||||
<div class="flex flex-row gap-1">
|
||||
<button @click="createTag"><i class="pi pi-plus-circle"></i></button>
|
||||
<button @click="creationShowed=false"><i class="pi pi-times-circle"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -7,11 +7,17 @@ import {ref} from "vue";
|
||||
import Divider from "primevue/divider";
|
||||
import CategoriesList from "@/components/settings/categories/CategoriesList.vue";
|
||||
import RecurrentList from "@/components/settings/recurrent/RecurrentList.vue";
|
||||
import CommonSettings from "@/components/settings/CommonSettings.vue";
|
||||
|
||||
const selectedModeCode = ref("categories")
|
||||
const selectedModeCode = ref("common")
|
||||
|
||||
|
||||
const pages = ref([
|
||||
{
|
||||
"code": "common",
|
||||
"title": "Общие",
|
||||
"icon": "pi pi-cog"
|
||||
},
|
||||
{
|
||||
"code": "categories",
|
||||
"title": "Категории",
|
||||
@@ -57,7 +63,8 @@ const pages = ref([
|
||||
</ul>
|
||||
</div>
|
||||
<div class=" flex flex-col col-span-5 gap-2 max-h-[90dvh] overflow-y-scroll justify-items-start">
|
||||
<CategoriesList v-if="selectedModeCode == 'categories'"/>
|
||||
<CommonSettings v-if="selectedModeCode == 'common'"/>
|
||||
<CategoriesList v-else-if="selectedModeCode == 'categories'"/>
|
||||
<RecurrentList v-else-if="selectedModeCode == 'recurrent'"/>
|
||||
<p v-else-if="selectedModeCode == 'notifications'" class="flex justify-items-start justify-center items-center h-screen">NOTIFICATIONS UNDER CONSTRUCTIONS</p>
|
||||
</div>
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
</div>
|
||||
|
||||
<CreateCategoryModal
|
||||
v-if="isDialogVisible"
|
||||
:show="isDialogVisible"
|
||||
:categoryTypes="categoryTypes"
|
||||
:selectedCategoryType="selectedCategoryType"
|
||||
@@ -116,7 +117,6 @@ import {
|
||||
deleteCategory,
|
||||
getCategories,
|
||||
getCategoryTypes,
|
||||
updateCategory
|
||||
} from "@/services/categoryService";
|
||||
|
||||
import {useConfirm} from "primevue/useconfirm";
|
||||
@@ -201,11 +201,6 @@ const closeCreateDialog = () => {
|
||||
};
|
||||
|
||||
const saveCategory = async (newCategory: Category) => {
|
||||
if (newCategory.id) {
|
||||
await updateCategory(newCategory.id, newCategory);
|
||||
} else {
|
||||
await createCategory(newCategory);
|
||||
}
|
||||
await fetchCategories()
|
||||
closeCreateDialog();
|
||||
};
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
class="w-[95%] xl:!w-1/3">
|
||||
<div class="flex justify-center gap-4">
|
||||
<Button rounded outlined @click="toggleEmojiPicker" class=" flex justify-center !rounded-full !text-4xl !p-4"
|
||||
size="large" :label="icon"/>
|
||||
size="large" :label="category.icon"/>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
class="absolute pt-1 border rounded-lg shadow-2xl border-gray-300 bg-white grid grid-cols-6 gap-4 h-40 z-50 ml-3 mt-1 overflow-scroll"
|
||||
v-if="showEmojiPicker">
|
||||
@@ -14,16 +15,32 @@
|
||||
|
||||
<!-- SelectButton для выбора типа категории -->
|
||||
<div class="flex justify-center mt-4">
|
||||
<SelectButton v-if="!isEditing" v-model="categoryType" :options="categoryTypes" optionLabel="name"/>
|
||||
<SelectButton v-if="!isEditing" v-model="category.type" :options="categoryTypes" optionLabel="name"/>
|
||||
</div>
|
||||
|
||||
<!-- Поля для создания/редактирования категории -->
|
||||
<label for="newCategoryName">Название категории:</label>
|
||||
<input v-model="name" type="text" id="newCategoryName"/>
|
||||
<input v-model="category.name" type="text" id="newCategoryName"/>
|
||||
|
||||
<label for="newCategoryDesc">Описание категории:</label>
|
||||
<input v-model="description" type="text" id="newCategoryDesc"/>
|
||||
|
||||
<input v-model="category.description" type="text" id="newCategoryDesc"/>
|
||||
<div class="flex flex-col flex-wrap gap-0">
|
||||
<h2 class="text-lg ">Добавленные теги</h2>
|
||||
<div class="flex flex-row flex-wrap gap-2">
|
||||
<Tag v-if="category.tags" v-for="tag in category.tags" :key="tag.id" class="w-fit group">{{ tag.name }}
|
||||
({{ tag.code }})<i
|
||||
class="pi pi-trash !hidden group-hover:!block" @click="deleteTag(tag)"/></Tag>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col flex-wrap gap-2">
|
||||
<h2 class="text-lg ">Доступные теги</h2>
|
||||
<div class="flex flex-row flex-wrap gap-2">
|
||||
<Tag v-for="tag in availableTags" :key="tag.id" class="w-fit " @click="addTag(tag)">
|
||||
{{ tag.name }} ({{ tag.code }})
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Кнопки -->
|
||||
<div class="button-group">
|
||||
<button @click="saveCategory" class="create-category-btn">{{ isEditing ? 'Сохранить' : 'Создать' }}</button>
|
||||
@@ -32,95 +49,151 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {ref, watch, computed, PropType} from 'vue';
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, computed, PropType, onMounted} from 'vue';
|
||||
import Button from "primevue/button";
|
||||
import Dialog from "primevue/dialog";
|
||||
import SelectButton from "primevue/selectbutton";
|
||||
import {Category, CategoryType} from '@/models/Category';
|
||||
import {Category, CategoryTag, CategoryType} from '@/models/Category';
|
||||
import Tag from "primevue/tag";
|
||||
import {useSpaceStore} from "@/stores/spaceStore";
|
||||
import {createCategory, editCategoryRequest, getTagsRequest} from "@/services/categoryService";
|
||||
import {useToast} from "primevue/usetoast";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Dialog,
|
||||
Button, SelectButton
|
||||
},
|
||||
props: {
|
||||
show: Boolean,
|
||||
categoryTypes: Object as PropType<CategoryType[]>,
|
||||
category: Object as PropType<Category | null>, // Категория для редактирования (null, если создание)
|
||||
},
|
||||
emits: ['saveCategory', 'close-modal'],
|
||||
setup(props, {emit}) {
|
||||
const icon = ref('🐱');
|
||||
const name = ref('');
|
||||
const description = ref('');
|
||||
const showEmojiPicker = ref(false);
|
||||
const categoryType = ref(props.category?.type || props.categoryTypes[0]); // Тип по умолчанию
|
||||
const isEditing = computed(() => !!props.category); // Если есть категория, значит редактирование
|
||||
const toast = useToast();
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
categoryTypes: Object as PropType<CategoryType[]>,
|
||||
category: Object as PropType<Category | null>, // Категория для редактирования (null, если создание)
|
||||
})
|
||||
const emit = defineEmits(['saveCategory', 'close-modal'])
|
||||
const category = ref<Category>()
|
||||
const icon = ref('🐱');
|
||||
const name = ref('');
|
||||
const description = ref('');
|
||||
const showEmojiPicker = ref(false);
|
||||
const categoryType = ref(props.category?.type || props.categoryTypes[0]); // Тип по умолчанию
|
||||
const isEditing = computed(() => !!props.category); // Если есть категория, значит редактирование
|
||||
const availableTags = computed(() => {
|
||||
const availableTags = []
|
||||
tags.value.forEach((tag1) => {
|
||||
console.log(tag1.code)
|
||||
console.log(category.value.tags)
|
||||
if (category.value?.tags.filter(tag => tag.code == tag1.code).length == 0) {
|
||||
availableTags.push(tag1);
|
||||
}
|
||||
})
|
||||
return availableTags;
|
||||
});
|
||||
|
||||
|
||||
// Если мы редактируем категорию, заполняем поля данными
|
||||
watch(() => props.category, (newCategory) => {
|
||||
if (newCategory) {
|
||||
name.value = newCategory.name;
|
||||
description.value = newCategory.description;
|
||||
icon.value = newCategory.icon;
|
||||
categoryType.value = newCategory.type;
|
||||
} else {
|
||||
resetForm(); // Сбрасываем форму, если не редактируем
|
||||
}
|
||||
});
|
||||
const emojis = ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '😍', '🥰', '😘', '🍎', '🍔', '🍕', '🍣', '☕', '🍺', '🚗', '🚕', '🚴♂️', '🚆', '✈️', '⛴️', '🛒', '👗', '💍', '👟', '🛍️', '🎮', '🎥', '🎧', '🎢', '🎨', '🏠', '🛏️', '🧹', '🪴', '🍼', '🏥', '💊', '🩺', '🦷', '💳', '💰', '🏦', '🌍', '🗺️', '🏝️', '🏔️', '💻', '📚', '🖋️', '🏫'];
|
||||
|
||||
const emojis = ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '😍', '🥰', '😘', '🍎', '🍔', '🍕', '🍣', '☕', '🍺', '🚗', '🚕', '🚴♂️', '🚆', '✈️', '⛴️', '🛒', '👗', '💍', '👟', '🛍️', '🎮', '🎥', '🎧', '🎢', '🎨', '🏠', '🛏️', '🧹', '🪴', '🍼', '🏥', '💊', '🩺', '🦷', '💳', '💰', '🏦', '🌍', '🗺️', '🏝️', '🏔️', '💻', '📚', '🖋️', '🏫'];
|
||||
|
||||
const toggleEmojiPicker = () => {
|
||||
showEmojiPicker.value = !showEmojiPicker.value;
|
||||
};
|
||||
|
||||
const selectEmoji = (emoji: string) => {
|
||||
icon.value = emoji;
|
||||
showEmojiPicker.value = false;
|
||||
};
|
||||
|
||||
const saveCategory = () => {
|
||||
const categoryData = new Category(categoryType.value, name.value, description.value, icon.value);
|
||||
if (isEditing.value && props.category) {
|
||||
categoryData.id = props.category.id; // Сохраняем ID при редактировании
|
||||
}
|
||||
emit('saveCategory', categoryData);
|
||||
resetForm();
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
emit('close-modal');
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
name.value = '';
|
||||
description.value = '';
|
||||
icon.value = '🐱';
|
||||
categoryType.value = 'EXPENSE';
|
||||
showEmojiPicker.value = false;
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
showEmojiPicker,
|
||||
categoryType,
|
||||
emojis,
|
||||
toggleEmojiPicker,
|
||||
selectEmoji,
|
||||
saveCategory,
|
||||
closeModal,
|
||||
isEditing
|
||||
};
|
||||
}
|
||||
const toggleEmojiPicker = () => {
|
||||
showEmojiPicker.value = !showEmojiPicker.value;
|
||||
};
|
||||
|
||||
const selectEmoji = (emoji: string) => {
|
||||
category.value.icon = emoji
|
||||
showEmojiPicker.value = false;
|
||||
};
|
||||
|
||||
const saveCategory = async () => {
|
||||
// const categoryData = new Category(categoryType.value, name.value, description.value, icon.value);
|
||||
if (isEditing.value) {
|
||||
console.log(category.value)
|
||||
await editCategoryRequest(category.value).then((res) => {
|
||||
category.value = res.data
|
||||
emit("saveCategory", category.value)
|
||||
}).catch(err =>
|
||||
toast.add({severity: 'error', summary: 'Ошибка сохранения категории', detail: err.response.data.message, life: 3000})
|
||||
)
|
||||
} else {
|
||||
await createCategory(category.value).then((res) => {
|
||||
category.value = res.data
|
||||
}).catch(err =>
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка создания категории',
|
||||
detail: err.response.data.message,
|
||||
life: 3000
|
||||
})
|
||||
)
|
||||
}
|
||||
resetForm();
|
||||
closeModal();
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
emit('close-modal');
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
name.value = '';
|
||||
description.value = '';
|
||||
icon.value = '🐱';
|
||||
categoryType.value = 'EXPENSE';
|
||||
showEmojiPicker.value = false;
|
||||
};
|
||||
|
||||
const addTag = (tag: CategoryType) => {
|
||||
category.value.tags.push(tag);
|
||||
}
|
||||
const deleteTag = (tag) => {
|
||||
category.value.tags = category.value.tags.filter(t => t.code !== tag.code);
|
||||
}
|
||||
const tags = ref<CategoryType[]>([]);
|
||||
const spaceStore = useSpaceStore()
|
||||
const selectedSpace = computed(() => spaceStore.space)
|
||||
|
||||
const fetchTags = async () => {
|
||||
await getTagsRequest().then(res => tags.value = res)
|
||||
.catch(err => {
|
||||
console.log(err);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка получения тэгов',
|
||||
detail: err.response.data.message,
|
||||
life: 3000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedSpace.value,
|
||||
async (newValue, oldValue) => {
|
||||
|
||||
if (newValue != oldValue || !oldValue) {
|
||||
try {
|
||||
// loading.value = true;
|
||||
// Если выбранный space изменился, получаем новую информацию о бюджете
|
||||
await fetchTags()
|
||||
|
||||
constructCategory()
|
||||
} catch (error) {
|
||||
console.error('Error fetching budget infos:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const constructCategory = () => {
|
||||
if (props.category) {
|
||||
category.value = new Category(props.category.id, props.category.type, props.category.name, props.category.description, props.category.icon, props.category.tags);
|
||||
} else {
|
||||
category.value = new Category(null, categoryType.value, '', '', icon.value, []);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
constructCategory()
|
||||
// console.log(props.category)
|
||||
await fetchTags();
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -232,9 +232,9 @@ onMounted(async () => {
|
||||
<ConfirmDialog/>
|
||||
<Dialog :visible="inviteCreatedDialog" header="Приглашение" @hide="inviteCreatedDialog=false"
|
||||
@update:visible="inviteCreatedDialog=false">
|
||||
<div class="flex flex-col justify-start">
|
||||
<div class="flex flex-col gap-4 justify-start">
|
||||
<div class="flex flex-row gap-2 items-center"> Ссылка приглашения:
|
||||
<input class="p-2 border min-w-fit w-80" v-model="inviteUrl" disabled></input>
|
||||
<input class="p-2 border w-5/6" v-model="inviteUrl" disabled></input>
|
||||
<button @click="copyToClipboard('https://luminic.space/spaces/invite/' + invite.code)">
|
||||
{{ !copied ? 'Копировать' : 'Скопировано!' }}
|
||||
</button>
|
||||
|
||||
@@ -10,17 +10,20 @@ export class CategorySettingType {
|
||||
|
||||
|
||||
export class Category {
|
||||
id: number = null;
|
||||
id: string | null = null;
|
||||
type: CategoryType;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
tags: CategoryTag[];
|
||||
|
||||
constructor(type: CategoryType, name: string, description: string, icon: string,) {
|
||||
constructor(id: string | null,type: CategoryType, name: string, description: string, icon: string, tags: CategoryTag[]) {
|
||||
this.id = id
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.icon = icon;
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
// Метод, который возвращает краткое описание
|
||||
@@ -36,3 +39,13 @@ export class CategorySetting {
|
||||
settingValue: number
|
||||
value: number
|
||||
}
|
||||
|
||||
export class CategoryTag {
|
||||
code: string;
|
||||
name: string;
|
||||
|
||||
constructor(code: string, name: string) {
|
||||
this.code = code;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/services/categoryService.ts
|
||||
import apiClient from '@/services/axiosSetup';
|
||||
import {Category} from "@/models/Category";
|
||||
import {Category, CategoryTag} from "@/models/Category";
|
||||
import {useSpaceStore} from "@/stores/spaceStore"; // Импортируете настроенный экземпляр axios
|
||||
|
||||
export const getCategories = async (type = null) => {
|
||||
@@ -22,12 +22,20 @@ export const getCategoryTypes = async () => {
|
||||
|
||||
export const createCategory = async (category: Category) => {
|
||||
const spaceStore = useSpaceStore();
|
||||
return await apiClient.post(`/spaces/${spaceStore.space?.id}/categories`, category);
|
||||
return await apiClient.post(`/spaces/${spaceStore.space?.id}/categories`, category)
|
||||
.then(res => res.data)
|
||||
.catch(err => {
|
||||
throw err
|
||||
})
|
||||
};
|
||||
|
||||
export const updateCategory = async (id: number, category: any) => {
|
||||
export const editCategoryRequest = async (category: any) => {
|
||||
const spaceStore = useSpaceStore();
|
||||
return await apiClient.put(`/spaces/${spaceStore.space?.id}/categories/${id}`, category);
|
||||
return await apiClient.put(`/spaces/${spaceStore.space?.id}/categories/${category.id}`, category)
|
||||
.then(res => res.data)
|
||||
.catch(err => {
|
||||
throw err
|
||||
})
|
||||
};
|
||||
|
||||
export const deleteCategory = async (id: number) => {
|
||||
@@ -38,5 +46,32 @@ export const deleteCategory = async (id: number) => {
|
||||
export const getCategoriesSumsRequest = async (spaceId: string) => {
|
||||
const spaceStore = useSpaceStore();
|
||||
return await apiClient.get(`/spaces/${spaceStore.space?.id}/analytics/by-month`)
|
||||
}
|
||||
|
||||
export const getTagsRequest = async () => {
|
||||
const spaceStore = useSpaceStore();
|
||||
return await apiClient.get(`/spaces/${spaceStore.space?.id}/categories/tags`)
|
||||
.then(res => res.data)
|
||||
.catch(err => {
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
export const createTagRequest = async (tag: CategoryTag) => {
|
||||
const spaceStore = useSpaceStore();
|
||||
return await apiClient.post(`/spaces/${spaceStore.space?.id}/categories/tags`, tag)
|
||||
.then(res => res.data)
|
||||
.catch(err => {
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteTagRequest = async (tag: CategoryTag) => {
|
||||
const spaceStore = useSpaceStore();
|
||||
return await apiClient.delete(`/spaces/${spaceStore.space?.id}/categories/tags/${tag.code}`)
|
||||
.then(res => res.data)
|
||||
.catch(err => {
|
||||
throw err
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export const deleteTransactionRequest = async (id: number) => {
|
||||
|
||||
export const getTransactionTypes = async () => {
|
||||
const spaceStore = useSpaceStore()
|
||||
return await apiClient.get(`/transactions/types`);
|
||||
return await apiClient.get(`/transactions/types/`);
|
||||
}
|
||||
|
||||
export const getTransactionCategoriesSums = async () => {
|
||||
|
||||
@@ -3,8 +3,10 @@ import {ref} from 'vue';
|
||||
import apiClient from "@/services/axiosSetup";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {useSpaceStore} from "@/stores/spaceStore";
|
||||
import {useToast} from "primevue/usetoast";
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const toast = useToast();
|
||||
const user = ref(null);
|
||||
const loadingUser = ref(true);
|
||||
const router = useRouter();
|
||||
@@ -31,11 +33,12 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
|
||||
// Основная функция для логина
|
||||
async function login(username, password, tg_id = null) {
|
||||
async function login(username, password, tg_id: string | null = null) {
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (tg_id) {
|
||||
response = await apiClient.post('/auth/token/tg', {tg_id: tg_id});
|
||||
response = await apiClient.post('/auth/tgLogin', {}, {headers: {'X-TG-ID': tg_id}});
|
||||
} else {
|
||||
response = await apiClient.post('/auth/login', {
|
||||
username: username,
|
||||
@@ -46,12 +49,13 @@ export const useUserStore = defineStore('user', () => {
|
||||
const token = response.data.token;
|
||||
localStorage.setItem('token', token);
|
||||
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
toast.add({ severity: 'success', summary: 'Вход выполнен', detail: 'Добро пожаловать!', life: 3000 })
|
||||
await fetchUserProfile();
|
||||
await spaceStore.fetchSpaces()
|
||||
await router.push(route.query['back'] ? route.query['back'].toString() : '/');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Ошибка входа. Проверьте логин и пароль.');
|
||||
toast.add({ severity: 'error', summary: 'Ошибка входа', detail: 'Ошибка Telegram авторизации', life: 3000 })
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user