581 lines
22 KiB
Vue
581 lines
22 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Editing a reservation, from the admin page or from "my reservations". Only the
|
|
* period, the bikes and the Linka Go accounts can change — the unit, the
|
|
* requester, the telegram handle and the reason are shown for context and are
|
|
* not part of the request, so the backend keeps them as stored.
|
|
*
|
|
* `emails-only` narrows it further, for somebody who is on an already approved
|
|
* reservation: the period and the fleet are settled, and only the list of
|
|
* accounts allowed to unlock stays open. The backend enforces the same rule.
|
|
*/
|
|
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { refDebounced } from '@vueuse/core'
|
|
import { CalendarClock, Plus, TriangleAlert, Undo2, X } from '@lucide/vue'
|
|
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'
|
|
import { toast } from 'vue-sonner'
|
|
import { HttpStatus } from 'http-status-ts'
|
|
|
|
import DatePicker from '@/components/DatePicker.vue'
|
|
import TelegramInput from '@/components/TelegramInput.vue'
|
|
import TimePicker from '@/components/TimePicker.vue'
|
|
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogScrollContent,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { useConflicts, useUpdateReservation } from '@/services/api/reservations'
|
|
import {
|
|
ApiError,
|
|
unitLabel,
|
|
type Bike,
|
|
type Conflict,
|
|
type NewReservationBike,
|
|
type Reservation,
|
|
} from '@/utils/types'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{ reservation: Reservation; bikes: Bike[]; emailsOnly?: boolean }>(),
|
|
{ emailsOnly: false },
|
|
)
|
|
const open = defineModel<boolean>('open', { default: false })
|
|
|
|
const { t, locale } = useI18n()
|
|
const update = useUpdateReservation()
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
const TELEGRAM_RE = /^@[A-Za-z][A-Za-z0-9_]{4,31}$/
|
|
const SLOT_MINUTES = 15
|
|
|
|
const startDate = shallowRef<DateValue>()
|
|
const endDate = shallowRef<DateValue>()
|
|
const form = reactive({
|
|
startTime: '' as string | undefined,
|
|
endTime: '' as string | undefined,
|
|
bikes: [] as number[],
|
|
// Without the leading @, which `TelegramInput` shows as a prefix
|
|
telegram: '',
|
|
emails: [''],
|
|
})
|
|
const errors = reactive<Record<string, string>>({})
|
|
const submitted = ref(false)
|
|
|
|
/**
|
|
* A period one bike is held for, when it is not the reservation's own. This is
|
|
* how a conflict is settled: the booking keeps its hours and the disputed bike
|
|
* changes hands earlier. A bike absent from here simply follows the
|
|
* reservation.
|
|
*/
|
|
type Period = { startDate: DateValue; startTime: string; endDate: DateValue; endTime: string }
|
|
const overrides = reactive<Record<number, Period>>({})
|
|
|
|
function toCalendarDate(date: Date): DateValue {
|
|
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
|
|
}
|
|
|
|
/** The picker only offers quarter hours; anything else snaps to the nearest one */
|
|
function toSlot(date: Date): string {
|
|
const minutes = Math.round((date.getHours() * 60 + date.getMinutes()) / SLOT_MINUTES)
|
|
const total = (minutes * SLOT_MINUTES) % (24 * 60)
|
|
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`
|
|
}
|
|
|
|
function toPeriod(from: string, to: string): Period {
|
|
const start = new Date(from)
|
|
const end = new Date(to)
|
|
return {
|
|
startDate: toCalendarDate(start),
|
|
startTime: toSlot(start),
|
|
endDate: toCalendarDate(end),
|
|
endTime: toSlot(end),
|
|
}
|
|
}
|
|
|
|
/** Reloads the fields from the reservation, so cancelling really cancels */
|
|
function load() {
|
|
const start = new Date(props.reservation.start_time)
|
|
const end = new Date(props.reservation.end_time)
|
|
startDate.value = toCalendarDate(start)
|
|
endDate.value = toCalendarDate(end)
|
|
form.startTime = toSlot(start)
|
|
form.endTime = toSlot(end)
|
|
form.bikes = props.reservation.bikes.map((held) => held.id)
|
|
form.telegram = props.reservation.telegram.replace(/^@/, '')
|
|
Object.keys(overrides).forEach((key) => delete overrides[Number(key)])
|
|
for (const held of props.reservation.bikes) {
|
|
if (held.custom) overrides[held.id] = toPeriod(held.start_time, held.end_time)
|
|
}
|
|
form.emails = props.reservation.linka_emails.length ? [...props.reservation.linka_emails] : ['']
|
|
Object.keys(errors).forEach((key) => delete errors[key])
|
|
submitted.value = false
|
|
}
|
|
|
|
watch(open, (isOpen) => isOpen && load(), { immediate: true })
|
|
|
|
function toDate(date: DateValue | undefined, time: string | undefined): Date | null {
|
|
if (!date || !time) return null
|
|
const [hours, minutes] = time.split(':').map(Number)
|
|
const local = date.toDate(getLocalTimeZone())
|
|
local.setHours(hours, minutes, 0, 0)
|
|
return local
|
|
}
|
|
|
|
/** Whatever this dialog edits below is not repeated in the context block */
|
|
const omitted = computed<('period' | 'bikes' | 'linka' | 'telegram')[]>(() =>
|
|
props.emailsOnly ? ['linka'] : ['period', 'bikes', 'linka', 'telegram'],
|
|
)
|
|
|
|
const start = computed(() => toDate(startDate.value, form.startTime))
|
|
const end = computed(() => toDate(endDate.value, form.endTime))
|
|
|
|
/**
|
|
* A bike already on the reservation stays pickable even if it has since gone out
|
|
* of service — the same rule the backend applies.
|
|
*/
|
|
function isBlocked(bike: Bike) {
|
|
return (
|
|
bike.status === 'out_of_service' && !props.reservation.bikes.some((held) => held.id === bike.id)
|
|
)
|
|
}
|
|
|
|
function toggleBike(bike: Bike) {
|
|
if (isBlocked(bike)) return
|
|
const index = form.bikes.indexOf(bike.id)
|
|
if (index >= 0) {
|
|
form.bikes.splice(index, 1)
|
|
delete overrides[bike.id]
|
|
} else form.bikes.push(bike.id)
|
|
}
|
|
|
|
function bikeName(id: number) {
|
|
return props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`
|
|
}
|
|
|
|
/** The period a bike would be held for once saved, reservation's or its own */
|
|
function heldPeriod(id: number): [Date, Date] | null {
|
|
const own = overrides[id]
|
|
const from = own ? toDate(own.startDate, own.startTime) : start.value
|
|
const to = own ? toDate(own.endDate, own.endTime) : end.value
|
|
return from && to ? [from, to] : null
|
|
}
|
|
|
|
function useOwnPeriod(id: number) {
|
|
if (!start.value || !end.value) return
|
|
overrides[id] = toPeriod(start.value.toISOString(), end.value.toISOString())
|
|
}
|
|
|
|
function followReservation(id: number) {
|
|
delete overrides[id]
|
|
}
|
|
|
|
/** The fleet in the shape the backend takes: no period means "the booking's" */
|
|
const editedBikes = computed<NewReservationBike[]>(() =>
|
|
form.bikes.map((id) => {
|
|
const period = heldPeriod(id)
|
|
if (!overrides[id] || !period) return { id }
|
|
return { id, start_time: period[0].toISOString(), end_time: period[1].toISOString() }
|
|
}),
|
|
)
|
|
|
|
/**
|
|
* What the reservation would clash with once saved. Answered by the backend
|
|
* against the whole table, and only a warning: whoever manages the reservation
|
|
* is allowed to double-book knowingly, and is the only one who can sort it out.
|
|
*/
|
|
const probe = computed(() => {
|
|
// Emails-only mode changes neither the period nor the fleet: nothing to check
|
|
if (props.emailsOnly || !start.value || !end.value || !form.bikes.length) return null
|
|
if (end.value <= start.value) return null
|
|
return {
|
|
reservation: props.reservation.id,
|
|
start_time: start.value.toISOString(),
|
|
end_time: end.value.toISOString(),
|
|
bikes: editedBikes.value,
|
|
}
|
|
})
|
|
// Every picker change would otherwise be its own request
|
|
const debouncedProbe = refDebounced(probe, 300)
|
|
const { data: conflicts } = useConflicts(debouncedProbe, { enabled: open })
|
|
|
|
/** One block per bike, since the same one can be held by two reservations */
|
|
const conflictsByBike = computed(() => {
|
|
const grouped = new Map<number, Conflict[]>()
|
|
for (const conflict of conflicts.value ?? []) {
|
|
const list = grouped.get(conflict.bike)
|
|
if (list) list.push(conflict)
|
|
else grouped.set(conflict.bike, [conflict])
|
|
}
|
|
return [...grouped].map(([id, list]) => ({ id, name: bikeName(id), conflicts: list }))
|
|
})
|
|
|
|
/**
|
|
* The one-click way out, when there is one: hand the bike back before the other
|
|
* booking takes it, or take it once that one is done. It only ever shortens our
|
|
* own hold, so it is always safe — and it is exactly what settles the case
|
|
* where two bookings want the same bike on either side of an hour.
|
|
*/
|
|
function resolution(id: number, conflict: Conflict): { at: Date; kind: 'end' | 'start' } | null {
|
|
const period = heldPeriod(id)
|
|
if (!period) return null
|
|
const [from, to] = period
|
|
const otherFrom = new Date(conflict.start_time)
|
|
const otherTo = new Date(conflict.end_time)
|
|
if (otherFrom > from) return { at: otherFrom, kind: 'end' }
|
|
if (otherTo < to) return { at: otherTo, kind: 'start' }
|
|
// The other booking covers ours whole: only moving the reservation, or the
|
|
// other one, can settle this
|
|
return null
|
|
}
|
|
|
|
function applyResolution(id: number, conflict: Conflict) {
|
|
const fix = resolution(id, conflict)
|
|
const period = heldPeriod(id)
|
|
if (!fix || !period) return
|
|
const [from, to] = period
|
|
const next = fix.kind === 'end' ? [from, fix.at] : [fix.at, to]
|
|
overrides[id] = toPeriod(next[0].toISOString(), next[1].toISOString())
|
|
}
|
|
|
|
const timeFormatter = computed(
|
|
() =>
|
|
new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
}),
|
|
)
|
|
const formatMoment = (value: Date | string) => timeFormatter.value.format(new Date(value))
|
|
|
|
function addEmail() {
|
|
form.emails.push('')
|
|
}
|
|
|
|
function removeEmail(index: number) {
|
|
form.emails.splice(index, 1)
|
|
if (form.emails.length === 0) form.emails.push('')
|
|
}
|
|
|
|
function validate(): boolean {
|
|
Object.keys(errors).forEach((key) => delete errors[key])
|
|
|
|
if (!props.emailsOnly) {
|
|
if (!start.value) errors.start = t('reservation.error-datetime-required')
|
|
if (!end.value) errors.end = t('reservation.error-datetime-required')
|
|
if (start.value && end.value && end.value <= start.value) {
|
|
errors.end = t('reservation.error-end-before-start')
|
|
}
|
|
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
|
|
if (!form.telegram) errors.telegram = t('reservation.error-required')
|
|
else if (!TELEGRAM_RE.test(`@${form.telegram}`)) {
|
|
errors.telegram = t('reservation.error-telegram')
|
|
}
|
|
|
|
// The backend refuses these too; saying so here saves a round trip and
|
|
// names the bike rather than the whole reservation
|
|
for (const id of form.bikes) {
|
|
const own = overrides[id]
|
|
if (!own) continue
|
|
const from = toDate(own.startDate, own.startTime)
|
|
const to = toDate(own.endDate, own.endTime)
|
|
if (!from || !to || to <= from) {
|
|
errors.periods = t('reservation.edit.error-period', { bike: bikeName(id) })
|
|
} else if (start.value && end.value && (from < start.value || to > end.value)) {
|
|
errors.periods = t('reservation.edit.error-period-outside', { bike: bikeName(id) })
|
|
}
|
|
}
|
|
}
|
|
|
|
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
|
|
if (emails.length === 0) errors.emails = t('reservation.error-required')
|
|
else if (!emails.every((email) => EMAIL_RE.test(email))) {
|
|
errors.emails = t('reservation.error-email')
|
|
}
|
|
|
|
return Object.keys(errors).length === 0
|
|
}
|
|
|
|
function save() {
|
|
submitted.value = true
|
|
if (!validate()) {
|
|
toast.error(t('reservation.error-form'))
|
|
return
|
|
}
|
|
|
|
update.mutate(
|
|
{
|
|
id: props.reservation.id,
|
|
// Sent back as stored when they are not editable, which is exactly what
|
|
// the backend checks before accepting the change
|
|
telegram: props.emailsOnly ? props.reservation.telegram : `@${form.telegram}`,
|
|
start_time: props.emailsOnly ? props.reservation.start_time : start.value!.toISOString(),
|
|
end_time: props.emailsOnly ? props.reservation.end_time : end.value!.toISOString(),
|
|
bikes: props.emailsOnly
|
|
? props.reservation.bikes.map((held) => ({
|
|
id: held.id,
|
|
...(held.custom ? { start_time: held.start_time, end_time: held.end_time } : {}),
|
|
}))
|
|
: editedBikes.value,
|
|
linka_emails: form.emails.map((email) => email.trim()).filter(Boolean),
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
toast.success(t('reservation.edit.saved'))
|
|
open.value = false
|
|
},
|
|
onError: (error) =>
|
|
toast.error(
|
|
error instanceof ApiError && error.status === HttpStatus.CONFLICT
|
|
? t('reservation.conflicts.refused')
|
|
: error.message || t('reservation.edit.error'),
|
|
),
|
|
},
|
|
)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog v-model:open="open">
|
|
<DialogScrollContent class="sm:max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle class="flex flex-wrap items-center gap-2">
|
|
{{ $t('reservation.edit.title', { id: reservation.id }) }}
|
|
<Badge variant="secondary">
|
|
{{ $t(`reservation.status.${reservation.status}`) }}
|
|
</Badge>
|
|
<span class="text-muted-foreground text-sm font-normal">
|
|
{{ unitLabel(reservation.unit) }}
|
|
</span>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{{ emailsOnly ? $t('reservation.edit.intro-emails') : $t('reservation.edit.intro') }}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<!-- Context: only what this dialog does not let anybody change -->
|
|
<div class="bg-muted/40 rounded-md border p-3">
|
|
<ReservationDetails :reservation="reservation" :bikes="bikes" :omit="omitted" />
|
|
</div>
|
|
|
|
<div class="grid gap-4">
|
|
<!-- Period -->
|
|
<div v-if="!emailsOnly" class="grid gap-2">
|
|
<span id="edit-start-label" class="text-sm leading-none font-medium">
|
|
{{ $t('reservation.start') }}
|
|
</span>
|
|
<div role="group" aria-labelledby="edit-start-label" class="grid gap-2 sm:grid-cols-2">
|
|
<DatePicker id="edit-start-date" v-model="startDate" :invalid="!!errors.start" />
|
|
<TimePicker id="edit-start-time" v-model="form.startTime" :invalid="!!errors.start" />
|
|
</div>
|
|
<p v-if="errors.start" class="text-destructive text-xs">{{ errors.start }}</p>
|
|
</div>
|
|
|
|
<div v-if="!emailsOnly" class="grid gap-2">
|
|
<span id="edit-end-label" class="text-sm leading-none font-medium">
|
|
{{ $t('reservation.end') }}
|
|
</span>
|
|
<div role="group" aria-labelledby="edit-end-label" class="grid gap-2 sm:grid-cols-2">
|
|
<DatePicker
|
|
id="edit-end-date"
|
|
v-model="endDate"
|
|
:min-value="startDate"
|
|
:invalid="!!errors.end"
|
|
/>
|
|
<TimePicker id="edit-end-time" v-model="form.endTime" :invalid="!!errors.end" />
|
|
</div>
|
|
<p v-if="errors.end" class="text-destructive text-xs">{{ errors.end }}</p>
|
|
</div>
|
|
|
|
<!-- Bikes -->
|
|
<div v-if="!emailsOnly" class="grid gap-2">
|
|
<span class="text-sm font-medium">{{ $t('reservation.edit.bikes') }}</span>
|
|
<div class="grid gap-2 sm:grid-cols-2">
|
|
<button
|
|
v-for="bike in bikes"
|
|
:key="bike.id"
|
|
type="button"
|
|
:disabled="isBlocked(bike)"
|
|
:aria-pressed="form.bikes.includes(bike.id)"
|
|
class="flex h-9 items-center justify-between gap-2 rounded-md border px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
:class="
|
|
form.bikes.includes(bike.id)
|
|
? 'border-primary bg-primary/10 text-foreground'
|
|
: 'hover:bg-accent hover:text-accent-foreground'
|
|
"
|
|
@click="toggleBike(bike)"
|
|
>
|
|
<span class="truncate">{{ bike.name }}</span>
|
|
<span
|
|
v-if="bike.status === 'out_of_service'"
|
|
class="text-destructive shrink-0 text-[0.65rem] font-bold uppercase"
|
|
>
|
|
{{ $t('reservation.bike-out-of-service') }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
|
|
</div>
|
|
|
|
<!-- Whoever picks the bikes up may change, and with them the handle -->
|
|
<div v-if="!emailsOnly" class="grid gap-2">
|
|
<Label for="edit-telegram">{{ $t('reservation.telegram') }}</Label>
|
|
<TelegramInput id="edit-telegram" v-model="form.telegram" :invalid="!!errors.telegram" />
|
|
<p v-if="errors.telegram" class="text-destructive text-xs">{{ errors.telegram }}</p>
|
|
</div>
|
|
|
|
<!-- One period per bike, which is how a conflict gets settled without
|
|
moving the whole booking -->
|
|
<div v-if="!emailsOnly && form.bikes.length" class="grid gap-2">
|
|
<span class="text-sm font-medium">{{ $t('reservation.edit.periods') }}</span>
|
|
<div v-for="id in form.bikes" :key="id" class="grid gap-2 rounded-md border p-3">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<span class="flex items-center gap-2 text-sm font-medium tabular-nums">
|
|
{{ bikeName(id) }}
|
|
<span v-if="!overrides[id]" class="text-muted-foreground font-normal">
|
|
{{ $t('reservation.edit.follows') }}
|
|
</span>
|
|
</span>
|
|
<Button
|
|
v-if="!overrides[id]"
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="useOwnPeriod(id)"
|
|
>
|
|
<CalendarClock class="size-4" />
|
|
{{ $t('reservation.edit.own-period') }}
|
|
</Button>
|
|
<Button v-else type="button" variant="ghost" size="sm" @click="followReservation(id)">
|
|
<Undo2 class="size-4" />
|
|
{{ $t('reservation.edit.follow-again') }}
|
|
</Button>
|
|
</div>
|
|
|
|
<div v-if="overrides[id]" class="grid gap-2">
|
|
<span class="text-muted-foreground text-xs">
|
|
{{ $t('reservation.edit.period-from') }}
|
|
</span>
|
|
<div class="grid gap-2 sm:grid-cols-2">
|
|
<DatePicker
|
|
v-model="overrides[id].startDate"
|
|
:min-value="startDate"
|
|
:max-value="endDate"
|
|
/>
|
|
<TimePicker v-model="overrides[id].startTime" />
|
|
</div>
|
|
<span class="text-muted-foreground text-xs">
|
|
{{ $t('reservation.edit.period-to') }}
|
|
</span>
|
|
<div class="grid gap-2 sm:grid-cols-2">
|
|
<DatePicker
|
|
v-model="overrides[id].endDate"
|
|
:min-value="overrides[id].startDate"
|
|
:max-value="endDate"
|
|
/>
|
|
<TimePicker v-model="overrides[id].endTime" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p v-if="errors.periods" class="text-destructive text-xs">{{ errors.periods }}</p>
|
|
</div>
|
|
|
|
<!-- A warning, not a wall: whoever manages the reservation is allowed to
|
|
double-book on purpose, and is the only one who can settle it -->
|
|
<div
|
|
v-if="conflictsByBike.length"
|
|
class="grid gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3"
|
|
>
|
|
<p class="flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-400">
|
|
<TriangleAlert class="size-4 shrink-0" />
|
|
{{ $t('reservation.conflicts.title') }}
|
|
</p>
|
|
<p class="text-muted-foreground text-xs">{{ $t('reservation.conflicts.intro') }}</p>
|
|
<div v-for="group in conflictsByBike" :key="group.id" class="grid gap-1">
|
|
<div v-for="conflict in group.conflicts" :key="conflict.reservation" class="text-sm">
|
|
<p>
|
|
<span class="font-medium tabular-nums">{{ group.name }}</span>
|
|
—
|
|
{{
|
|
$t('reservation.conflicts.held', {
|
|
unit: conflict.unit,
|
|
id: conflict.reservation,
|
|
from: formatMoment(conflict.start_time),
|
|
to: formatMoment(conflict.end_time),
|
|
})
|
|
}}
|
|
</p>
|
|
<Button
|
|
v-if="resolution(group.id, conflict)"
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
class="mt-1"
|
|
@click="applyResolution(group.id, conflict)"
|
|
>
|
|
{{
|
|
$t(
|
|
resolution(group.id, conflict)!.kind === 'end'
|
|
? 'reservation.conflicts.give-back'
|
|
: 'reservation.conflicts.take-later',
|
|
{
|
|
bike: group.name,
|
|
at: formatMoment(resolution(group.id, conflict)!.at),
|
|
},
|
|
)
|
|
}}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Linka Go accounts -->
|
|
<div class="grid gap-2">
|
|
<Label for="edit-email-0">{{ $t('reservation.emails') }}</Label>
|
|
<div v-for="(_, index) in form.emails" :key="index" class="flex gap-2">
|
|
<Input
|
|
:id="`edit-email-${index}`"
|
|
v-model.trim="form.emails[index]"
|
|
type="email"
|
|
:aria-label="$t('reservation.email-nth', { n: index + 1 })"
|
|
placeholder="prenom.nom@exemple.com"
|
|
:aria-invalid="!!errors.emails || undefined"
|
|
/>
|
|
<Button
|
|
v-if="form.emails.length > 1"
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
:aria-label="$t('reservation.remove-email')"
|
|
@click="removeEmail(index)"
|
|
>
|
|
<X class="size-4" />
|
|
</Button>
|
|
</div>
|
|
<div>
|
|
<Button type="button" variant="secondary" size="sm" @click="addEmail()">
|
|
<Plus class="size-4" />
|
|
{{ $t('reservation.add-email') }}
|
|
</Button>
|
|
</div>
|
|
<p v-if="errors.emails" class="text-destructive text-xs">{{ errors.emails }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" :disabled="update.isPending.value" @click="open = false">
|
|
{{ $t('reservation.edit.cancel') }}
|
|
</Button>
|
|
<Button :disabled="update.isPending.value" @click="save()">
|
|
{{ update.isPending.value ? $t('reservation.edit.saving') : $t('reservation.edit.save') }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogScrollContent>
|
|
</Dialog>
|
|
</template>
|