CreateView.vue
7,547 bytes
| 1 | <script setup lang="ts"> |
|---|---|
| 2 | import { ref, computed, onMounted } from 'vue' |
| 3 | import { useRouter, useRoute } from 'vue-router' |
| 4 | import { useI18n } from 'vue-i18n' |
| 5 | import ExpenseService from '@/services/ExpenseService' |
| 6 | import BudgetCategoryService from '@/services/BudgetCategoryService' |
| 7 | import CurrencyService from '@/services/CurrencyService' |
| 8 | import TripService from '@/services/TripService' |
| 9 | import type { IBudgetCategory } from '@/types/IBudgetCategory' |
| 10 | import type { ICurrency } from '@/types/ICurrency' |
| 11 | import type { ITripParticipant } from '@/types/ITrip' |
| 12 | import type { IExpenseSplitCreate } from '@/types/IExpense' |
| 13 | import { useToast } from '@/composables/useToast' |
| 14 | import { useAuthStore } from '@/stores/auth' |
| 15 | import { getUserIdFromJwt } from '@/utils/parseJwt' |
| 16 | import SplitMethodSelector from '@/components/SplitMethodSelector.vue' |
| 17 | |
| 18 | const router = useRouter() |
| 19 | const route = useRoute() |
| 20 | const toast = useToast() |
| 21 | const auth = useAuthStore() |
| 22 | const { t } = useI18n() |
| 23 | |
| 24 | const tripId = route.params.tripId as string |
| 25 | |
| 26 | const amount = ref<number>(0) |
| 27 | const description = ref('') |
| 28 | const expenseDate = ref(new Date().toISOString().substring(0, 10)) |
| 29 | const splitMethod = ref('EqualAll') |
| 30 | const budgetCategoryId = ref('') |
| 31 | const currencyId = ref('') |
| 32 | const paidByUserId = ref('') |
| 33 | |
| 34 | const budgetCategories = ref<IBudgetCategory[]>([]) |
| 35 | const currencies = ref<ICurrency[]>([]) |
| 36 | const participants = ref<ITripParticipant[]>([]) |
| 37 | const splits = ref<IExpenseSplitCreate[]>([]) |
| 38 | const splitsValid = ref(true) |
| 39 | const errors = ref<string[]>([]) |
| 40 | const isSaving = ref(false) |
| 41 | |
| 42 | const splitMethods = computed(() => [ |
| 43 | { value: 'EqualAll', label: t('expenses.splitMethod.EqualAll'), icon: 'bi-people-fill' }, |
| 44 | { value: 'EqualSubset', label: t('expenses.splitMethod.EqualSubset'), icon: 'bi-person-check-fill' }, |
| 45 | { value: 'ExactAmounts', label: t('expenses.splitMethod.ExactAmounts'), icon: 'bi-hash' }, |
| 46 | { value: 'Percentages', label: t('expenses.splitMethod.Percentages'), icon: 'bi-percent' }, |
| 47 | ]) |
| 48 | |
| 49 | onMounted(async () => { |
| 50 | const [catResult, currResult, partResult] = await Promise.all([ |
| 51 | BudgetCategoryService.getByTrip(tripId), |
| 52 | CurrencyService.getAll(), |
| 53 | TripService.getParticipants(tripId), |
| 54 | ]) |
| 55 | |
| 56 | if (catResult.data) budgetCategories.value = catResult.data |
| 57 | if (currResult.data) currencies.value = currResult.data |
| 58 | if (partResult.data) { |
| 59 | participants.value = partResult.data |
| 60 | const currentUserId = auth.jwt ? getUserIdFromJwt(auth.jwt) : null |
| 61 | const match = partResult.data.find((p) => p.userId === currentUserId) |
| 62 | paidByUserId.value = match?.userId ?? partResult.data[0]?.userId ?? '' |
| 63 | } |
| 64 | }) |
| 65 | |
| 66 | async function handleSubmit() { |
| 67 | errors.value = [] |
| 68 | isSaving.value = true |
| 69 | |
| 70 | const result = await ExpenseService.create({ |
| 71 | tripId, |
| 72 | paidByUserId: paidByUserId.value || null, |
| 73 | amount: amount.value, |
| 74 | description: description.value || null, |
| 75 | expenseDate: new Date(expenseDate.value).toISOString(), |
| 76 | splitMethod: splitMethod.value, |
| 77 | budgetCategoryId: budgetCategoryId.value || null, |
| 78 | currencyId: currencyId.value || null, |
| 79 | splits: splits.value, |
| 80 | }) |
| 81 | |
| 82 | if (result.errors) { |
| 83 | errors.value = result.errors |
| 84 | } else { |
| 85 | toast.success(t('expenses.create.added')) |
| 86 | router.push({ name: 'ExpensesIndex', params: { tripId } }) |
| 87 | } |
| 88 | |
| 89 | isSaving.value = false |
| 90 | } |
| 91 | </script> |
| 92 | |
| 93 | <template> |
| 94 | <div class="row justify-content-center"> |
| 95 | <div class="col-md-8 col-lg-6"> |
| 96 | <h3 class="mb-4"><i class="bi bi-plus-circle me-2 sa-text-primary"></i>{{ t('expenses.create.title') }}</h3> |
| 97 | |
| 98 | <div v-if="errors.length" class="sa-card-static sa-card-accent sa-card-accent-danger mb-4"> |
| 99 | <div class="sa-card-body"> |
| 100 | <div v-for="err in errors" :key="err" style="color: var(--sa-danger); font-size: 0.9rem">{{ err }}</div> |
| 101 | </div> |
| 102 | </div> |
| 103 | |
| 104 | <form @submit.prevent="handleSubmit"> |
| 105 | <div class="mb-4 text-center"> |
| 106 | <label for="amount" class="form-label">{{ t('expenses.create.amount') }}</label> |
| 107 | <input |
| 108 | id="amount" |
| 109 | v-model.number="amount" |
| 110 | type="number" |
| 111 | step="0.01" |
| 112 | min="0.01" |
| 113 | class="form-control sa-amount-input" |
| 114 | placeholder="0.00" |
| 115 | required |
| 116 | /> |
| 117 | </div> |
| 118 | |
| 119 | <div class="mb-3"> |
| 120 | <label for="description" class="form-label">{{ t('expenses.create.description') }}</label> |
| 121 | <input id="description" v-model="description" type="text" class="form-control" :placeholder="t('expenses.create.descriptionPlaceholder')" /> |
| 122 | </div> |
| 123 | |
| 124 | <div class="mb-3"> |
| 125 | <label for="expenseDate" class="form-label">{{ t('expenses.create.date') }}</label> |
| 126 | <input id="expenseDate" v-model="expenseDate" type="date" class="form-control" required /> |
| 127 | </div> |
| 128 | |
| 129 | <div class="mb-3"> |
| 130 | <label for="paidBy" class="form-label">{{ t('expenses.create.paidBy') }}</label> |
| 131 | <select id="paidBy" v-model="paidByUserId" class="form-select" required> |
| 132 | <option value="" disabled>{{ t('expenses.create.selectPayer') }}</option> |
| 133 | <option v-for="p in participants" :key="p.userId" :value="p.userId"> |
| 134 | {{ p.nickname || p.userName || p.userEmail }} |
| 135 | </option> |
| 136 | </select> |
| 137 | </div> |
| 138 | |
| 139 | <div class="mb-4"> |
| 140 | <label class="form-label">{{ t('expenses.create.splitMethod') }}</label> |
| 141 | <div class="sa-split-methods"> |
| 142 | <label v-for="method in splitMethods" :key="method.value" class="sa-split-option"> |
| 143 | <input type="radio" :value="method.value" v-model="splitMethod" /> |
| 144 | <div class="sa-split-option-label"> |
| 145 | <div class="sa-split-option-icon"><i :class="['bi', method.icon]"></i></div> |
| 146 | <div class="sa-split-option-text">{{ method.label }}</div> |
| 147 | </div> |
| 148 | </label> |
| 149 | </div> |
| 150 | |
| 151 | <SplitMethodSelector |
| 152 | v-if="participants.length > 0" |
| 153 | :participants="participants" |
| 154 | :total-amount="amount" |
| 155 | :split-method="splitMethod" |
| 156 | @update:splits="splits = $event" |
| 157 | @update:valid="splitsValid = $event" |
| 158 | /> |
| 159 | </div> |
| 160 | |
| 161 | <div class="mb-3"> |
| 162 | <label for="budgetCategory" class="form-label">{{ t('expenses.create.budgetCategory') }}</label> |
| 163 | <select id="budgetCategory" v-model="budgetCategoryId" class="form-select"> |
| 164 | <option value="">{{ t('common.none') }}</option> |
| 165 | <option v-for="cat in budgetCategories" :key="cat.id" :value="cat.id">{{ cat.name }}</option> |
| 166 | </select> |
| 167 | </div> |
| 168 | |
| 169 | <div class="mb-4"> |
| 170 | <label for="currency" class="form-label">{{ t('expenses.create.currency') }}</label> |
| 171 | <select id="currency" v-model="currencyId" class="form-select"> |
| 172 | <option value="">{{ t('common.default') }}</option> |
| 173 | <option v-for="c in currencies" :key="c.id" :value="c.id">{{ c.code }} — {{ c.name }} ({{ c.symbol }})</option> |
| 174 | </select> |
| 175 | </div> |
| 176 | |
| 177 | <div class="d-flex gap-2 justify-content-end"> |
| 178 | <button type="button" class="sa-btn sa-btn-ghost" @click="router.push({ name: 'ExpensesIndex', params: { tripId } })"> |
| 179 | {{ t('common.cancel') }} |
| 180 | </button> |
| 181 | <button type="submit" class="sa-btn sa-btn-primary" :class="{ 'sa-btn-loading': isSaving }" :disabled="isSaving || !splitsValid"> |
| 182 | <i class="bi bi-check-lg"></i> {{ t('expenses.create.addExpense') }} |
| 183 | </button> |
| 184 | </div> |
| 185 | </form> |
| 186 | </div> |
| 187 | </div> |
| 188 | </template> |
| 189 | |