CreateView.vue
7,308 bytes
| 1 | <script setup lang="ts"> |
|---|---|
| 2 | import { ref, computed, onMounted } from 'vue' |
| 3 | import { useRouter } from 'vue-router' |
| 4 | import { useI18n } from 'vue-i18n' |
| 5 | import TripService from '@/services/TripService' |
| 6 | import CurrencyService from '@/services/CurrencyService' |
| 7 | import type { ICurrency } from '@/types/ICurrency' |
| 8 | |
| 9 | const router = useRouter() |
| 10 | const { t } = useI18n() |
| 11 | |
| 12 | const name = ref('') |
| 13 | const description = ref('') |
| 14 | const destination = ref('') |
| 15 | const startDate = ref('') |
| 16 | const endDate = ref('') |
| 17 | const defaultCurrencyId = ref('') |
| 18 | const currencies = ref<ICurrency[]>([]) |
| 19 | const errors = ref<string[]>([]) |
| 20 | const isSaving = ref(false) |
| 21 | |
| 22 | // Inline validation: end date must be on or after start date. |
| 23 | // The ISO yyyy-MM-dd format from <input type="date"> compares lexicographically. |
| 24 | const dateError = computed<string | null>(() => { |
| 25 | if (!startDate.value || !endDate.value) return null |
| 26 | return endDate.value < startDate.value |
| 27 | ? t('trips.create.dateError') |
| 28 | : null |
| 29 | }) |
| 30 | |
| 31 | const tripNights = computed<number | null>(() => { |
| 32 | if (!startDate.value || !endDate.value || dateError.value) return null |
| 33 | const ms = new Date(endDate.value).getTime() - new Date(startDate.value).getTime() |
| 34 | return Math.max(0, Math.round(ms / 86_400_000)) |
| 35 | }) |
| 36 | |
| 37 | onMounted(async () => { |
| 38 | const result = await CurrencyService.getAll() |
| 39 | if (result.data) { |
| 40 | currencies.value = result.data |
| 41 | const eur = result.data.find((c) => c.code === 'EUR') |
| 42 | if (eur) defaultCurrencyId.value = eur.id |
| 43 | } |
| 44 | }) |
| 45 | |
| 46 | async function handleSubmit() { |
| 47 | if (dateError.value) { |
| 48 | errors.value = [dateError.value] |
| 49 | return |
| 50 | } |
| 51 | errors.value = [] |
| 52 | isSaving.value = true |
| 53 | |
| 54 | const result = await TripService.create({ |
| 55 | name: name.value, |
| 56 | description: description.value || null, |
| 57 | destination: destination.value || null, |
| 58 | startDate: startDate.value ? new Date(startDate.value).toISOString() : null, |
| 59 | endDate: endDate.value ? new Date(endDate.value).toISOString() : null, |
| 60 | defaultCurrencyId: defaultCurrencyId.value, |
| 61 | }) |
| 62 | |
| 63 | if (result.errors) { |
| 64 | errors.value = result.errors |
| 65 | } else { |
| 66 | router.push({ name: 'TripsIndex' }) |
| 67 | } |
| 68 | |
| 69 | isSaving.value = false |
| 70 | } |
| 71 | </script> |
| 72 | |
| 73 | <template> |
| 74 | <div class="row justify-content-center"> |
| 75 | <div class="col-lg-8 col-xl-6"> |
| 76 | <!-- Gradient hero --> |
| 77 | <div class="sa-gradient-header"> |
| 78 | <h2 class="mb-1"><i class="bi bi-suitcase-lg me-2"></i>{{ t('trips.create.title') }}</h2> |
| 79 | <p class="mb-0 text-muted" style="font-size: 0.9rem"> |
| 80 | {{ t('trips.create.subtitle') }} |
| 81 | </p> |
| 82 | </div> |
| 83 | |
| 84 | <div v-if="errors.length" class="alert alert-danger"> |
| 85 | <div v-for="error in errors" :key="error">{{ error }}</div> |
| 86 | </div> |
| 87 | |
| 88 | <!-- Form card --> |
| 89 | <div class="sa-card"> |
| 90 | <div class="sa-card-body"> |
| 91 | <form @submit.prevent="handleSubmit"> |
| 92 | <div class="mb-3"> |
| 93 | <label for="name" class="form-label"> |
| 94 | <i class="bi bi-pencil-square me-1"></i> {{ t('trips.create.name') }} * |
| 95 | </label> |
| 96 | <input |
| 97 | id="name" |
| 98 | v-model="name" |
| 99 | type="text" |
| 100 | class="form-control" |
| 101 | :placeholder="t('trips.create.namePlaceholder')" |
| 102 | required |
| 103 | /> |
| 104 | </div> |
| 105 | |
| 106 | <div class="mb-3"> |
| 107 | <label for="destination" class="form-label"> |
| 108 | <i class="bi bi-geo-alt me-1"></i> {{ t('trips.create.destination') }} |
| 109 | </label> |
| 110 | <input |
| 111 | id="destination" |
| 112 | v-model="destination" |
| 113 | type="text" |
| 114 | class="form-control" |
| 115 | :placeholder="t('trips.create.destinationPlaceholder')" |
| 116 | /> |
| 117 | </div> |
| 118 | |
| 119 | <div class="mb-3"> |
| 120 | <label for="description" class="form-label"> |
| 121 | <i class="bi bi-card-text me-1"></i> {{ t('trips.create.description') }} |
| 122 | </label> |
| 123 | <textarea |
| 124 | id="description" |
| 125 | v-model="description" |
| 126 | class="form-control" |
| 127 | rows="3" |
| 128 | :placeholder="t('trips.create.descriptionPlaceholder')" |
| 129 | ></textarea> |
| 130 | </div> |
| 131 | |
| 132 | <div class="row g-3 mb-1"> |
| 133 | <div class="col-6"> |
| 134 | <label for="startDate" class="form-label"> |
| 135 | <i class="bi bi-calendar-event me-1"></i> {{ t('trips.create.start') }} |
| 136 | </label> |
| 137 | <input |
| 138 | id="startDate" |
| 139 | v-model="startDate" |
| 140 | type="date" |
| 141 | class="form-control" |
| 142 | /> |
| 143 | </div> |
| 144 | <div class="col-6"> |
| 145 | <label for="endDate" class="form-label"> |
| 146 | <i class="bi bi-calendar-check me-1"></i> {{ t('trips.create.end') }} |
| 147 | </label> |
| 148 | <input |
| 149 | id="endDate" |
| 150 | v-model="endDate" |
| 151 | type="date" |
| 152 | class="form-control" |
| 153 | :class="{ 'is-invalid': dateError }" |
| 154 | :min="startDate || undefined" |
| 155 | /> |
| 156 | <div v-if="dateError" class="invalid-feedback">{{ dateError }}</div> |
| 157 | </div> |
| 158 | </div> |
| 159 | <div class="mb-3" style="min-height: 1.25rem"> |
| 160 | <small v-if="tripNights !== null" class="text-muted"> |
| 161 | <i class="bi bi-moon-stars me-1"></i> |
| 162 | {{ t('trips.create.nightCount', { n: tripNights }, tripNights) }} |
| 163 | </small> |
| 164 | </div> |
| 165 | |
| 166 | <div class="mb-4"> |
| 167 | <label for="currency" class="form-label"> |
| 168 | <i class="bi bi-currency-exchange me-1"></i> {{ t('trips.create.defaultCurrency') }} |
| 169 | </label> |
| 170 | <select |
| 171 | id="currency" |
| 172 | v-model="defaultCurrencyId" |
| 173 | class="form-select" |
| 174 | required |
| 175 | > |
| 176 | <option value="" disabled>{{ t('trips.create.selectCurrency') }}</option> |
| 177 | <option v-for="c in currencies" :key="c.id" :value="c.id"> |
| 178 | {{ c.code }} — {{ c.name }} ({{ c.symbol }}) |
| 179 | </option> |
| 180 | </select> |
| 181 | </div> |
| 182 | |
| 183 | <div class="d-flex justify-content-end gap-2"> |
| 184 | <button |
| 185 | type="button" |
| 186 | class="sa-btn sa-btn-ghost sa-btn-pill" |
| 187 | @click="router.push({ name: 'TripsIndex' })" |
| 188 | > |
| 189 | {{ t('common.cancel') }} |
| 190 | </button> |
| 191 | <button |
| 192 | type="submit" |
| 193 | class="sa-btn sa-btn-primary sa-btn-pill" |
| 194 | :disabled="isSaving || !!dateError" |
| 195 | > |
| 196 | <span v-if="isSaving"> |
| 197 | <span |
| 198 | class="spinner-border spinner-border-sm me-1" |
| 199 | role="status" |
| 200 | aria-hidden="true" |
| 201 | ></span> |
| 202 | {{ t('trips.create.creating') }} |
| 203 | </span> |
| 204 | <span v-else> |
| 205 | <i class="bi bi-check-lg me-1"></i> {{ t('trips.create.createTrip') }} |
| 206 | </span> |
| 207 | </button> |
| 208 | </div> |
| 209 | </form> |
| 210 | </div> |
| 211 | </div> |
| 212 | </div> |
| 213 | </div> |
| 214 | </template> |
| 215 | |