cargagep-v2/frontend/src/components/reservation/ReservationEditDialog.vue
Antoine Pelletier 51df9e75ad wip
2026-08-25 10:38:05 +02:00

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>