feat: add modify a reservation admin
This commit is contained in:
parent
035f82cb88
commit
0b161d83f6
22 changed files with 917 additions and 59 deletions
|
|
@ -12,6 +12,15 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import ReservationDetails from '@/components/admin/ReservationDetails.vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogScrollContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { unitLabel, type Bike, type Reservation } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ reservations: Reservation[]; bikes: Bike[] }>()
|
||||
|
|
@ -85,12 +94,24 @@ const booked = computed(() =>
|
|||
props.reservations.filter((r) => r.status === 'approved' || r.status === 'ongoing'),
|
||||
)
|
||||
|
||||
/** Which reservation the modal is showing, by id, or `null` when closed */
|
||||
const opened = ref<number | null>(null)
|
||||
const openedReservation = computed(() =>
|
||||
props.reservations.find((reservation) => reservation.id === opened.value),
|
||||
)
|
||||
const dialogOpen = computed({
|
||||
get: () => opened.value !== null,
|
||||
set: (value) => {
|
||||
if (!value) opened.value = null
|
||||
},
|
||||
})
|
||||
|
||||
const today = new Date()
|
||||
function isToday(day: Date) {
|
||||
return day.toDateString() === today.toDateString()
|
||||
}
|
||||
|
||||
type Block = { id: number; top: number; height: number; label: string }
|
||||
type Block = { id: number; top: number; height: number }
|
||||
|
||||
function blocksFor(day: Date, bike: Bike): Block[] {
|
||||
const dayStart = new Date(day)
|
||||
|
|
@ -109,7 +130,7 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
|||
|
||||
const top = ((from.getTime() - dayStart.getTime()) / 3_600_000) * HOUR_HEIGHT
|
||||
const height = Math.max(((to.getTime() - from.getTime()) / 3_600_000) * HOUR_HEIGHT, 6)
|
||||
return [{ id: reservation.id, top, height, label: unitLabel(reservation.unit) }]
|
||||
return [{ id: reservation.id, top, height }]
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
@ -214,15 +235,16 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
|||
<!-- One lane per bike, blocks positioned inside -->
|
||||
<div class="absolute inset-0 flex gap-px px-px">
|
||||
<div v-for="bike in lanes" :key="bike.id" class="relative min-w-0 flex-1">
|
||||
<div
|
||||
<button
|
||||
v-for="block in blocksFor(day, bike)"
|
||||
:key="`${block.id}-${bike.id}`"
|
||||
class="bg-primary text-primary-foreground absolute inset-x-0 overflow-hidden rounded-sm px-0.5 text-[0.6rem] leading-tight"
|
||||
type="button"
|
||||
class="bg-primary hover:bg-primary/80 focus-visible:ring-ring absolute inset-x-0 rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
:style="{ top: `${block.top}px`, height: `${block.height}px` }"
|
||||
:title="`#${block.id} — ${block.label}`"
|
||||
>
|
||||
{{ block.label }}
|
||||
</div>
|
||||
:title="$t('admin.calendar.open', { id: block.id })"
|
||||
:aria-label="$t('admin.calendar.open', { id: block.id })"
|
||||
@click="opened = block.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -231,6 +253,21 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
|||
</div>
|
||||
|
||||
<p class="text-muted-foreground mt-3 text-xs">{{ $t('admin.calendar.legend') }}</p>
|
||||
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogScrollContent v-if="openedReservation" class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex flex-wrap items-center gap-2">
|
||||
{{ $t('admin.calendar.reservation', { id: openedReservation.id }) }}
|
||||
<Badge variant="secondary">
|
||||
{{ $t(`admin.reservations.status.${openedReservation.status}`) }}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription>{{ unitLabel(openedReservation.unit) }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ReservationDetails :reservation="openedReservation" :bikes="bikes" />
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@
|
|||
* mirror `ReservationStatus::can_transition_to` on the backend, which re-checks
|
||||
* and answers 409 on anything else.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Pencil } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import ReservationDetails from '@/components/admin/ReservationDetails.vue'
|
||||
import ReservationEditDialog from '@/components/admin/ReservationEditDialog.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSetReservationStatus } from '@/services/api/reservations'
|
||||
|
|
@ -15,7 +18,7 @@ import { unitLabel, type Bike, type Reservation, type ReservationStatus } from '
|
|||
|
||||
const props = defineProps<{ reservation: Reservation; bikes: Bike[] }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const setStatus = useSetReservationStatus()
|
||||
|
||||
/** Kept in step with the state machine drawn on the backend enum */
|
||||
|
|
@ -39,23 +42,11 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
|
|||
|
||||
const transitions = computed(() => TRANSITIONS[props.reservation.status])
|
||||
|
||||
const bikeNames = computed(() =>
|
||||
props.reservation.bikes
|
||||
.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`)
|
||||
.join(', '),
|
||||
/** A reservation nobody can act on any more is not worth an edit button */
|
||||
const editable = computed(
|
||||
() => !['refused', 'cancelled', 'archived'].includes(props.reservation.status),
|
||||
)
|
||||
|
||||
const formatter = computed(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}),
|
||||
)
|
||||
|
||||
function format(iso: string) {
|
||||
return formatter.value.format(new Date(iso))
|
||||
}
|
||||
const editing = ref(false)
|
||||
|
||||
function move(status: ReservationStatus) {
|
||||
setStatus.mutate(
|
||||
|
|
@ -76,7 +67,11 @@ function move(status: ReservationStatus) {
|
|||
<span class="text-muted-foreground text-sm">{{ unitLabel(reservation.unit) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="transitions.length" class="flex flex-wrap gap-2">
|
||||
<div v-if="transitions.length || editable" class="flex flex-wrap gap-2">
|
||||
<Button v-if="editable" size="sm" variant="outline" @click="editing = true">
|
||||
<Pencil class="size-4" />
|
||||
{{ $t('admin.reservations.edit.action') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-for="status in transitions"
|
||||
:key="status"
|
||||
|
|
@ -90,33 +85,13 @@ function move(status: ReservationStatus) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="mt-3 grid gap-1 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.period') }}</dt>
|
||||
<dd>{{ format(reservation.start_time) }} → {{ format(reservation.end_time) }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.bikes') }}</dt>
|
||||
<dd>{{ bikeNames }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.telegram') }}</dt>
|
||||
<dd class="truncate">{{ reservation.telegram }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.people') }}</dt>
|
||||
<dd class="min-w-0 break-words">
|
||||
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="reservation.linka_emails.length" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.linka') }}</dt>
|
||||
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd>
|
||||
</div>
|
||||
<div v-if="reservation.description" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.reason') }}</dt>
|
||||
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<ReservationDetails class="mt-3" :reservation="reservation" :bikes="bikes" />
|
||||
|
||||
<ReservationEditDialog
|
||||
v-if="editable"
|
||||
v-model:open="editing"
|
||||
:reservation="reservation"
|
||||
:bikes="bikes"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
77
frontend/src/components/admin/ReservationDetails.vue
Normal file
77
frontend/src/components/admin/ReservationDetails.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* The read-only body of a reservation, shared by the admin card and the two
|
||||
* dialogs so the three never drift apart.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { Bike, Reservation } from '@/utils/types'
|
||||
|
||||
type Field = 'period' | 'bikes' | 'telegram' | 'people' | 'linka' | 'reason'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
reservation: Reservation
|
||||
bikes: Bike[]
|
||||
/** Fields the caller renders itself — the edit dialog omits what it edits */
|
||||
omit?: Field[]
|
||||
}>(),
|
||||
{ omit: () => [] },
|
||||
)
|
||||
|
||||
function shows(field: Field) {
|
||||
return !props.omit.includes(field)
|
||||
}
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
const bikeNames = computed(() =>
|
||||
props.reservation.bikes
|
||||
.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`)
|
||||
.join(', '),
|
||||
)
|
||||
|
||||
const formatter = computed(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}),
|
||||
)
|
||||
|
||||
function format(iso: string) {
|
||||
return formatter.value.format(new Date(iso))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dl class="grid gap-1 text-sm">
|
||||
<div v-if="shows('period')" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.period') }}</dt>
|
||||
<dd>{{ format(reservation.start_time) }} → {{ format(reservation.end_time) }}</dd>
|
||||
</div>
|
||||
<div v-if="shows('bikes')" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.bikes') }}</dt>
|
||||
<dd>{{ bikeNames }}</dd>
|
||||
</div>
|
||||
<div v-if="shows('telegram')" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.telegram') }}</dt>
|
||||
<dd class="truncate">{{ reservation.telegram }}</dd>
|
||||
</div>
|
||||
<div v-if="shows('people')" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.people') }}</dt>
|
||||
<dd class="min-w-0 break-words">
|
||||
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="shows('linka') && reservation.linka_emails.length" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.linka') }}</dt>
|
||||
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd>
|
||||
</div>
|
||||
<div v-if="shows('reason') && reservation.description" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.reason') }}</dt>
|
||||
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
290
frontend/src/components/admin/ReservationEditDialog.vue
Normal file
290
frontend/src/components/admin/ReservationEditDialog.vue
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Editing a reservation from the admin page. 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.
|
||||
*/
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Plus, X } from '@lucide/vue'
|
||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import DatePicker from '@/components/DatePicker.vue'
|
||||
import TimePicker from '@/components/TimePicker.vue'
|
||||
import ReservationDetails from '@/components/admin/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 { useUpdateReservation } from '@/services/api/reservations'
|
||||
import { unitLabel, type Bike, type Reservation } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ reservation: Reservation; bikes: Bike[] }>()
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
const update = useUpdateReservation()
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
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[],
|
||||
emails: [''],
|
||||
})
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
const submitted = ref(false)
|
||||
|
||||
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')}`
|
||||
}
|
||||
|
||||
/** 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]
|
||||
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
|
||||
}
|
||||
|
||||
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.includes(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)
|
||||
else form.bikes.push(bike.id)
|
||||
}
|
||||
|
||||
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 (!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')
|
||||
|
||||
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,
|
||||
start_time: start.value!.toISOString(),
|
||||
end_time: end.value!.toISOString(),
|
||||
bikes: [...form.bikes],
|
||||
linka_emails: form.emails.map((email) => email.trim()).filter(Boolean),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.reservations.edit.saved'))
|
||||
open.value = false
|
||||
},
|
||||
onError: (error) => toast.error(error.message || t('admin.reservations.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('admin.reservations.edit.title', { id: reservation.id }) }}
|
||||
<Badge variant="secondary">
|
||||
{{ $t(`admin.reservations.status.${reservation.status}`) }}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground text-sm font-normal">
|
||||
{{ unitLabel(reservation.unit) }}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>{{ $t('admin.reservations.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="['period', 'bikes', 'linka']"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<!-- Period -->
|
||||
<div 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 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 class="grid gap-2">
|
||||
<span class="text-sm font-medium">{{ $t('admin.reservations.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>
|
||||
|
||||
<!-- 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('admin.reservations.edit.cancel') }}
|
||||
</Button>
|
||||
<Button :disabled="update.isPending.value" @click="save()">
|
||||
{{
|
||||
update.isPending.value
|
||||
? $t('admin.reservations.edit.saving')
|
||||
: $t('admin.reservations.edit.save')
|
||||
}}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
15
frontend/src/components/ui/dialog/Dialog.vue
Normal file
15
frontend/src/components/ui/dialog/Dialog.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
12
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
12
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from 'reka-ui'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose v-bind="props">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
47
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
47
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { X } from '@lucide/vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
22
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
22
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogDescription, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
12
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
12
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
14
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
60
frontend/src/components/ui/dialog/DialogScrollContent.vue
Normal file
60
frontend/src/components/ui/dialog/DialogScrollContent.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { X } from '@lucide/vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
@pointer-down-outside="
|
||||
(event) => {
|
||||
const originalEvent = event.detail.originalEvent
|
||||
const target = originalEvent.target as HTMLElement
|
||||
if (
|
||||
originalEvent.offsetX > target.clientWidth ||
|
||||
originalEvent.offsetY > target.clientHeight
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-3 right-3 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
22
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
22
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogTitle, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-lg font-semibold leading-none tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
12
frontend/src/components/ui/dialog/DialogTrigger.vue
Normal file
12
frontend/src/components/ui/dialog/DialogTrigger.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from 'reka-ui'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger v-bind="props">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
9
frontend/src/components/ui/dialog/index.ts
Normal file
9
frontend/src/components/ui/dialog/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { default as Dialog } from './Dialog.vue'
|
||||
export { default as DialogClose } from './DialogClose.vue'
|
||||
export { default as DialogContent } from './DialogContent.vue'
|
||||
export { default as DialogDescription } from './DialogDescription.vue'
|
||||
export { default as DialogFooter } from './DialogFooter.vue'
|
||||
export { default as DialogHeader } from './DialogHeader.vue'
|
||||
export { default as DialogScrollContent } from './DialogScrollContent.vue'
|
||||
export { default as DialogTitle } from './DialogTitle.vue'
|
||||
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||
105
frontend/src/lib/api.d.ts
vendored
105
frontend/src/lib/api.d.ts
vendored
|
|
@ -507,6 +507,97 @@ export interface paths {
|
|||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/api/reservations/{id}': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
get?: never
|
||||
/**
|
||||
* Edit the period, the bikes and the Linka Go accounts
|
||||
* @description Everything else is left as stored. A reservation in a final state cannot be edited any more.
|
||||
*/
|
||||
put: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path: {
|
||||
id: number
|
||||
}
|
||||
cookie?: never
|
||||
}
|
||||
/**
|
||||
* @description Only what the admin page lets somebody change. The unit, the requester, the
|
||||
* telegram handle and the reason are shown but not editable, so they are not
|
||||
* in the body at all: the handler reads them back from the stored reservation
|
||||
* rather than trusting a client to send them unchanged.
|
||||
*/
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['ReservationEditForm']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
/** @description no content */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description The reservation would become malformed */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Unauthenticated - a session is required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description Forbidden - the user must be part of the unit, or an admin when the reservation names no known unit */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description no content */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description A newly added bike is out of service, or the reservation is final */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
/** @description One of the bikes does not exist */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
}
|
||||
}
|
||||
post?: never
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/api/reservations/{id}/status': {
|
||||
parameters: {
|
||||
query?: never
|
||||
|
|
@ -670,6 +761,20 @@ export interface components {
|
|||
unit: components['schemas']['ReservationUnit']
|
||||
users: components['schemas']['UserSummary'][]
|
||||
}
|
||||
/**
|
||||
* @description Only what the admin page lets somebody change. The unit, the requester, the
|
||||
* telegram handle and the reason are shown but not editable, so they are not
|
||||
* in the body at all: the handler reads them back from the stored reservation
|
||||
* rather than trusting a client to send them unchanged.
|
||||
*/
|
||||
ReservationEditForm: {
|
||||
bikes: number[]
|
||||
/** Format: date-time */
|
||||
end_time: string
|
||||
linka_emails: string[]
|
||||
/** Format: date-time */
|
||||
start_time: string
|
||||
}
|
||||
/** @enum {string} */
|
||||
ReservationStatus: 'requested' | 'refused' | 'approved' | 'cancelled' | 'ongoing' | 'archived'
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -99,6 +99,17 @@ admin:
|
|||
telegram: Telegram
|
||||
people: People
|
||||
linka: Linka Go accounts
|
||||
edit:
|
||||
action: Edit
|
||||
title: 'Reservation #{id}'
|
||||
intro: >-
|
||||
Only the period, the cargobikes and the Linka Go accounts can be changed.
|
||||
bikes: Assigned cargobikes
|
||||
save: Save
|
||||
saving: Saving…
|
||||
cancel: Cancel
|
||||
saved: Reservation updated.
|
||||
error: Could not save the changes.
|
||||
reason: Reason
|
||||
load-error: Unable to load the reservations.
|
||||
error: The status change failed.
|
||||
|
|
@ -125,4 +136,6 @@ admin:
|
|||
today: Today
|
||||
hour: H
|
||||
no-bike: No cargobike to show.
|
||||
legend: One bar per booked cargobike, split by day.
|
||||
legend: One bar per booked cargobike, split by day. Click one for the details.
|
||||
open: 'Open reservation #{id}'
|
||||
reservation: 'Reservation #{id}'
|
||||
|
|
|
|||
|
|
@ -100,6 +100,17 @@ admin:
|
|||
telegram: Telegram
|
||||
people: Personnes
|
||||
linka: Comptes Linka Go
|
||||
edit:
|
||||
action: Modifier
|
||||
title: 'Réservation #{id}'
|
||||
intro: >-
|
||||
Seuls la période, les cargobikes et les comptes Linka Go peuvent être modifiés.
|
||||
bikes: Cargobikes attribués
|
||||
save: Enregistrer
|
||||
saving: Enregistrement…
|
||||
cancel: Annuler
|
||||
saved: Réservation mise à jour.
|
||||
error: La modification a échoué.
|
||||
reason: Raison
|
||||
load-error: Impossible de charger les réservations.
|
||||
error: Le changement de statut a échoué.
|
||||
|
|
@ -126,4 +137,6 @@ admin:
|
|||
today: Aujourd'hui
|
||||
hour: H
|
||||
no-bike: Aucun cargobike à afficher.
|
||||
legend: Une barre par cargobike réservé, découpée par jour.
|
||||
legend: Une barre par cargobike réservé, découpée par jour. Cliquez pour voir le détail.
|
||||
open: 'Voir la réservation #{id}'
|
||||
reservation: 'Réservation #{id}'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
|
||||
import type { NewReservation, Reservation, ReservationStatus } from '@/utils/types'
|
||||
import type { NewReservation, Reservation, ReservationEdit, ReservationStatus } from '@/utils/types'
|
||||
import { getClient } from './client'
|
||||
|
||||
export const RESERVATIONS_KEY = ['reservations']
|
||||
|
|
@ -71,3 +71,24 @@ export function useCreateReservation() {
|
|||
onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the period, the bikes and the Linka Go accounts. Everything else is
|
||||
* left as stored: the backend reads it back rather than taking it from here.
|
||||
*/
|
||||
export function useUpdateReservation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
retry: 0,
|
||||
mutationFn: async ({ id, ...edit }: ReservationEdit & { id: number }) => {
|
||||
const { response, error } = await getClient().PUT('/api/reservations/{id}', {
|
||||
params: { path: { id } },
|
||||
body: edit,
|
||||
})
|
||||
if (response.status !== HttpStatus.OK) {
|
||||
throw new Error(typeof error === 'string' ? error : `Unexpected status: ${response.status}`)
|
||||
}
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export type ReservationStatus = components['schemas']['ReservationStatus']
|
|||
export type UserSummary = components['schemas']['UserSummary']
|
||||
export type ReservationUnit = components['schemas']['ReservationUnit']
|
||||
export type NewReservation = components['schemas']['NewReservation']
|
||||
export type ReservationEdit = components['schemas']['ReservationEditForm']
|
||||
|
||||
/**
|
||||
* What to display for a reservation's unit: the name of the unit it points at,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use aide::{
|
|||
transform::TransformOperation,
|
||||
};
|
||||
use axum::{Json, extract::Path, http::StatusCode};
|
||||
use chrono::{DateTime, Utc};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -21,7 +22,10 @@ use crate::{
|
|||
api::helpers::{IdPath, admin, admin_desc, desc, manager, manager_desc, unexpected_error},
|
||||
core::{
|
||||
controller::{AppController, ControllerError, reservations::ReservationsControllerError},
|
||||
models::reservation::{NewReservation, Reservation, ReservationStatus},
|
||||
models::{
|
||||
bike::BikeId,
|
||||
reservation::{NewReservation, Reservation, ReservationEdit, ReservationStatus},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -32,6 +36,10 @@ pub fn routes() -> ApiRouter {
|
|||
get_with(get_reservations, get_reservations_docs)
|
||||
.post_with(create_reservation, create_reservation_docs),
|
||||
)
|
||||
.api_route(
|
||||
"/{id}",
|
||||
put_with(update_reservation, update_reservation_docs),
|
||||
)
|
||||
.api_route("/{id}/status", put_with(set_status, set_status_docs))
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +100,79 @@ fn create_reservation_docs(op: TransformOperation) -> TransformOperation {
|
|||
.response_with::<422, (), _>(desc("The unit or one of the bikes does not exist"))
|
||||
}
|
||||
|
||||
/// Only what the admin page lets somebody change. The unit, the requester, the
|
||||
/// telegram handle and the reason are shown but not editable, so they are not
|
||||
/// in the body at all: the handler reads them back from the stored reservation
|
||||
/// rather than trusting a client to send them unchanged.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ReservationEditForm {
|
||||
start_time: DateTime<Utc>,
|
||||
end_time: DateTime<Utc>,
|
||||
bikes: Vec<BikeId>,
|
||||
linka_emails: Vec<String>,
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
async fn update_reservation(
|
||||
ac: AppController,
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
Json(form): Json<ReservationEditForm>,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
let current = match ac.get_reservation(id).await {
|
||||
Ok(reservation) => reservation,
|
||||
Err(err) if err.is_not_found() => {
|
||||
return Err((StatusCode::NOT_FOUND, "No such reservation".to_owned()));
|
||||
}
|
||||
Err(err) => return unexpected_error("update_reservation", err),
|
||||
};
|
||||
|
||||
let edit = ReservationEdit {
|
||||
id,
|
||||
unit: current.unit.as_new(),
|
||||
start_time: form.start_time,
|
||||
end_time: form.end_time,
|
||||
users: current.users.iter().map(|user| user.id).collect(),
|
||||
telegram: current.telegram,
|
||||
description: current.description,
|
||||
bikes: form.bikes,
|
||||
linka_emails: form.linka_emails,
|
||||
};
|
||||
|
||||
match manager(ac, current.unit.scope())?
|
||||
.update_reservation(edit)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
Err(ControllerError::Reservation(
|
||||
err @ ReservationsControllerError::ReservationInvalid,
|
||||
)) => Err((StatusCode::BAD_REQUEST, err.to_string())),
|
||||
Err(ControllerError::Reservation(
|
||||
err @ (ReservationsControllerError::BikeOutOfService(_)
|
||||
| ReservationsControllerError::ReservationFinal(_)),
|
||||
)) => Err((StatusCode::CONFLICT, err.to_string())),
|
||||
Err(err) if err.is_not_found() => {
|
||||
Err((StatusCode::UNPROCESSABLE_ENTITY, "Unknown bike".to_owned()))
|
||||
}
|
||||
Err(err) => unexpected_error("update_reservation", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_reservation_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Reservations")
|
||||
.summary("Edit the period, the bikes and the Linka Go accounts")
|
||||
.description(
|
||||
"Everything else is left as stored. A reservation in a final state \
|
||||
cannot be edited any more.",
|
||||
)
|
||||
.response_with::<403, (), _>(manager_desc)
|
||||
.response::<404, ()>()
|
||||
.response_with::<400, (), _>(desc("The reservation would become malformed"))
|
||||
.response_with::<409, (), _>(desc(
|
||||
"A newly added bike is out of service, or the reservation is final",
|
||||
))
|
||||
.response_with::<422, (), _>(desc("One of the bikes does not exist"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct SetStatusForm {
|
||||
status: ReservationStatus,
|
||||
|
|
|
|||
|
|
@ -89,6 +89,17 @@ impl ManagerAppController {
|
|||
return Err(ReservationsControllerError::ReservationFinal(current.status).into());
|
||||
}
|
||||
|
||||
// A bike already on the reservation may well have broken down since:
|
||||
// only a newly added one has to be in service.
|
||||
for id in &reservation.bikes {
|
||||
if current.bikes.contains(id) {
|
||||
continue;
|
||||
}
|
||||
if self.get_bike(*id).await?.status == BikeStatus::OutOfService {
|
||||
return Err(ReservationsControllerError::BikeOutOfService(*id).into());
|
||||
}
|
||||
}
|
||||
|
||||
self.db
|
||||
.update_reservation(reservation)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ impl ReservationUnit {
|
|||
}
|
||||
}
|
||||
|
||||
/// The same choice in the shape an edit carries. Editing never changes the
|
||||
/// unit, so a handler rebuilds it from the stored reservation.
|
||||
pub fn as_new(&self) -> NewReservationUnit {
|
||||
match self {
|
||||
ReservationUnit::Known { unit } => NewReservationUnit::Known { id: unit.id },
|
||||
ReservationUnit::Free { name } => NewReservationUnit::Free { name: name.clone() },
|
||||
}
|
||||
}
|
||||
|
||||
/// The scope somebody must manage to act on this reservation. `None` for a
|
||||
/// typed name: nobody is the manager of a unit we do not know, so only an
|
||||
/// admin qualifies.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue