444 lines
17 KiB
Vue
444 lines
17 KiB
Vue
<script setup lang="ts">
|
|
|
|
import {DatePicker, Divider, InputNumber} from "primevue";
|
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
|
import {useRoute, useRouter} from "vue-router";
|
|
import {useToolbarStore} from "@/stores/toolbar-store";
|
|
import {useToast} from "primevue/usetoast";
|
|
import {useSpaceStore} from "@/stores/spaceStore";
|
|
import {computed, onMounted, ref} from "vue";
|
|
import {Category} from "@/models/category";
|
|
import {TransactionService} from "@/services/transactions-service";
|
|
import {CreateTransactionDTO, UpdateTransactionDTO} from "@/models/transaction";
|
|
import {CategoryType, TransactionKind, TransactionKindName, TransactionType, TransactionTypeName} from "@/models/enums";
|
|
import {useTransactionStore} from "@/stores/transactions-store";
|
|
import {categoriesService} from "@/services/categories-service";
|
|
import ex = CSS.ex;
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const router = useRouter()
|
|
const toolbar = useToolbarStore();
|
|
const toast = useToast();
|
|
const spaceStore = useSpaceStore();
|
|
const transactionService = TransactionService
|
|
const transactionStore = useTransactionStore()
|
|
const tgApp = window.Telegram.WebApp
|
|
|
|
const isCategorySelectorOpened = ref(false);
|
|
const categorySearchQuery = ref<string>("");
|
|
const categories = ref<Category[]>([]);
|
|
|
|
const normalizedQuery = computed(() =>
|
|
categorySearchQuery.value.trim().toLowerCase()
|
|
);
|
|
|
|
const incomeCategories = computed(() =>
|
|
categories.value.filter(i =>
|
|
i.type === CategoryType.INCOME &&
|
|
(normalizedQuery.value
|
|
? i.name.toLowerCase().includes(normalizedQuery.value)
|
|
: true)
|
|
)
|
|
);
|
|
|
|
const expenseCategories = computed(() =>
|
|
categories.value.filter(i =>
|
|
i.type === CategoryType.EXPENSE &&
|
|
(normalizedQuery.value
|
|
? i.name.toLowerCase().includes(normalizedQuery.value)
|
|
: true)
|
|
)
|
|
);const transactionId = ref<number | undefined>(route.params.id)
|
|
const mode = computed(() => {
|
|
return transactionId.value ? "edit" : "create"
|
|
})
|
|
|
|
const isDeleteAlertVisible = ref(false)
|
|
const deleteAlertMessage = ref('Do you want to delete transaction?')
|
|
|
|
// Генерим опции: [{ label: "Расходы", value: "EXPENSE" }, ...]
|
|
const optionsType = Object.values(TransactionType).map(type => ({
|
|
label: TransactionTypeName[type],
|
|
value: type
|
|
}))
|
|
|
|
// Генерим опции: [{ label: "Расходы", value: "EXPENSE" }, ...]
|
|
const optionsKind = Object.values(TransactionKind).map(type => ({
|
|
label: TransactionKindName[type],
|
|
value: type
|
|
}))
|
|
|
|
|
|
const transactionType = ref<TransactionType>(TransactionType.EXPENSE)
|
|
const isTypeError = ref<boolean>(false);
|
|
const transactionKind = ref<TransactionKind>(TransactionKind.INSTANT)
|
|
const isKindError = ref<boolean>(false);
|
|
const transactionCategory = ref<Category>({} as Category)
|
|
const isCategoryError = ref(false)
|
|
const transactionComment = ref<string>('')
|
|
const isCommentError = ref(false)
|
|
const transactionAmount = ref<number>(0)
|
|
const isAmountError = ref(false)
|
|
const transactionDate = ref<Date>(new Date())
|
|
const isDateError = ref(false)
|
|
const isDone = ref(false)
|
|
|
|
const fetchCategories = async () => {
|
|
|
|
try {
|
|
if (spaceStore.selectedSpaceId) {
|
|
categories.value = await categoriesService.fetchCategories(spaceStore.selectedSpaceId)
|
|
if (categories.value.length > 0) {
|
|
transactionCategory.value = categories.value[0]
|
|
}
|
|
} else throw Error("No space selected")
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.add({
|
|
severity: "error",
|
|
summary: "Error while fetching categories.",
|
|
detail: error.message,
|
|
life: 3000
|
|
})
|
|
}
|
|
}
|
|
|
|
const fetchData = async () => {
|
|
|
|
try {
|
|
if (spaceStore.selectedSpaceId && transactionId.value) {
|
|
categories.value = await categoriesService.fetchCategories(spaceStore.selectedSpaceId);
|
|
let data = await transactionService.getTransaction(spaceStore.selectedSpaceId, Number(transactionId.value));
|
|
transactionType.value = data.type;
|
|
transactionKind.value = data.kind;
|
|
transactionCategory.value = data.category;
|
|
transactionComment.value = data.comment;
|
|
transactionAmount.value = data.amount;
|
|
transactionDate.value = new Date(data.date);
|
|
} else {
|
|
throw new Error("Could not find any space")
|
|
}
|
|
} catch (error) {
|
|
toast.add({
|
|
severity: "error",
|
|
summary: "Failed to load transaction data.",
|
|
detail: error,
|
|
life: 3000
|
|
})
|
|
}
|
|
}
|
|
|
|
const validateForm = (): boolean => {
|
|
// if (!transactionType.value) {
|
|
// isTypeError.value = true;
|
|
// return false;
|
|
// }
|
|
// if (!transactionKind.value) {
|
|
// isKindError.value = true;
|
|
// return false;
|
|
// }
|
|
if (!transactionCategory.value) {
|
|
isCategoryError.value = true
|
|
return false
|
|
}
|
|
if (transactionComment.value.length == 0) {
|
|
isCommentError.value = true
|
|
return false
|
|
}
|
|
if (transactionAmount.value <= 0) {
|
|
isAmountError.value = true
|
|
return false
|
|
}
|
|
if (!transactionDate.value) {
|
|
isDateError.value = true
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
const buildUpdate = (): UpdateTransactionDTO => {
|
|
if (validateForm()) {
|
|
return {
|
|
type: transactionType.value,
|
|
kind: transactionDate.value < new Date() ? TransactionKind.INSTANT : TransactionKind.PLANNING,
|
|
categoryId: transactionCategory.value.id,
|
|
comment: transactionComment.value,
|
|
amount: transactionAmount.value,
|
|
fees: 0,
|
|
isDone: isDone.value,
|
|
date: transactionDate.value
|
|
} as UpdateTransactionDTO
|
|
} else {
|
|
throw new Error("Form is not valid")
|
|
}
|
|
}
|
|
|
|
const buildCreate = (): CreateTransactionDTO => {
|
|
if (validateForm()) {
|
|
return {
|
|
type: transactionType.value,
|
|
kind: transactionDate.value < new Date() ? TransactionKind.INSTANT : TransactionKind.PLANNING,
|
|
categoryId: transactionCategory.value.id,
|
|
comment: transactionComment.value,
|
|
amount: transactionAmount.value,
|
|
fees: 0,
|
|
date: transactionDate.value
|
|
} as CreateTransactionDTO
|
|
} else {
|
|
throw new Error("Form is not valid")
|
|
}
|
|
}
|
|
|
|
const moveUser = async () => {
|
|
if (window.history.length > 1) {
|
|
console.log('moved back')
|
|
router.back()
|
|
} else {
|
|
console.log('moved forward')
|
|
await router.push('/categories')
|
|
}
|
|
}
|
|
|
|
const deleteTransaction = async () => {
|
|
if (spaceStore.selectedSpaceId && transactionId.value) {
|
|
await transactionService.deleteTransaction(spaceStore.selectedSpaceId, Number(transactionId.value))
|
|
await transactionStore.fetchTransactions(spaceStore.selectedSpaceId)
|
|
await moveUser()
|
|
}
|
|
}
|
|
|
|
const insetTop = ref(54)
|
|
|
|
|
|
onMounted(async () => {
|
|
if (route.query.type === "EXPENSE") transactionType.value = TransactionType.EXPENSE
|
|
if (route.query.type === "INCOME") transactionType.value = TransactionType.INCOME
|
|
|
|
if (route.query.kind === "INSTANT") transactionKind.value = TransactionKind.INSTANT
|
|
if (route.query.kind === "PLANNING") transactionKind.value = TransactionKind.PLANNING
|
|
|
|
// Remove query params AFTER reading them
|
|
if (route.query.type || route.query.kind) {
|
|
router.replace({path: route.path}) // instead of window.location
|
|
}
|
|
await fetchCategories()
|
|
if (tgApp && ['ios', 'android'].includes(tgApp.platform)) {
|
|
insetTop.value = tgApp.contentSafeAreaInset.top + tgApp.safeAreaInset.top
|
|
}
|
|
|
|
if (mode.value === "edit") {
|
|
await fetchData()
|
|
toolbar.registerHandler('deleteTransaction', () => {
|
|
if (tgApp.initData) {
|
|
tgApp.showConfirm(deleteAlertMessage.value, async (confirmed: boolean) => {
|
|
if (confirmed) {
|
|
await deleteTransaction()
|
|
}
|
|
})
|
|
} else {
|
|
isDeleteAlertVisible.value = true
|
|
}
|
|
})
|
|
toolbar.registerHandler('updateTransaction', async () => {
|
|
if (spaceStore.selectedSpaceId) {
|
|
// await categoriesStore.fetchCategories(spaceStore.selectedSpaceId)
|
|
try {
|
|
let updateDTO = buildUpdate()
|
|
await transactionService.updateTransaction(spaceStore.selectedSpaceId, Number(transactionId.value), updateDTO)
|
|
console.log(updateDTO)
|
|
await moveUser()
|
|
} catch (e) {
|
|
toast.add({
|
|
severity: "error",
|
|
summary: "Error while updating transaction",
|
|
detail: e,
|
|
life: 3000,
|
|
})
|
|
}
|
|
}
|
|
})
|
|
} else {
|
|
toolbar.registerHandler('createTransaction', async () => {
|
|
if (spaceStore.selectedSpaceId) {
|
|
// await categoriesStore.fetchCategories(spaceStore.selectedSpaceId)
|
|
try {
|
|
let createDTO = buildCreate()
|
|
await transactionService.createTransaction(spaceStore.selectedSpaceId, createDTO)
|
|
console.log(createDTO)
|
|
await moveUser()
|
|
} catch (e) {
|
|
toast.add({
|
|
severity: "error",
|
|
summary: "Error while creating transaction",
|
|
detail: e,
|
|
life: 3000
|
|
})
|
|
}
|
|
}
|
|
|
|
})
|
|
}
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
<template>
|
|
|
|
<div class="flex ">
|
|
<span v-if="categories.length == 0" class="card">Looks like you have no created categories yet. <router-link
|
|
to="/categories/create" class="!text-blue-400">Try to create some first.</router-link></span>
|
|
|
|
<div v-else class="flex flex-col w-full justify-items-start gap-1 pb-4">
|
|
<ConfirmDialog
|
|
v-if="isDeleteAlertVisible"
|
|
:message="deleteAlertMessage"
|
|
:callback="(confirmed) => { if (confirmed) deleteTransaction(); isDeleteAlertVisible = false; }"
|
|
/>
|
|
<!-- Fixed modal container -->
|
|
<div v-if="isCategorySelectorOpened"
|
|
class="absolute inset-0 top-0 z-[1000] flex items-start justify-center p-4 overflow-y-auto "
|
|
style="background-color: var(--primary-color); padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom);"
|
|
|
|
:style="tgApp ? `padding-top: ${insetTop}px !important` : 'padding-top: 2rem !important'">
|
|
<div class="flex flex-col w-full max-w-md !h-dvh !pb-20">
|
|
<div class="card w-full">
|
|
<input class=" p-2 rounded-xl border border-gray-100 w-full" placeholder="Search categories" v-model="categorySearchQuery">
|
|
</div>
|
|
<div v-if="incomeCategories.length > 0" class="flex flex-col w-full">
|
|
<span>Income categories</span>
|
|
<div class="flex card justify-items-start justify-start h-fit">
|
|
<div v-for="(cat, idx) in incomeCategories" :key="cat.id"
|
|
@click="transactionCategory = cat; transactionType = cat.type == 'EXPENSE' ? TransactionType.EXPENSE : TransactionType.INCOME; isCategorySelectorOpened = false"
|
|
class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center font-bold cursor-pointer hover:bg-gray-50 transition-colors">
|
|
<div class="flex flex-row w-full items-center justify-between py-3">
|
|
<div class="flex flex-row items-center gap-2">
|
|
<span class="text-3xl">{{ cat.icon }} </span>
|
|
<div class="flex flex-col justify-between">
|
|
<div class="flex flex-row"> {{ cat.name }}</div>
|
|
<div class="flex flex-row text-sm text-gray-600">{{ cat.description }}</div>
|
|
</div>
|
|
</div>
|
|
<i class="pi pi-angle-right !font-extralight"/>
|
|
</div>
|
|
<Divider v-if="idx + 1 !== incomeCategories.length" class="!m-0"/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="expenseCategories.length>0" class="flex flex-col w-full">
|
|
<span>Expense categories</span>
|
|
<div class="flex card justify-items-start justify-start h-fit">
|
|
<div v-for="(cat, idx) in expenseCategories" :key="cat.id"
|
|
@click="transactionCategory = cat; transactionType = cat.type == 'EXPENSE' ? TransactionType.EXPENSE : TransactionType.INCOME; isCategorySelectorOpened = false"
|
|
class="flex flex-col w-full gap-0 pl-5 items-start justify-items-center font-bold cursor-pointer hover:bg-gray-50 transition-colors">
|
|
<div class="flex flex-row w-full items-center justify-between py-3">
|
|
<div class="flex flex-row items-center gap-2">
|
|
<span class="text-3xl">{{ cat.icon }} </span>
|
|
<div class="flex flex-col justify-between">
|
|
<div class="flex flex-row"> {{ cat.name }}</div>
|
|
<div class="flex flex-row text-sm text-gray-600">{{ cat.description }}</div>
|
|
</div>
|
|
</div>
|
|
<i class="pi pi-angle-right !font-extralight"/>
|
|
</div>
|
|
<Divider v-if="idx + 1 !== expenseCategories.length" class="!m-0"/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col w-full ">
|
|
<div class="flex flex-col w-full">
|
|
<InputNumber
|
|
v-model="transactionAmount"
|
|
@input=" isAmountError = false"
|
|
|
|
type="text"
|
|
inputmode="numeric"
|
|
placeholder="Amount"
|
|
suffix="₽"
|
|
class="text-7xl font-bold w-full text-center focus:outline-none !p-0 !m-0"
|
|
|
|
/>
|
|
<span v-if="isAmountError" class="text-sm !text-red-500 font-extralight">Amount couldn't be less then 1</span>
|
|
<!-- <span class="absolute right-2 top-1/2 -translate-y-1/2 text-7xl font-bold">₽</span>-->
|
|
|
|
<label class="!justify-items-center !justify-center !font-extralight text-gray-600 text-center">Amount</label>
|
|
</div>
|
|
</div>
|
|
<div class="flex lg:flex-row flex-col w-full justify-between gap-4 ">
|
|
<!-- <div class="card flex flex-col w-full items-center justify-center">-->
|
|
<!-- <span class="text-lg hidden lg:flex">Тип транзакции</span>-->
|
|
<!-- <SelectButton v-model="transactionType" :options="optionsType" optionLabel="label"-->
|
|
<!-- optionValue="value"-->
|
|
<!-- class="!w-full !items-center !justify-center !border-none "/>-->
|
|
<!-- </div>-->
|
|
<!-- <div class="card flex flex-col w-full items-center justify-center">-->
|
|
|
|
<!-- <span class="text-lg hidden lg:flex">Вид транзакции</span>-->
|
|
<!-- <SelectButton v-model="transactionKind" :options="optionsKind" optionLabel="label"-->
|
|
<!-- optionValue="value"-->
|
|
<!-- class="!w-full !items-center !justify-center !border-none "/>-->
|
|
<!-- </div>-->
|
|
</div>
|
|
<div class="flex flex-row w-full justify-between gap-4 ">
|
|
<div class="flex flex-col w-full justify-items-start">
|
|
<label class="!font-semibold text-gray-600 pl-2">Transaction comment</label>
|
|
<div class="flex card !justify-start !items-start !p-[1.11rem] !pl-5 ">
|
|
<input class="font-extralight w-full focus:outline-0" placeholder="Comment"
|
|
@input="transactionComment?.length ==0 ? isCommentError = true : isCommentError=false"
|
|
v-model="transactionComment"/>
|
|
</div>
|
|
<span v-if="isCommentError" class="text-sm !text-red-500 font-extralight">Comment couldn't be empty.</span>
|
|
|
|
</div>
|
|
<div class="flex flex-col w-full justify-items-start">
|
|
<label class="!font-semibold text-gray-600 pl-2">Transaction category</label>
|
|
<div class="flex card !justify-start !items-start !p-3 !pl-5 cursor-pointer"
|
|
@click="isCategorySelectorOpened = true; isCategoryError=false;">
|
|
<div class="flex flex-row w-full gap-2 items-center justify-between">
|
|
<div v-if="transactionCategory" class="flex flex-row gap-2 items-center">
|
|
<span class="!text-3xl ">{{ transactionCategory.icon }}</span>
|
|
<div class="flex flex-col ">
|
|
<span class=" !">{{ transactionCategory.name }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div v-else class="flex flex-row gap-2 items-center">
|
|
<i class="pi pi-question !text-3xl "></i>
|
|
<div class="flex flex-col ">
|
|
<span class=" !">Категория не определена
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<i class="pi pi-angle-right !font-extralight"/>
|
|
</div>
|
|
</div>
|
|
<span v-if="isCategoryError" class="text-sm text-red-500 font-extralight">Category should be selected</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex !flex-col w-full justify-items-start">
|
|
<label class="!font-semibold text-gray-600 !pl-2">Transaction date</label>
|
|
<div class="card flex justify-center">
|
|
<DatePicker inline v-model="transactionDate" class="w-auto !border-0"/>
|
|
</div>
|
|
|
|
<span v-if="isDateError" class="text-sm text-red-500 font-extralight">
|
|
Date couldn't be empty or less than 1 and greater than 31.
|
|
</span>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
:deep(.p-datepicker-panel) {
|
|
width: 100% !important;
|
|
border: none;
|
|
}
|
|
</style> |