tags and new analytics new in budget

This commit is contained in:
xds
2025-02-21 01:45:20 +03:00
parent f977fe363f
commit db03c743b2
16 changed files with 711 additions and 258 deletions

12
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"axios": "^1.7.7", "axios": "^1.7.7",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"chartjs-plugin-datalabels": "^2.2.0", "chartjs-plugin-datalabels": "^2.2.0",
"circle-progress.vue": "^3.3.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"pinia": "^2.2.6", "pinia": "^2.2.6",
@@ -2195,6 +2196,12 @@
"node": ">=6.0" "node": ">=6.0"
} }
}, },
"node_modules/circle-progress.vue": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/circle-progress.vue/-/circle-progress.vue-3.3.0.tgz",
"integrity": "sha512-Ynj/ZaB6LvbkWiT42OURlfE3gZN0fbCOgag1DMPJ7Dj87qUoDZocCPF0Os1xhXwdo6dqU9BchYVkt6QOXl19NQ==",
"license": "MIT"
},
"node_modules/clean-css": { "node_modules/clean-css": {
"version": "5.3.3", "version": "5.3.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
@@ -11333,6 +11340,11 @@
"resolved": "https://registry.npmmirror.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "resolved": "https://registry.npmmirror.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==" "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="
}, },
"circle-progress.vue": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/circle-progress.vue/-/circle-progress.vue-3.3.0.tgz",
"integrity": "sha512-Ynj/ZaB6LvbkWiT42OURlfE3gZN0fbCOgag1DMPJ7Dj87qUoDZocCPF0Os1xhXwdo6dqU9BchYVkt6QOXl19NQ=="
},
"clean-css": { "clean-css": {
"version": "5.3.3", "version": "5.3.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",

View File

@@ -13,6 +13,7 @@
"axios": "^1.7.7", "axios": "^1.7.7",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"chartjs-plugin-datalabels": "^2.2.0", "chartjs-plugin-datalabels": "^2.2.0",
"circle-progress.vue": "^3.3.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"pinia": "^2.2.6", "pinia": "^2.2.6",

View File

@@ -137,9 +137,12 @@ const chartOptions = {
// Преобразование данных для таблицы // Преобразование данных для таблицы
const prepareTableData = (categories) => { const prepareTableData = (categories) => {
// 1. Собираем все уникальные значения date из monthlySums // 1. Собираем все уникальные значения date из monthlySums
const onlyExpenses = categories.filter((it) => it.categoryType == "EXPENSE")
const allDates = [ const allDates = [
...new Set( ...new Set(
categories.flatMap((category) => onlyExpenses.flatMap((category) =>
category.monthlySums.map((sumItem) => sumItem.date) category.monthlySums.map((sumItem) => sumItem.date)
) )
), ),
@@ -164,7 +167,7 @@ const prepareTableData = (categories) => {
// console.log(tableColumns.value[0].field); // console.log(tableColumns.value[0].field);
const sums = {} const sums = {}
// 4. Формируем строки (для каждой категории) // 4. Формируем строки (для каждой категории)
const rows = categories.map((category) => { const rows = onlyExpenses.map((category) => {
// Начинаем со строки, где есть поле с именем категории // Начинаем со строки, где есть поле с именем категории
const row = {category: category.categoryIcon + " " + category.categoryName}; const row = {category: category.categoryIcon + " " + category.categoryName};
let categorySum = 0 let categorySum = 0
@@ -218,6 +221,7 @@ const selectedSpace = computed(() => spaceStore.space)
watch( selectedSpace, async (newValue, oldValue) => { watch( selectedSpace, async (newValue, oldValue) => {
if (newValue != oldValue) { if (newValue != oldValue) {
await fetchCategoriesCatalog()
await fetchCategoriesSums() await fetchCategoriesSums()
} }
}) })
@@ -249,26 +253,30 @@ const fetchCategoriesCatalog = async () => {
} }
onMounted(async () => { onMounted(async () => {
await Promise.all([ fetchCategoriesCatalog()])
if (selectedSpace.value) { if (selectedSpace.value) {
await fetchCategoriesCatalog()
await fetchCategoriesSums(); await fetchCategoriesSums();
} }
}); });
</script> </script>
<template> <template>
<LoadingView v-if="loading"/> <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-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"> <div v-if="!selectedSpace" class="flex w-full h-full items-center justify-center">
<p>Сперва нужно выбрать Пространство. <p>Сперва нужно выбрать Пространство.
<button class="text-blue-500 hover:underline" @click="router.push('/spaces').then((res) => router.go(0))">Перейти</button> <button class="text-blue-500 hover:underline" @click="router.push('/spaces').then((res) => router.go(0))">Перейти</button>
</p> </p>
</div> </div>
<div v-else-if="dataTableCategories.length==0">
<div v-else-if="dataTableCategories.length <= 1 && dataTableCategories.keys().next()">
Начните записывать траты и здесь появится информация. Начните записывать траты и здесь появится информация.
</div> </div>
<div v-else class="!items-center w-5/6 bg-white"> <div v-else class="!flex !flex-col !items-center w-5/6 !gap-4 ">
<Accordion value="1" class=" " @tab-open="isChartOpen=true" <Accordion value="1" class="!w-full " @tab-open="isChartOpen=true"
@tab-close="closeChart"> @tab-close="closeChart">
<AccordionPanel value="0"> <AccordionPanel value="0">
<AccordionHeader>График</AccordionHeader> <AccordionHeader>График</AccordionHeader>
@@ -291,7 +299,7 @@ onMounted(async () => {
<span v-html="catType"/> <span v-html="catType"/>
<ul> <ul>
<li v-for="cat in category" class="px-2 py-2 hover:bg-blue-50 rounded-md line-clamp- w-full" <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> </ul>
</li> </li>
</ul> </ul>
@@ -314,9 +322,8 @@ onMounted(async () => {
</AccordionContent> </AccordionContent>
</AccordionPanel> </AccordionPanel>
</Accordion> </Accordion>
<div class="items-center !w-full bg-white">
<DataTable :value="dataTableCategories" responsiveLayout="scroll" filter stripedRows class="">
<DataTable :value="dataTableCategories" responsiveLayout="scroll" filter stripedRows class="w-5/6 items-center">
<Column <Column
:field="tableColumns[0].field" :field="tableColumns[0].field"
:header="tableColumns[0].header" :header="tableColumns[0].header"
@@ -337,6 +344,7 @@ onMounted(async () => {
</template> </template>
</Column> </Column>
</DataTable> </DataTable>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -59,28 +59,26 @@ const errors = ref({ username: '', password: '' })
// Получение tg_id (Telegram ID) // Получение tg_id (Telegram ID)
const tg_id = computed(() => { const tg_id = computed(() => {
if (window.Telegram?.WebApp) { if (window.Telegram?.WebApp) {
const tg = window.Telegram.WebApp const tg = window.Telegram.WebApp
tg.expand() // Разворачиваем WebApp tg.expand() // Разворачиваем WebApp
return tg.initDataUnsafe.user?.id ?? null return tg.initDataUnsafe.user?.id ?? null
} }
return null return ''
}) })
// Авто-вход по Telegram ID // Авто-вход по Telegram ID
const autoLoginWithTgId = async () => { const autoLoginWithTgId = async () => {
if (tg_id.value) { if (tg_id.value) {
try { try {
const response = await apiClient.post('/auth/token/tg', { tg_id: tg_id.value }) await userStore.login(null, null, tg_id.value)
const token = response.data.access_token
localStorage.setItem('token', token)
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`
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() : '/') await router.replace(route.query['back'] ? route.query['back'].toString() : '/')
} catch (error) { } catch (error) {
console.error(error) console.error(error)
toast.add({ severity: 'error', summary: 'Ошибка входа', detail: 'Ошибка Telegram авторизации', life: 3000 }) // toast.add({ severity: 'error', summary: 'Ошибка входа', detail: 'Ошибка Telegram авторизации', life: 3000 })
} }
} }
} }

View File

@@ -5,10 +5,11 @@ import Button from "primevue/button";
import InputText from "primevue/inputtext"; import InputText from "primevue/inputtext";
import FloatLabel from "primevue/floatlabel"; import FloatLabel from "primevue/floatlabel";
import DatePicker from "primevue/datepicker"; import DatePicker from "primevue/datepicker";
import {onMounted, ref} from "vue"; import {computed, onMounted, ref, watch} from "vue";
import {getMonthName} from "@/utils/utils"; import {getMonthName} from "@/utils/utils";
import {Budget} from "@/models/Budget"; import {Budget} from "@/models/Budget";
import {getCategories} from "@/services/categoryService"; import {getCategories} from "@/services/categoryService";
import {useSpaceStore} from "@/stores/spaceStore";
const props = defineProps({ const props = defineProps({
opened: { opened: {
@@ -63,9 +64,31 @@ const resetForm = () => {
} }
budget.value.name = getMonthName(budget.value.dateFrom.getMonth()) + ' ' + budget.value.dateFrom.getFullYear(); 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(() => { onMounted(() => {
resetForm() resetForm()
if (selectedSpace.value) {
fetchCategories()
}
}) })
</script> </script>
@@ -91,6 +114,7 @@ onMounted(() => {
<Checkbox v-model="createRecurrentPayments" binary/> <Checkbox v-model="createRecurrentPayments" binary/>
Создать ежемесячные платежи? Создать ежемесячные платежи?
</div> </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"> <div class="flex flex-row gap-2 justify-end items-center">
<Button label="Создать" severity="success" icon="pi pi-save" @click="create"/> <Button label="Создать" severity="success" icon="pi pi-save" @click="create"/>
<Button label="Отмена" severity="secondary" icon="pi pi-times-circle" @click="cancel"/> <Button label="Отмена" severity="secondary" icon="pi pi-times-circle" @click="cancel"/>

View File

@@ -39,10 +39,10 @@
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<div class="text-2xl">{{ warn.message.icon }}</div> <div class="text-2xl">{{ warn.message.icon }}</div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="font-bold">{{ warn.message.title }}</span> <span class="font-bold">{{ warn.message.title }}</span>
<span v-html="warn.message.body"></span> <span v-html="warn.message.body"></span>
</div> </div>
</div> </div>
<button @click="hideWarn(warn.id)"><i class="pi pi-times" style="font-size: 0.7rem"></i></button> <button @click="hideWarn(warn.id)"><i class="pi pi-times" style="font-size: 0.7rem"></i></button>
</div> </div>
@@ -50,7 +50,8 @@
</div> </div>
</div> </div>
<div v-else class="flex items-center justify-center p-16"> <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>
</div> </div>
@@ -352,7 +353,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, onMounted, ref} from 'vue'; import {computed, onMounted, ref, watch} from 'vue';
import Chart from 'primevue/chart'; import Chart from 'primevue/chart';
import BudgetTransactionView from "@/components/budgets/BudgetTransactionView.vue"; import BudgetTransactionView from "@/components/budgets/BudgetTransactionView.vue";
import { import {
@@ -361,7 +362,7 @@ import {
getBudgetTransactions, getBudgetTransactions,
updateBudgetCategoryRequest, updateBudgetCategoryRequest,
getWarns, getWarns,
hideWarnRequest hideWarnRequest
} from "@/services/budgetsService"; } from "@/services/budgetsService";
import {Budget, BudgetCategory, Warn} from "@/models/Budget"; import {Budget, BudgetCategory, Warn} from "@/models/Budget";
import {useRoute} from "vue-router"; import {useRoute} from "vue-router";
@@ -378,6 +379,7 @@ import {Chart as ChartJS} from 'chart.js/auto';
import SelectButton from "primevue/selectbutton"; import SelectButton from "primevue/selectbutton";
import Divider from "primevue/divider"; import Divider from "primevue/divider";
import TransactionForm from "@/components/transactions/TransactionForm.vue"; import TransactionForm from "@/components/transactions/TransactionForm.vue";
import {useSpaceStore} from "@/stores/spaceStore";
// Зарегистрируем плагин // Зарегистрируем плагин
ChartJS.register(ChartDataLabels); ChartJS.register(ChartDataLabels);
@@ -563,9 +565,9 @@ const fetchBudgetTransactions = async () => {
const updateTransactions = async () => { const updateTransactions = async () => {
setTimeout(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 budgetInfo = ref<Budget>();
const fetchBudgetInfo = async () => { const fetchBudgetInfo = async () => {
await getBudgetInfo(route.params.id).then((data) => { await getBudgetInfo(route.params.id).then((data) => {
budget.value = data budget.value = data
plannedExpenses.value = budget.value?.plannedExpenses plannedExpenses.value = budget.value?.plannedExpenses
plannedIncomes.value = budget.value?.plannedIncomes plannedIncomes.value = budget.value?.plannedIncomes
transactions.value = budget.value?.transactions transactions.value = budget.value?.transactions
categories.value = budget.value?.categories categories.value = budget.value?.categories
updateLoading.value = false updateLoading.value = false
} }
) )
} }
@@ -623,7 +625,7 @@ const fetchBudgetInfo = async () => {
const updateBudgetCategory = async (category) => { const updateBudgetCategory = async (category) => {
// loading.value = true // loading.value = true
await updateBudgetCategoryRequest(budget.value.id, category) await updateBudgetCategoryRequest(budget.value.id, category)
await fetchBudgetInfo() await fetchBudgetInfo()
setTimeout(async () => { setTimeout(async () => {
await fetchWarns() await fetchWarns()
@@ -844,19 +846,42 @@ const fetchWarns = async (hidden: Boolean = null) => {
warns.value = await getWarns(route.params.id, hidden) 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 () => { onMounted(async () => {
try { try {
await Promise.all([ if (selectedSpace.value) {
fetchBudgetInfo(), fetchBudgetInfo()
fetchWarns() fetchWarns()
// budget.value = await getBudgetInfo(route.params.id),
// fetchPlannedIncomes(), await Promise.all([
// fetchPlannedExpenses(), fetchBudgetInfo(),
// fetchBudgetCategories(), fetchWarns()
// fetchBudgetTransactions(), // budget.value = await getBudgetInfo(route.params.id),
]); // fetchPlannedIncomes(),
// fetchPlannedExpenses(),
// fetchBudgetCategories(),
// fetchBudgetTransactions(),
]);
}
} catch (error) { } catch (error) {
console.error('Error during fetching data:', error); console.error('Error during fetching data:', error);
} finally { } finally {

View File

@@ -25,6 +25,7 @@ import {useDrawerStore} from "@/stores/drawerStore";
import {EventBus} from '@/utils/EventBus.ts'; import {EventBus} from '@/utils/EventBus.ts';
import {useToast} from "primevue/usetoast"; import {useToast} from "primevue/usetoast";
import {useSpaceStore} from "@/stores/spaceStore"; import {useSpaceStore} from "@/stores/spaceStore";
import {CircleProgressBar} from 'circle-progress.vue';
// Зарегистрируем плагин // Зарегистрируем плагин
ChartJS.register(ChartDataLabels); ChartJS.register(ChartDataLabels);
@@ -108,9 +109,17 @@ const savingRatio = computed(() => {
return totalExpenses.value == 0 ? 0 : totalSaving.value / totalExpenses.value * 100 return totalExpenses.value == 0 ? 0 : totalSaving.value / totalExpenses.value * 100
}) })
const totalSaving = computed(() => { const totalSaving = computed(() => {
let value = 0 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 value += cat.currentLimit
}) })
return value return value
@@ -763,14 +772,14 @@ onUnmounted(async () => {
<div class=" flex flex-col gap-3"> <div class=" flex flex-col gap-3">
<div class="flex flex-row justify-between "> <div class="flex flex-row justify-between ">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h2 class="text-4xl font-bold">Бюджет {{ budget.name }} </h2> <h2 class="text-4xl font-bold text-gray-700">Бюджет {{ budget.name }} </h2>
<div class="flex flex-row gap-2 text-xl">{{ formatDate(budget.dateFrom) }} - <div class="flex flex-row gap-2 text-xl text-gray-700">{{ formatDate(budget.dateFrom) }} -
{{ formatDate(budget.dateTo) }} {{ formatDate(budget.dateTo) }}
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 justify-center"> <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"> @click="warnsOpened = !warnsOpened">
<span class="bg-gray-300 p-1 rounded font-bold">{{ <span class="bg-gray-300 p-1 rounded font-bold">{{
warns ? warns.length : 0 warns ? warns.length : 0
@@ -778,7 +787,7 @@ onUnmounted(async () => {
</button> </button>
<div v-if="warnsOpened" <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-if="checkWarnsExists" class="flex flex-col p-4">
<div v-for="warn in warns"> <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 justify-between">
@@ -807,143 +816,214 @@ onUnmounted(async () => {
<div class="flex flex-col gap-2"> <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"> <div class="flex flex-col gap-4">
<!-- Блок Аналитики (25%) --> <!-- Блок Аналитики (25%) -->
<div <h3 class="text-2xl font-bold text-gray-700">Аналитика</h3>
class="bg-white p-4 shadow-lg rounded-lg flex flex-col gap-4 items-start "> <div class=" flex flex-col gap-3 items-start ">
<h3 class="text-xl font-bold">Аналитика</h3> <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"> <Chart v-if="selectedChart.value=='pie'" type="pie" :data="pieChartData" :options="pieChartOptions"
<template #option="slotProps"> class="chart "/>
<i :class="slotProps.option.icon"></i> </div>
</template> <div></div>
</SelectButton> <div class=" flex flex-col gap-2 relative border-[0.1rem] rounded-xl p-2 w-full h-full pt-4">
<Chart v-if="selectedChart.value=='bar'" type="bar" :data="incomeExpenseChartData" <p class="absolute -top-3 left-1/2 -translate-x-1/2 bg-gray-100 px-2 text-gray-700 font-semibold">
:options="incomeExpenseChartOptions" class="!w-full" Запланировано</p>
style="width: 100%"/> <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" <div class="font-light text-center ">
class="chart "/> {{ formatAmount(totalIncomes) }}
<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> </div>
<!-- <p>Total Incomes</p>--> <!-- <p>Total Incomes</p>-->
</div> </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" </div>
:class="totalExpenses > totalIncomes ? ' text-red-700' : ''"> <div class="flex flex-col items-center">
-{{ formatAmount(totalExpenses) }} ({{ formatAmount(totalExpenses - totalIncomes) }}) <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>
</div> </div>
</button> </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>-->
<div class="flex flex-col items-center font-bold "> <!-- <div class="flex flex-col items-center">-->
<p class="font-light ">в первый период</p> <!-- <p class="font-light ">во второй период</p>-->
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner text-center w-full "> <!-- <div class="font-light text-center w-full ">-->
+{{ formatAmount(incomesByPeriod[0]) }} <!-- -{{ formatAmount(expensesByPeriod[1]) }} -->
</div> <!-- </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[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>-->
<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 "> </div>
-{{ formatAmount(expensesByPeriod[1]) }} <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> <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> </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"> <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"> @click="detailedShowed = !detailedShowed">
<div class="flex flex-col items-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> <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="font-light text-center w-full ">
{{ 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) }} {{ formatAmount(totalInstantIncomes) }}
</div> </div>
<!-- <p>Total Incomes</p>--> <!-- <p>Total Incomes</p>-->
</div> </div>
<div class="flex flex-col items-center "> -
<span class="text-sm lg:text-base">Факт. траты 📛</span> <div class="flex flex-col items-center font-bold bg-white p-2 shadow-md rounded-lg w-full h-full">
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner w-full text-center"> <span class="text-sm lg:text-base font-bold">Траты </span>
<div class="font-light text-center w-full ">
{{ formatAmount(totalInstantExpenses) }} {{ formatAmount(totalInstantExpenses) }}
</div> </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> =
<div class="grid grid-cols-2 !gap-1 mt-4" :class="detailedShowed ? 'block' : 'hidden'"> <div class="flex flex-col items-center font-bold bg-white p-2 shadow-md rounded-lg w-full h-full">
<div v-for="categorySum in transactionCategoriesSums" class="flex flex-col items-center font-bold "> <span class="text-sm lg:text-base font-bold">Остаток</span>
<p class="font-light ">{{ categorySum.category.icon }} {{ categorySum.category.name }}</p> <div class="font-light text-center w-full "
<div class="font-light bg-gray-100 p-1 rounded-lg box-shadow-inner 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) }} {{ formatAmount(categorySum.sum) }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<ProgressBar :value="value" class="mt-2 col-span-2" style="height: 1rem !important;"></ProgressBar>
</div> </div>
<div class=" h-fit overflow-y-auto gap-4 flex-col row-span-6 hidden lg:flex"> <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" <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"> <div class="flex flex-row justify-between w-full items-center transition-transform duration-300">
<!-- {{category}}--> <!-- {{category}}-->
@@ -1056,7 +1136,7 @@ onUnmounted(async () => {
</button> </button>
</div> </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'" v-if="calendarExpanded == '1'"
:class="day.date.toISOString().split('T')[0] == new Date().toISOString().split('T')[0]? 'bg-emerald-200' : 'bg-white '"> :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 "> <div class="flex flex-row gap-2 items-center ">
@@ -1076,7 +1156,7 @@ onUnmounted(async () => {
</div> </div>
<ul class="grid grid-cols-1 md:grid-cols-2 gap-4"> <ul class="grid grid-cols-1 md:grid-cols-2 gap-4">
<li v-for="(category, categoryId) in categoriesTransactions" :key="categoryId" <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="">
<div class="flex flex-row justify-between w-full items-center"> <div class="flex flex-row justify-between w-full items-center">
<div class="flex flex-row col-span-2 gap-2 items-center"> <div class="flex flex-row col-span-2 gap-2 items-center">

View 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>

View File

@@ -7,11 +7,17 @@ import {ref} from "vue";
import Divider from "primevue/divider"; import Divider from "primevue/divider";
import CategoriesList from "@/components/settings/categories/CategoriesList.vue"; import CategoriesList from "@/components/settings/categories/CategoriesList.vue";
import RecurrentList from "@/components/settings/recurrent/RecurrentList.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([ const pages = ref([
{
"code": "common",
"title": "Общие",
"icon": "pi pi-cog"
},
{ {
"code": "categories", "code": "categories",
"title": "Категории", "title": "Категории",
@@ -57,7 +63,8 @@ const pages = ref([
</ul> </ul>
</div> </div>
<div class=" flex flex-col col-span-5 gap-2 max-h-[90dvh] overflow-y-scroll justify-items-start"> <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'"/> <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> <p v-else-if="selectedModeCode == 'notifications'" class="flex justify-items-start justify-center items-center h-screen">NOTIFICATIONS UNDER CONSTRUCTIONS</p>
</div> </div>

View File

@@ -89,6 +89,7 @@
</div> </div>
<CreateCategoryModal <CreateCategoryModal
v-if="isDialogVisible"
:show="isDialogVisible" :show="isDialogVisible"
:categoryTypes="categoryTypes" :categoryTypes="categoryTypes"
:selectedCategoryType="selectedCategoryType" :selectedCategoryType="selectedCategoryType"
@@ -116,7 +117,6 @@ import {
deleteCategory, deleteCategory,
getCategories, getCategories,
getCategoryTypes, getCategoryTypes,
updateCategory
} from "@/services/categoryService"; } from "@/services/categoryService";
import {useConfirm} from "primevue/useconfirm"; import {useConfirm} from "primevue/useconfirm";
@@ -201,11 +201,6 @@ const closeCreateDialog = () => {
}; };
const saveCategory = async (newCategory: Category) => { const saveCategory = async (newCategory: Category) => {
if (newCategory.id) {
await updateCategory(newCategory.id, newCategory);
} else {
await createCategory(newCategory);
}
await fetchCategories() await fetchCategories()
closeCreateDialog(); closeCreateDialog();
}; };

View File

@@ -3,9 +3,10 @@
class="w-[95%] xl:!w-1/3"> class="w-[95%] xl:!w-1/3">
<div class="flex justify-center gap-4"> <div class="flex justify-center gap-4">
<Button rounded outlined @click="toggleEmojiPicker" class=" flex justify-center !rounded-full !text-4xl !p-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>
<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" 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"> v-if="showEmojiPicker">
@@ -14,16 +15,32 @@
<!-- SelectButton для выбора типа категории --> <!-- SelectButton для выбора типа категории -->
<div class="flex justify-center mt-4"> <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> </div>
<!-- Поля для создания/редактирования категории --> <!-- Поля для создания/редактирования категории -->
<label for="newCategoryName">Название категории:</label> <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> <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"> <div class="button-group">
<button @click="saveCategory" class="create-category-btn">{{ isEditing ? 'Сохранить' : 'Создать' }}</button> <button @click="saveCategory" class="create-category-btn">{{ isEditing ? 'Сохранить' : 'Создать' }}</button>
@@ -32,95 +49,151 @@
</Dialog> </Dialog>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import {ref, watch, computed, PropType} from 'vue'; import {ref, watch, computed, PropType, onMounted} from 'vue';
import Button from "primevue/button"; import Button from "primevue/button";
import Dialog from "primevue/dialog"; import Dialog from "primevue/dialog";
import SelectButton from "primevue/selectbutton"; 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 { const toast = useToast();
components: {
Dialog, const props = defineProps({
Button, SelectButton show: Boolean,
}, categoryTypes: Object as PropType<CategoryType[]>,
props: { category: Object as PropType<Category | null>, // Категория для редактирования (null, если создание)
show: Boolean, })
categoryTypes: Object as PropType<CategoryType[]>, const emit = defineEmits(['saveCategory', 'close-modal'])
category: Object as PropType<Category | null>, // Категория для редактирования (null, если создание) const category = ref<Category>()
}, const icon = ref('🐱');
emits: ['saveCategory', 'close-modal'], const name = ref('');
setup(props, {emit}) { const description = ref('');
const icon = ref('🐱'); const showEmojiPicker = ref(false);
const name = ref(''); const categoryType = ref(props.category?.type || props.categoryTypes[0]); // Тип по умолчанию
const description = ref(''); const isEditing = computed(() => !!props.category); // Если есть категория, значит редактирование
const showEmojiPicker = ref(false); const availableTags = computed(() => {
const categoryType = ref(props.category?.type || props.categoryTypes[0]); // Тип по умолчанию const availableTags = []
const isEditing = computed(() => !!props.category); // Если есть категория, значит редактирование 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;
});
// Если мы редактируем категорию, заполняем поля данными const emojis = ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '😍', '🥰', '😘', '🍎', '🍔', '🍕', '🍣', '☕', '🍺', '🚗', '🚕', '🚴‍♂️', '🚆', '✈️', '⛴️', '🛒', '👗', '💍', '👟', '🛍️', '🎮', '🎥', '🎧', '🎢', '🎨', '🏠', '🛏️', '🧹', '🪴', '🍼', '🏥', '💊', '🩺', '🦷', '💳', '💰', '🏦', '🌍', '🗺️', '🏝️', '🏔️', '💻', '📚', '🖋️', '🏫'];
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 toggleEmojiPicker = () => {
showEmojiPicker.value = !showEmojiPicker.value;
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 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> </script>
<style scoped> <style scoped>

View File

@@ -232,9 +232,9 @@ onMounted(async () => {
<ConfirmDialog/> <ConfirmDialog/>
<Dialog :visible="inviteCreatedDialog" header="Приглашение" @hide="inviteCreatedDialog=false" <Dialog :visible="inviteCreatedDialog" header="Приглашение" @hide="inviteCreatedDialog=false"
@update:visible="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"> Ссылка приглашения: <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)"> <button @click="copyToClipboard('https://luminic.space/spaces/invite/' + invite.code)">
{{ !copied ? 'Копировать' : 'Скопировано!' }} {{ !copied ? 'Копировать' : 'Скопировано!' }}
</button> </button>

View File

@@ -10,17 +10,20 @@ export class CategorySettingType {
export class Category { export class Category {
id: number = null; id: string | null = null;
type: CategoryType; type: CategoryType;
name: string; name: string;
description: string; description: string;
icon: 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.type = type;
this.name = name; this.name = name;
this.description = description; this.description = description;
this.icon = icon; this.icon = icon;
this.tags = tags;
} }
// Метод, который возвращает краткое описание // Метод, который возвращает краткое описание
@@ -36,3 +39,13 @@ export class CategorySetting {
settingValue: number settingValue: number
value: number value: number
} }
export class CategoryTag {
code: string;
name: string;
constructor(code: string, name: string) {
this.code = code;
this.name = name;
}
}

View File

@@ -1,6 +1,6 @@
// src/services/categoryService.ts // src/services/categoryService.ts
import apiClient from '@/services/axiosSetup'; import apiClient from '@/services/axiosSetup';
import {Category} from "@/models/Category"; import {Category, CategoryTag} from "@/models/Category";
import {useSpaceStore} from "@/stores/spaceStore"; // Импортируете настроенный экземпляр axios import {useSpaceStore} from "@/stores/spaceStore"; // Импортируете настроенный экземпляр axios
export const getCategories = async (type = null) => { export const getCategories = async (type = null) => {
@@ -22,12 +22,20 @@ export const getCategoryTypes = async () => {
export const createCategory = async (category: Category) => { export const createCategory = async (category: Category) => {
const spaceStore = useSpaceStore(); 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(); 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) => { export const deleteCategory = async (id: number) => {
@@ -38,5 +46,32 @@ export const deleteCategory = async (id: number) => {
export const getCategoriesSumsRequest = async (spaceId: string) => { export const getCategoriesSumsRequest = async (spaceId: string) => {
const spaceStore = useSpaceStore(); const spaceStore = useSpaceStore();
return await apiClient.get(`/spaces/${spaceStore.space?.id}/analytics/by-month`) 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
})
} }

View File

@@ -83,7 +83,7 @@ export const deleteTransactionRequest = async (id: number) => {
export const getTransactionTypes = async () => { export const getTransactionTypes = async () => {
const spaceStore = useSpaceStore() const spaceStore = useSpaceStore()
return await apiClient.get(`/transactions/types`); return await apiClient.get(`/transactions/types/`);
} }
export const getTransactionCategoriesSums = async () => { export const getTransactionCategoriesSums = async () => {

View File

@@ -3,8 +3,10 @@ import {ref} from 'vue';
import apiClient from "@/services/axiosSetup"; import apiClient from "@/services/axiosSetup";
import {useRoute, useRouter} from "vue-router"; import {useRoute, useRouter} from "vue-router";
import {useSpaceStore} from "@/stores/spaceStore"; import {useSpaceStore} from "@/stores/spaceStore";
import {useToast} from "primevue/usetoast";
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const toast = useToast();
const user = ref(null); const user = ref(null);
const loadingUser = ref(true); const loadingUser = ref(true);
const router = useRouter(); 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 { try {
let response; let response;
if (tg_id) { 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 { } else {
response = await apiClient.post('/auth/login', { response = await apiClient.post('/auth/login', {
username: username, username: username,
@@ -46,12 +49,13 @@ export const useUserStore = defineStore('user', () => {
const token = response.data.token; const token = response.data.token;
localStorage.setItem('token', token); localStorage.setItem('token', token);
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
toast.add({ severity: 'success', summary: 'Вход выполнен', detail: 'Добро пожаловать!', life: 3000 })
await fetchUserProfile(); await fetchUserProfile();
await spaceStore.fetchSpaces() await spaceStore.fetchSpaces()
await router.push(route.query['back'] ? route.query['back'].toString() : '/'); await router.push(route.query['back'] ? route.query['back'].toString() : '/');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
alert('Ошибка входа. Проверьте логин и пароль.'); toast.add({ severity: 'error', summary: 'Ошибка входа', detail: 'Ошибка Telegram авторизации', life: 3000 })
} }
}; };