Replaces imperative validate() blocks in TransactionCreate, signup, and AccountNew with schema-driven validation. Drops the dead "(!currency && !date && payFor.length === 0)" branch in TransactionCreate that was hiding behind the generic error.
332 lines
11 KiB
Vue
332 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { ref, reactive, computed, watch, onBeforeMount } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useRouter } from 'vue-router'
|
|
import { format } from 'date-fns'
|
|
import { storeToRefs } from 'pinia'
|
|
import { useUserStore } from '@/stores/user'
|
|
import type IAccount from '@/models/IAccount'
|
|
import type ICurrency from '@/models/ICurrency'
|
|
import type ITransaction from '@/models/ITransaction'
|
|
import type ISplit from '@/models/ISplit'
|
|
import type ILocation from '@/models/ILocation'
|
|
import TransactionType from '@/enums/TransactionType'
|
|
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
|
|
import exchangeService from '@/services/ExchangeService'
|
|
import transactionService from '@/services/TransactionService'
|
|
import mapService from '@/services/MapService'
|
|
import { money } from '@/utils/format'
|
|
import { findContrastColor } from '@/utils'
|
|
import notif from '@/utils/notif'
|
|
import { validateAndNotify } from '@/utils/validate'
|
|
import { transactionFormSchema, MAX_TRANSACTION_AMOUNT } from '@/schemas/transaction'
|
|
import FabButton from '@/components/FabButton.vue'
|
|
import TransactionSplit from '@/components/TransactionSplit.vue'
|
|
import TransactionTagUpdate from '@/components/TransactionTagUpdate.vue'
|
|
import EarthMap from '@/components/EarthMap.vue'
|
|
import CurrencyInput from '@/components/CurrencyInput.vue'
|
|
|
|
const props = defineProps<{
|
|
transaction: ITransaction
|
|
account: IAccount
|
|
}>()
|
|
|
|
const router = useRouter()
|
|
const { t } = useI18n()
|
|
const userStore = useUserStore()
|
|
const { locale } = storeToRefs(userStore)
|
|
|
|
const formatToDate = (d: Date | string) => format(new Date(d), 'yyyy-MM-dd')
|
|
const todayDate = new Date()
|
|
|
|
const name = ref('')
|
|
const amount = ref<number | null>(null)
|
|
const tag = ref<TransactionTag>(TransactionTag.None)
|
|
const today = formatToDate(todayDate)
|
|
const date = ref(today)
|
|
const currency = ref<ICurrency | null>(null)
|
|
const payBy = ref('')
|
|
const payFor = reactive<ISplit[]>([])
|
|
const maxAmount = MAX_TRANSACTION_AMOUNT
|
|
const displayLocationModal = ref(false)
|
|
const isSetLocation = ref(false)
|
|
const location = ref<ILocation | null>(null)
|
|
const noTag = TransactionTag.None
|
|
|
|
onBeforeMount(() => {
|
|
setData(props.transaction)
|
|
})
|
|
|
|
const setData = (t: ITransaction) => {
|
|
name.value = t.name
|
|
date.value = formatToDate(t.date)
|
|
tag.value = t.tag
|
|
amount.value = t.amount || null
|
|
payBy.value = t.payBy
|
|
payFor.splice(0, payFor.length, ...t.payFor)
|
|
if (props.account.users) {
|
|
props.account.users.forEach((u) => {
|
|
if (!t.payFor.find((p) => p.alias === u.alias)) {
|
|
payFor.push({ alias: u.alias || '', weight: 0 })
|
|
}
|
|
})
|
|
}
|
|
currency.value = t.mainCurrency
|
|
}
|
|
|
|
const payForUsers = computed(() => payFor.filter((p) => p.weight > 0))
|
|
|
|
const backgroundColor = computed(() => {
|
|
if (!props.account || !props.account.color) return undefined
|
|
return {
|
|
backgroundColor: props.account.color,
|
|
color: findContrastColor(props.account.color) || 'black'
|
|
}
|
|
})
|
|
|
|
watch(payBy, (newValue, oldValue) => {
|
|
if (oldValue && payFor.length !== props.account.users.length) {
|
|
const old = payFor.find((p) => p.alias === oldValue)
|
|
if (old) old.weight = 0
|
|
}
|
|
if (newValue) {
|
|
const newPayBy = payFor.find((p) => p.alias === newValue)
|
|
if (newPayBy) newPayBy.weight = newPayBy.weight || 1
|
|
}
|
|
})
|
|
|
|
const selectAll = () => {
|
|
payFor.forEach((p) => (p.weight = p.weight || 1))
|
|
}
|
|
|
|
const located = (loc: ILocation) => {
|
|
location.value = loc
|
|
}
|
|
|
|
const validLocation = async () => {
|
|
try {
|
|
isSetLocation.value = true
|
|
if (location.value) {
|
|
const place = await mapService.getPlace(location.value)
|
|
props.transaction.location = { ...location.value, place }
|
|
}
|
|
} catch {
|
|
props.transaction.location = props.transaction.location
|
|
} finally {
|
|
displayLocationModal.value = false
|
|
isSetLocation.value = false
|
|
}
|
|
}
|
|
|
|
const validate = (): boolean => {
|
|
const el = document.querySelector(':focus') as HTMLInputElement | null
|
|
el?.blur()
|
|
return validateAndNotify(
|
|
transactionFormSchema,
|
|
{ name: name.value, amount: amount.value, payBy: payBy.value },
|
|
t,
|
|
{ amount: { max: money(maxAmount, currency.value) } }
|
|
)
|
|
}
|
|
|
|
const submitTransaction = async (finish: () => void) => {
|
|
if (!validate() || !currency.value || !props.account) {
|
|
finish()
|
|
return
|
|
}
|
|
const currencies = props.account.currencies.map((c) => c.code)
|
|
const d = new Date(date.value)
|
|
d.setHours(todayDate.getHours(), todayDate.getMinutes())
|
|
const hasId = !!props.transaction._id
|
|
const numericAmount = amount.value ? parseFloat(amount.value.toString().replace(',', '.')) : 0
|
|
const transaction: ITransaction = {
|
|
_id: props.transaction._id,
|
|
_rev: props.transaction._rev,
|
|
doctype: props.transaction.doctype,
|
|
accountId: props.account._id || '',
|
|
name: name.value,
|
|
date: d,
|
|
tag: tag.value,
|
|
amount: numericAmount,
|
|
payBy: payBy.value,
|
|
payFor: payForUsers.value,
|
|
userIds: props.account.userIds,
|
|
mainCurrency: currency.value,
|
|
currencies: props.account.currencies,
|
|
transactionType: TransactionType.normal,
|
|
location: props.transaction.location,
|
|
exchange: await exchangeService.get(currency.value.code, d, currencies)
|
|
}
|
|
const response = hasId
|
|
? await transactionService.save(transaction)
|
|
: await transactionService.add(props.account._id || '', transaction)
|
|
if (response.ok) {
|
|
if (hasId) {
|
|
router.push({ name: 'transaction', params: { id: props.transaction._id || '' } })
|
|
notif.success('Dépense mise à jour.')
|
|
} else {
|
|
router.push({ name: 'account', params: { id: props.account._id || '' } })
|
|
notif.success('Dépense créée.')
|
|
}
|
|
} else {
|
|
console.warn(response)
|
|
notif.error(`Une erreur s'est produite à la création d'une transaction.`)
|
|
}
|
|
finish()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="transaction-create">
|
|
<nav v-if="account" class="breadcrumbs text-sm mb-3" aria-label="breadcrumbs">
|
|
<ul>
|
|
<li>
|
|
<router-link :to="{ name: 'account', params: { id: account._id } }">
|
|
{{ account.name }}
|
|
</router-link>
|
|
</li>
|
|
<li v-if="tag && tag !== noTag">
|
|
<a href="#" @click.prevent>{{ TransactionTagLabel[tag].label }}</a>
|
|
</li>
|
|
<li>
|
|
<a href="#" @click.prevent>
|
|
<span v-if="transaction._id">{{ name }}</span>
|
|
<span v-else>Nouvelle dépense</span>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
<div class="grid md:grid-cols-2 gap-6">
|
|
<div class="space-y-4">
|
|
<div class="flex items-center gap-3">
|
|
<label class="label w-24">Nom</label>
|
|
<div class="flex-1">
|
|
<input
|
|
v-model.trim="name"
|
|
type="text"
|
|
maxlength="30"
|
|
placeholder="Nom de la dépense"
|
|
class="input input-bordered w-full"
|
|
/>
|
|
<p class="text-xs text-info mt-1">
|
|
{{ t('validation.max_char', { max: 30 - name.length }, 30 - name.length) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<label class="label w-24">Montant</label>
|
|
<div class="flex-1">
|
|
<div class="join w-full">
|
|
<CurrencyInput
|
|
v-model="amount"
|
|
:locale="locale"
|
|
:precision="2"
|
|
:allow-negative="false"
|
|
:value-range="{ min: 0, max: 1000000 }"
|
|
placeholder="Montant"
|
|
class="join-item flex-1"
|
|
/>
|
|
<span v-if="account.currencies.length > 1" class="join-item">
|
|
<select v-model="currency" name="currency" class="select select-bordered">
|
|
<option v-for="(c, k) in account.currencies" :key="k" :value="c">
|
|
{{ c.symbol || c.name }}
|
|
</option>
|
|
</select>
|
|
</span>
|
|
<span v-else class="btn btn-disabled join-item">
|
|
{{ account.mainCurrency.symbol || account.mainCurrency.name }}
|
|
</span>
|
|
</div>
|
|
<p class="text-xs text-info mt-1">
|
|
montant max : {{ money(maxAmount, currency) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<label class="label w-24">Localisation</label>
|
|
<div class="flex-1 flex gap-2">
|
|
<input
|
|
v-if="transaction.location"
|
|
:value="transaction.location.place"
|
|
type="text"
|
|
readonly
|
|
class="input input-bordered flex-1"
|
|
/>
|
|
<button class="btn btn-primary" @click="displayLocationModal = true">définir</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<label class="label w-24">Date</label>
|
|
<input
|
|
v-model="date"
|
|
type="date"
|
|
class="input input-bordered"
|
|
placeholder="Date"
|
|
:max="today"
|
|
/>
|
|
</div>
|
|
|
|
<TransactionTagUpdate v-model="tag" />
|
|
</div>
|
|
|
|
<div v-if="account.users.length > 1" class="space-y-4">
|
|
<div class="flex items-center gap-3">
|
|
<label class="label w-24">Payé par</label>
|
|
<select v-model="payBy" name="pay-by" class="select select-bordered flex-1">
|
|
<option v-for="(u, k) in account.users" :key="k" :value="u.alias">
|
|
{{ u.alias }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<div class="flex items-center gap-3 mb-2">
|
|
<label class="label w-24">Pour</label>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary btn-sm"
|
|
:class="{ 'btn-outline': payForUsers.length !== account.users.length }"
|
|
@click="selectAll"
|
|
>
|
|
tous
|
|
</button>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div v-for="(split, k) in payFor" :key="k" class="user-item">
|
|
<TransactionSplit :split="payFor[k]" />
|
|
<hr v-if="k !== payFor.length - 1" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<dialog class="modal" :class="{ 'modal-open': displayLocationModal }">
|
|
<div class="modal-box max-w-3xl">
|
|
<EarthMap
|
|
v-if="displayLocationModal"
|
|
:locations="transaction.location ? [transaction.location] : undefined"
|
|
:define-location="true"
|
|
@located="located"
|
|
/>
|
|
<div class="modal-action">
|
|
<button
|
|
class="btn btn-primary"
|
|
:class="{ 'btn-loading': isSetLocation }"
|
|
@click="validLocation"
|
|
>
|
|
valider
|
|
</button>
|
|
<button class="btn" @click="displayLocationModal = false">annuler</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
|
|
<FabButton :margin="true" :button-style="backgroundColor" @valid="submitTransaction">
|
|
<vaquant-icon icon="check" />
|
|
</FabButton>
|
|
</div>
|
|
</template>
|