This commit is contained in:
Antoine Pelletier 2026-08-24 15:36:23 +02:00
parent 34fbc2f3de
commit c6460a0a77
20 changed files with 1099 additions and 316 deletions

View file

@ -1,32 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* A quarter-hour picker.
*
* The 96 slots are only put in the tree while the list is open: reka-ui creates
* a component per `SelectItem` as soon as they are rendered, even though it
* shows none of them until then. Two pickers' worth of that is what made the
* edit dialog take a third of a second to appear.
*/
import { computed, ref } from 'vue'
import { Clock } from '@lucide/vue' import { Clock } from '@lucide/vue'
import { import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const model = defineModel<string | undefined>() const model = defineModel<string | undefined>()
defineProps<{ id?: string; invalid?: boolean }>() defineProps<{ id?: string; invalid?: boolean }>()
const SLOT_MINUTES = 15 const SLOT_MINUTES = 15
const slots = Array.from({ length: (24 * 60) / SLOT_MINUTES }, (_, i) => { const SLOTS = Array.from({ length: (24 * 60) / SLOT_MINUTES }, (_, i) => {
const minutes = i * SLOT_MINUTES const minutes = i * SLOT_MINUTES
const hh = String(Math.floor(minutes / 60)).padStart(2, '0') const hh = String(Math.floor(minutes / 60)).padStart(2, '0')
const mm = String(minutes % 60).padStart(2, '0') const mm = String(minutes % 60).padStart(2, '0')
return `${hh}:${mm}` return `${hh}:${mm}`
}) })
const open = ref(false)
const slots = computed(() => (open.value ? SLOTS : []))
</script> </script>
<template> <template>
<Select v-model="model"> <Select v-model="model" v-model:open="open">
<SelectTrigger :id="id" :aria-invalid="invalid || undefined" class="w-full px-3"> <SelectTrigger :id="id" :aria-invalid="invalid || undefined" class="w-full px-3">
<span class="flex min-w-0 items-center gap-2"> <span class="flex min-w-0 items-center gap-2">
<Clock class="size-4 shrink-0 opacity-60" /> <Clock class="size-4 shrink-0 opacity-60" />
<SelectValue :placeholder="$t('reservation.pick-time')" /> <!-- The label is the value itself, so it needs no mounted item to read
it from — which is what lets the list stay empty while closed -->
<span class="truncate">{{ model || $t('reservation.pick-time') }}</span>
</span> </span>
</SelectTrigger> </SelectTrigger>
<SelectContent class="max-h-64"> <SelectContent class="max-h-64">

View file

@ -1,44 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* Reservations, split the way an administrator reads them: what is waiting for * Reservations, split the way an administrator reads them: what is waiting for
* a decision, what is live or coming, and — behind a toggle — everything that * a decision, what is live or coming, and — behind a button — the archive.
* is over one way or another. *
* The two sections are two queries, each asking the backend for its own
* statuses; the archive is a third one, paged, inside its dialog. Nothing here
* ever holds the whole table.
*/ */
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { Archive } from '@lucide/vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import ReservationArchiveDialog from './ReservationArchiveDialog.vue'
import ReservationCard from './ReservationCard.vue' import ReservationCard from './ReservationCard.vue'
import type { Bike, Reservation } from '@/utils/types' import type { Bike, Reservation } from '@/utils/types'
const props = defineProps<{ const props = defineProps<{
reservations: Reservation[] pending: Reservation[]
active: Reservation[]
bikes: Bike[] bikes: Bike[]
isPending: boolean isPending: boolean
isError: boolean isError: boolean
}>() }>()
const showArchived = ref(false) const archiveOpen = ref(false)
const pending = computed(() => props.reservations.filter((r) => r.status === 'requested')) /** Soonest first: both sections are read as "what comes next" */
const active = computed(() => const byStart = (list: Reservation[]) =>
props.reservations.filter((r) => r.status === 'approved' || r.status === 'ongoing'), [...list].sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime())
)
// The three final states of the machine, grouped: nothing more will happen to them
const archived = computed(() =>
props.reservations.filter(
(r) => r.status === 'refused' || r.status === 'cancelled' || r.status === 'archived',
),
)
/** Soonest first for what is coming, most recent first for the history */ const pendingSorted = computed(() => byStart(props.pending))
function byStart(list: Reservation[], descending = false) { const activeSorted = computed(() => byStart(props.active))
return [...list].sort((a, b) => {
const diff = new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
return descending ? -diff : diff
})
}
</script> </script>
<template> <template>
@ -68,7 +62,7 @@ function byStart(list: Reservation[], descending = false) {
{{ $t('admin.reservations.pending-empty') }} {{ $t('admin.reservations.pending-empty') }}
</p> </p>
<ReservationCard <ReservationCard
v-for="reservation in byStart(pending)" v-for="reservation in pendingSorted"
:key="reservation.id" :key="reservation.id"
:reservation="reservation" :reservation="reservation"
:bikes="bikes" :bikes="bikes"
@ -84,36 +78,22 @@ function byStart(list: Reservation[], descending = false) {
{{ $t('admin.reservations.active-empty') }} {{ $t('admin.reservations.active-empty') }}
</p> </p>
<ReservationCard <ReservationCard
v-for="reservation in byStart(active)" v-for="reservation in activeSorted"
:key="reservation.id" :key="reservation.id"
:reservation="reservation" :reservation="reservation"
:bikes="bikes" :bikes="bikes"
/> />
</section> </section>
<section class="grid gap-3"> <div>
<div> <Button variant="outline" size="sm" @click="archiveOpen = true">
<Button variant="outline" size="sm" @click="showArchived = !showArchived"> <Archive class="size-4" />
{{ {{ $t('admin.reservations.archive.action') }}
showArchived </Button>
? $t('admin.reservations.hide-archived') </div>
: $t('admin.reservations.show-archived', { n: archived.length })
}}
</Button>
</div>
<template v-if="showArchived">
<p v-if="!archived.length" class="text-muted-foreground text-sm">
{{ $t('admin.reservations.archived-empty') }}
</p>
<ReservationCard
v-for="reservation in byStart(archived, true)"
:key="reservation.id"
:reservation="reservation"
:bikes="bikes"
/>
</template>
</section>
</template> </template>
<ReservationArchiveDialog v-model:open="archiveOpen" :bikes="bikes" />
</CardContent> </CardContent>
</Card> </Card>
</template> </template>

View file

@ -0,0 +1,140 @@
<script setup lang="ts">
/**
* The archive: everything a reservation can end up as — refused, cancelled or
* archived — browsed one page at a time.
*
* The search and the paging are query parameters, not a filter over a list this
* component holds: the archive is the part of the table that only ever grows,
* and it is the one place where downloading all of it to hide most of it would
* hurt first.
*/
import { computed, ref, watch } from 'vue'
import { refDebounced } from '@vueuse/core'
import { Search } from '@lucide/vue'
import ReservationCard from './ReservationCard.vue'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { useReservations } from '@/services/api/reservations'
import type { Bike, ReservationStatus } from '@/utils/types'
defineProps<{ bikes: Bike[] }>()
const open = defineModel<boolean>('open', { default: false })
const PAGE_SIZE = 10
/** The three final states of the machine: nothing more will happen to these */
const FINAL: ReservationStatus[] = ['refused', 'cancelled', 'archived']
const search = ref('')
// The backend answers every keystroke otherwise; a third of a second of quiet
// is what turns typing into one query.
const debouncedSearch = refDebounced(search, 300)
const status = ref<'all' | ReservationStatus>('all')
const page = ref(0)
const filters = computed(() => ({
status: status.value === 'all' ? FINAL : [status.value],
q: debouncedSearch.value,
limit: PAGE_SIZE,
offset: page.value * PAGE_SIZE,
}))
// Closed, the archive costs nothing: the query only runs while it is on screen
const { data, isPending, isError, isFetching } = useReservations(filters, { enabled: open })
const items = computed(() => data.value?.items ?? [])
const total = computed(() => data.value?.total ?? 0)
const pages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
// Narrowing the search while on page 4 would otherwise land past the end
watch([debouncedSearch, status], () => (page.value = 0))
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="flex max-h-[85vh] flex-col gap-4 sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{{ $t('admin.reservations.archive.title') }}</DialogTitle>
<DialogDescription>{{ $t('admin.reservations.archive.intro') }}</DialogDescription>
</DialogHeader>
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-56 flex-1">
<Search class="text-muted-foreground absolute top-2.5 left-2.5 size-4" />
<Input
v-model="search"
class="pl-8"
type="search"
:placeholder="$t('admin.reservations.archive.search-placeholder')"
:aria-label="$t('admin.reservations.archive.search')"
/>
</div>
<Select v-model="status">
<SelectTrigger class="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ $t('admin.reservations.archive.all-status') }}</SelectItem>
<SelectItem v-for="value in FINAL" :key="value" :value="value">
{{ $t(`reservation.status.${value}`) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="isPending" class="grid gap-3 overflow-y-auto">
<Skeleton v-for="i in 3" :key="i" class="h-28 w-full" />
</div>
<p v-else-if="isError" class="text-destructive text-sm">
{{ $t('admin.reservations.load-error') }}
</p>
<template v-else>
<p class="text-muted-foreground text-sm" :class="isFetching ? 'opacity-60' : ''">
{{ $t('admin.reservations.archive.count', total) }}
</p>
<p v-if="!items.length" class="text-muted-foreground text-sm">
{{ $t('admin.reservations.archive.empty') }}
</p>
<div v-else class="-mr-2 grid min-h-0 gap-3 overflow-y-auto pr-2">
<ReservationCard
v-for="reservation in items"
:key="reservation.id"
:reservation="reservation"
:bikes="bikes"
/>
</div>
<div v-if="pages > 1" class="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" :disabled="page === 0" @click="page -= 1">
{{ $t('pagination.previous') }}
</Button>
<span class="text-muted-foreground text-sm">
{{ $t('pagination.page', { page: page + 1, pages }) }}
</span>
<Button variant="outline" size="sm" :disabled="page + 1 >= pages" @click="page += 1">
{{ $t('pagination.next') }}
</Button>
</div>
</template>
</DialogContent>
</Dialog>
</template>

View file

@ -66,7 +66,7 @@ function move(status: ReservationStatus) {
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]"> <Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
{{ $t(`reservation.status.${reservation.status}`) }} {{ $t(`reservation.status.${reservation.status}`) }}
</Badge> </Badge>
<span class="text-muted-foreground text-sm">{{ unitLabel(reservation.unit) }}</span> <span class="text-sm font-semibold">{{ unitLabel(reservation.unit) }}</span>
</div> </div>
<div v-if="transitions.length || editable" class="flex flex-wrap gap-2"> <div v-if="transitions.length || editable" class="flex flex-wrap gap-2">
@ -87,7 +87,7 @@ function move(status: ReservationStatus) {
</div> </div>
</div> </div>
<ReservationDetails class="mt-3" :reservation="reservation" :bikes="bikes" /> <ReservationDetails class="mt-3 border-t pt-3" :reservation="reservation" :bikes="bikes" />
<ReservationEditDialog <ReservationEditDialog
v-if="editable" v-if="editable"

View file

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { ChevronLeft, ChevronRight } from '@lucide/vue' import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { import {
Select, Select,
SelectContent, SelectContent,
@ -22,6 +22,7 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { unitLabel, type Bike, type CalendarReservation } from '@/utils/types' import { unitLabel, type Bike, type CalendarReservation } from '@/utils/types'
import { addDays, startOfWeek } from '@/utils/week'
// Takes the narrow shape the public calendar receives; a full `Reservation` // Takes the narrow shape the public calendar receives; a full `Reservation`
// satisfies it too, so the admin page keeps passing its own list unchanged. // satisfies it too, so the admin page keeps passing its own list unchanged.
@ -35,27 +36,18 @@ const FIRST_HOUR = 0
const LAST_HOUR = 24 const LAST_HOUR = 24
const HOUR_HEIGHT = 32 const HOUR_HEIGHT = 32
const weekStart = ref(startOfWeek(new Date())) // Bound by the parent, which fetches exactly the week being drawn; left alone
// the calendar simply keeps the week to itself.
const weekStart = defineModel<Date>('weekStart', { default: () => startOfWeek(new Date()) })
const selectedBike = ref<string>('all') const selectedBike = ref<string>('all')
function startOfWeek(date: Date) {
const start = new Date(date)
start.setHours(0, 0, 0, 0)
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
return start
}
function shiftWeek(weeks: number) { function shiftWeek(weeks: number) {
const next = new Date(weekStart.value) weekStart.value = addDays(weekStart.value, weeks * 7)
next.setDate(next.getDate() + weeks * 7)
weekStart.value = next
} }
const days = computed(() => const days = computed(() =>
Array.from({ length: 7 }, (_, i) => { Array.from({ length: 7 }, (_, i) => {
const day = new Date(weekStart.value) return addDays(weekStart.value, i)
day.setDate(day.getDate() + i)
return day
}), }),
) )
@ -77,11 +69,10 @@ const rangeFormatter = computed(
}), }),
) )
const range = computed(() => { const range = computed(
const end = new Date(weekStart.value) () =>
end.setDate(end.getDate() + 6) `${rangeFormatter.value.format(weekStart.value)} – ${rangeFormatter.value.format(addDays(weekStart.value, 6))}`,
return `${rangeFormatter.value.format(weekStart.value)} – ${rangeFormatter.value.format(end)}` )
})
const hours = computed(() => const hours = computed(() =>
Array.from({ length: LAST_HOUR - FIRST_HOUR }, (_, i) => FIRST_HOUR + i), Array.from({ length: LAST_HOUR - FIRST_HOUR }, (_, i) => FIRST_HOUR + i),
@ -145,7 +136,6 @@ function blocksFor(day: Date, bike: Bike): Block[] {
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<CardTitle>{{ $t('calendar.title') }}</CardTitle> <CardTitle>{{ $t('calendar.title') }}</CardTitle>
<CardDescription>{{ $t('calendar.intro') }}</CardDescription>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -267,7 +257,9 @@ function blocksFor(day: Date, bike: Bike): Block[] {
{{ $t(`reservation.status.${openedReservation.status}`) }} {{ $t(`reservation.status.${openedReservation.status}`) }}
</Badge> </Badge>
</DialogTitle> </DialogTitle>
<DialogDescription>{{ unitLabel(openedReservation.unit) }}</DialogDescription> <DialogDescription class="text-foreground font-semibold">
{{ unitLabel(openedReservation.unit) }}
</DialogDescription>
</DialogHeader> </DialogHeader>
<ReservationDetails :reservation="openedReservation" :bikes="bikes" /> <ReservationDetails :reservation="openedReservation" :bikes="bikes" />
</DialogScrollContent> </DialogScrollContent>

View file

@ -2,14 +2,21 @@
/** /**
* The read-only body of a reservation, shared by the admin card and the two * The read-only body of a reservation, shared by the admin card and the two
* dialogs so the three never drift apart. * dialogs so the three never drift apart.
*
* Each field is a stacked block — caption above, value below — and the blocks
* flow in two even columns: side-by-side label/value pairs wrapped badly on a
* phone, and a single column made the cards far taller than they needed to be.
*/ */
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { Bike, ReservationLike } from '@/utils/types' import type { Bike, ReservationLike } from '@/utils/types'
type Field = 'period' | 'bikes' | 'telegram' | 'people' | 'linka' | 'reason' type Field = 'period' | 'bikes' | 'telegram' | 'people' | 'linka' | 'reason'
/** Past this many Linka Go accounts the list is folded behind a button */
const LINKA_SHOWN = 5
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
reservation: ReservationLike reservation: ReservationLike
@ -26,52 +33,107 @@ function shows(field: Field) {
const { locale } = useI18n() const { locale } = useI18n()
/** Names when we know the bike, `#id` when the list has not loaded yet */
const bikeNames = computed(() => const bikeNames = computed(() =>
props.reservation.bikes props.reservation.bikes.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`),
.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`)
.join(', '),
) )
const formatter = computed( // The e-mail address is already on the reservation as a Linka Go account or in
// the admin's own directory: the name is what tells the people apart here.
const peopleNames = computed(() =>
(props.reservation.users ?? []).map((user) => `${user.firstname} ${user.name}`),
)
const linkaEmails = computed(() => props.reservation.linka_emails ?? [])
const linkaExpanded = ref(false)
const linkaShown = computed(() =>
linkaExpanded.value ? linkaEmails.value : linkaEmails.value.slice(0, LINKA_SHOWN),
)
const linkaHidden = computed(() => Math.max(0, linkaEmails.value.length - LINKA_SHOWN))
const dateFormatter = computed(
() => () =>
new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', { new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', {
dateStyle: 'short', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
}), }),
) )
function format(iso: string) { function format(iso: string) {
return formatter.value.format(new Date(iso)) return dateFormatter.value.format(new Date(iso))
} }
</script> </script>
<template> <template>
<dl class="grid gap-1 text-sm"> <dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
<div v-if="shows('period')" class="flex gap-2"> <div v-if="shows('period')" class="min-w-0">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.period') }}</dt> <dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<dd>{{ format(reservation.start_time) }} → {{ format(reservation.end_time) }}</dd> {{ $t('reservation.details.period') }}
</div> </dt>
<div v-if="shows('bikes')" class="flex gap-2"> <dd class="mt-0.5 flex flex-wrap items-center gap-x-2 font-medium tabular-nums">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.bikes') }}</dt> <span>{{ format(reservation.start_time) }}</span>
<dd>{{ bikeNames }}</dd> <span class="text-muted-foreground" aria-hidden="true">→</span>
</div> <span>{{ format(reservation.end_time) }}</span>
<div v-if="shows('telegram') && reservation.telegram" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.telegram') }}</dt>
<dd class="truncate">{{ reservation.telegram }}</dd>
</div>
<div v-if="shows('people') && reservation.users?.length" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.people') }}</dt>
<dd class="min-w-0 break-words">
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
</dd> </dd>
</div> </div>
<div v-if="shows('linka') && reservation.linka_emails?.length" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.linka') }}</dt> <div v-if="shows('bikes') && bikeNames.length" class="min-w-0">
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd> <dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.bikes') }}
</dt>
<dd class="mt-0.5 flex flex-wrap gap-1">
<span
v-for="name in bikeNames"
:key="name"
class="bg-muted rounded-md px-2 py-0.5 font-medium tabular-nums"
>
{{ name }}
</span>
</dd>
</div> </div>
<div v-if="shows('reason') && reservation.description" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.reason') }}</dt> <div v-if="shows('telegram') && reservation.telegram" class="min-w-0">
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd> <dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.telegram') }}
</dt>
<dd class="mt-0.5 font-medium break-all">{{ reservation.telegram }}</dd>
</div>
<div v-if="shows('people') && peopleNames.length" class="min-w-0">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.people') }}
</dt>
<dd class="mt-0.5 break-words">{{ peopleNames.join(', ') }}</dd>
</div>
<div v-if="shows('linka') && linkaEmails.length" class="min-w-0">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.linka') }}
</dt>
<dd class="mt-0.5">
<ul class="grid gap-0.5">
<li v-for="email in linkaShown" :key="email" class="break-all">{{ email }}</li>
</ul>
<button
v-if="linkaHidden"
type="button"
class="text-primary mt-1 cursor-pointer text-xs font-medium hover:underline"
@click="linkaExpanded = !linkaExpanded"
>
{{
linkaExpanded
? $t('reservation.details.show-less')
: $t('reservation.details.show-more', { n: linkaHidden })
}}
</button>
</dd>
</div>
<div v-if="shows('reason') && reservation.description" class="min-w-0">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.reason') }}
</dt>
<dd class="mt-0.5 break-words whitespace-pre-line">{{ reservation.description }}</dd>
</div> </div>
</dl> </dl>
</template> </template>

View file

@ -1,4 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* The shadcn dialog, with the enter and exit animations taken out on purpose:
* a dialog is a reply to a click, and 200ms of zoom and fade in front of it
* reads as the app being slow. The overlay appears with it, in one paint.
*/
import type { DialogContentEmits, DialogContentProps } from 'reka-ui' import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from '@vueuse/core'
@ -22,14 +27,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay <DialogOverlay class="fixed inset-0 z-50 bg-black/80" />
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 <DialogContent
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( 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', '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 sm:rounded-lg',
props.class, props.class,
) )
" "

View file

@ -403,24 +403,51 @@ export interface paths {
path?: never path?: never
cookie?: never cookie?: never
} }
/** Get every reservation */ /**
* Get a page of the reservations, filtered
* @description The filtering, the text search and the paging all happen in the database: no caller ever receives the whole table.
*/
get: { get: {
parameters: { parameters: {
query?: never query?: {
/** @description Keeps only what overlaps the window */
from?: string | null
limit?: number | null
offset?: number | null
/**
* @description Free text: the unit, the telegram handle, the people, the Linka Go
* addresses, the reason, or the number of a reservation
*/
q?: string | null
/** @description Comma-separated statuses (`requested,approved`); left out means any */
status?: string | null
to?: string | null
}
header?: never header?: never
path?: never path?: never
cookie?: never cookie?: never
} }
requestBody?: never requestBody?: never
responses: { responses: {
/**
* @description One page of a listing, with the size of the whole so the caller can page
* through it without asking twice.
*/
200: { 200: {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content: {
'application/json': components['schemas']['Reservation'][] 'application/json': components['schemas']['ReservationPage']
} }
} }
/** @description A status in the filter is not a known one */
400: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthenticated - a session is required */ /** @description Unauthenticated - a session is required */
401: { 401: {
headers: { headers: {
@ -515,26 +542,50 @@ export interface paths {
cookie?: never cookie?: never
} }
/** /**
* Get the reservations the session user is part of * Get a page of the reservations the session user is part of
* @description An address listed among the Linka Go accounts is enough, which is how somebody added before they ever logged in finds the reservation waiting for them. * @description Takes the same filters as the listing, and answers the same page. An address listed among the Linka Go accounts is enough to be part of a reservation, which is how somebody added before they ever logged in finds the one waiting for them.
*/ */
get: { get: {
parameters: { parameters: {
query?: never query?: {
/** @description Keeps only what overlaps the window */
from?: string | null
limit?: number | null
offset?: number | null
/**
* @description Free text: the unit, the telegram handle, the people, the Linka Go
* addresses, the reason, or the number of a reservation
*/
q?: string | null
/** @description Comma-separated statuses (`requested,approved`); left out means any */
status?: string | null
to?: string | null
}
header?: never header?: never
path?: never path?: never
cookie?: never cookie?: never
} }
requestBody?: never requestBody?: never
responses: { responses: {
/**
* @description One page of a listing, with the size of the whole so the caller can page
* through it without asking twice.
*/
200: { 200: {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content: {
'application/json': components['schemas']['Reservation'][] 'application/json': components['schemas']['ReservationPage']
} }
} }
/** @description A status in the filter is not a known one */
400: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthenticated - a session is required */ /** @description Unauthenticated - a session is required */
401: { 401: {
headers: { headers: {
@ -561,11 +612,14 @@ export interface paths {
} }
/** /**
* Get the approved and ongoing reservations, for the public calendar * Get the approved and ongoing reservations, for the public calendar
* @description No session needed, and no personal field travels: the telegram handle, the people, the Linka Go addresses and the reason are left out. * @description Give `from` and `to` to read one week rather than the whole table. No session needed, and no personal field travels: the telegram handle, the people, the Linka Go addresses and the reason are left out.
*/ */
get: { get: {
parameters: { parameters: {
query?: never query?: {
from?: string | null
to?: string | null
}
header?: never header?: never
path?: never path?: never
cookie?: never cookie?: never
@ -793,6 +847,17 @@ export interface components {
status: components['schemas']['ReservationStatus'] status: components['schemas']['ReservationStatus']
unit: components['schemas']['ReservationUnit'] unit: components['schemas']['ReservationUnit']
} }
/**
* @description The availability calendar, open to everybody: when the bikes are taken and by
* which association, with nothing personal attached.
* The window a calendar draws, so only that window is read.
*/
CalendarWindow: {
/** Format: date-time */
from?: string | null
/** Format: date-time */
to?: string | null
}
DevUser: { DevUser: {
admin: boolean admin: boolean
email: string email: string
@ -875,6 +940,46 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
start_time: string start_time: string
} }
/**
* @description One page of a listing, with the size of the whole so the caller can page
* through it without asking twice.
*/
ReservationPage: {
items: components['schemas']['Reservation'][]
/**
* Format: int64
* @description How many reservations match the query, ignoring `limit` and `offset`
*/
total: number
}
/**
* @description The listing filters, the way a query string carries them.
*
* Everything is optional and everything narrows, so the same route serves the
* three sections of the admin page and the archive browser. The alternative —
* handing the whole table to the browser and filtering it there — stops
* working the day the archive is a few thousand rows long.
*/
ReservationSearchParams: {
/**
* Format: date-time
* @description Keeps only what overlaps the window
*/
from?: string | null
/** Format: int64 */
limit?: number | null
/** Format: int64 */
offset?: number | null
/**
* @description Free text: the unit, the telegram handle, the people, the Linka Go
* addresses, the reason, or the number of a reservation
*/
q?: string | null
/** @description Comma-separated statuses (`requested,approved`); left out means any */
status?: string | null
/** Format: date-time */
to?: string | null
}
/** @enum {string} */ /** @enum {string} */
ReservationStatus: 'requested' | 'refused' | 'approved' | 'cancelled' | 'ongoing' | 'archived' ReservationStatus: 'requested' | 'refused' | 'approved' | 'cancelled' | 'ongoing' | 'archived'
/** /**

View file

@ -70,6 +70,8 @@ reservation:
people: People people: People
linka: Linka Go accounts linka: Linka Go accounts
reason: Reason reason: Reason
show-more: 'Show {n} more'
show-less: Show less
edit: edit:
action: Edit action: Edit
title: 'Reservation #{id}' title: 'Reservation #{id}'
@ -106,10 +108,14 @@ login:
dev: Development login dev: Development login
not-allowed: Your account is not allowed to use this application. not-allowed: Your account is not allowed to use this application.
error: The login failed. error: The login failed.
pagination:
page: 'Page {page} of {pages}'
previous: Previous
next: Next
calendar: calendar:
load-error: Unable to load the calendar. load-error: Unable to load the calendar.
title: Calendar per cargobike title: Calendar per cargobike
intro: Approved and ongoing reservations are shown in the calendar.
bike: Cargobike bike: Cargobike
all-bikes: All all-bikes: All
previous: Week previous: Week
@ -144,9 +150,18 @@ admin:
pending-empty: No reservation request for now. pending-empty: No reservation request for now.
active: Reservations (ongoing and upcoming) active: Reservations (ongoing and upcoming)
active-empty: No ongoing or upcoming reservation. active-empty: No ongoing or upcoming reservation.
show-archived: 'Show archived requests ({n})' archive:
hide-archived: Hide archived requests action: Browse the archive
archived-empty: No archived request. title: Archived reservations
intro: >-
The refused, cancelled and archived reservations. The search covers the
association, the telegram handle, the people, the Linka Go accounts, the
reason and the reservation number.
search: Search the archive
search-placeholder: 'Association, person, e-mail, #number…'
all-status: Any status
count: No result | 1 reservation | {count} reservations
empty: No reservation matches this search.
load-error: Unable to load the reservations. load-error: Unable to load the reservations.
error: The status change failed. error: The status change failed.
action: action:

View file

@ -71,6 +71,8 @@ reservation:
people: Personnes people: Personnes
linka: Comptes Linka Go linka: Comptes Linka Go
reason: Raison reason: Raison
show-more: 'Afficher {n} de plus'
show-less: Réduire
edit: edit:
action: Modifier action: Modifier
title: 'Réservation #{id}' title: 'Réservation #{id}'
@ -107,10 +109,14 @@ login:
dev: Connexion de développement dev: Connexion de développement
not-allowed: Votre compte n'est pas autorisé à accéder à cette application. not-allowed: Votre compte n'est pas autorisé à accéder à cette application.
error: La connexion a échoué. error: La connexion a échoué.
pagination:
page: 'Page {page} sur {pages}'
previous: Précédent
next: Suivant
calendar: calendar:
load-error: Impossible de charger le calendrier. load-error: Impossible de charger le calendrier.
title: Calendrier par cargobike title: Calendrier par cargobike
intro: Les réservations validées et en cours sont affichées dans le calendrier.
bike: Cargobike bike: Cargobike
all-bikes: Tous all-bikes: Tous
previous: Sem. previous: Sem.
@ -145,9 +151,18 @@ admin:
pending-empty: Aucune demande de réservation pour le moment. pending-empty: Aucune demande de réservation pour le moment.
active: Réservations (en cours et à venir) active: Réservations (en cours et à venir)
active-empty: Aucune réservation en cours ou à venir. active-empty: Aucune réservation en cours ou à venir.
show-archived: 'Voir les demandes archivées ({n})' archive:
hide-archived: Masquer les demandes archivées action: Parcourir les archives
archived-empty: Aucune demande archivée. title: Réservations archivées
intro: >-
Les réservations refusées, annulées et archivées. La recherche porte sur
l'association, le Telegram, les personnes, les comptes Linka Go, la raison
et le numéro de réservation.
search: Rechercher dans les archives
search-placeholder: 'Association, personne, e-mail, #numéro…'
all-status: Tous les statuts
count: Aucun résultat | 1 réservation | {count} réservations
empty: Aucune réservation ne correspond à cette recherche.
load-error: Impossible de charger les réservations. load-error: Impossible de charger les réservations.
error: Le changement de statut a échoué. error: Le changement de statut a échoué.
action: action:

View file

@ -1,8 +1,14 @@
/** /**
* Reservations. Filing a request only needs a session; reading the whole list is * Reservations. Filing a request only needs a session; reading the list is an
* an admin action, so `useReservations` is only ever mounted on /admin. * admin action, so `useReservations` is only ever mounted on /admin.
*
* Nothing here ever asks for "all the reservations": the status filter, the text
* search and the paging are query parameters the database applies. The table
* only grows, and an admin looking at three pending requests must not pay for
* the archive behind them.
*/ */
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { HttpStatus } from 'http-status-ts' import { HttpStatus } from 'http-status-ts'
import type { NewReservation, Reservation, ReservationEdit, ReservationStatus } from '@/utils/types' import type { NewReservation, Reservation, ReservationEdit, ReservationStatus } from '@/utils/types'
@ -12,16 +18,54 @@ export const RESERVATIONS_KEY = ['reservations']
export const MY_RESERVATIONS_KEY = ['reservations', 'mine'] export const MY_RESERVATIONS_KEY = ['reservations', 'mine']
export const CALENDAR_KEY = ['reservations', 'calendar'] export const CALENDAR_KEY = ['reservations', 'calendar']
export function useReservations() { /** A window of time, as the two calendar-shaped endpoints take it */
export type Window = { from?: Date; to?: Date }
export type ReservationFilters = Window & {
/** Empty or absent means any status */
status?: ReservationStatus[]
/** Free text, matched by the database across the whole reservation */
q?: string
limit?: number
offset?: number
}
/** The filters in the shape the query string takes: absent means "no filter" */
function toParams(filters: ReservationFilters) {
return {
status: filters.status?.length ? filters.status.join(',') : undefined,
q: filters.q?.trim() || undefined,
from: filters.from?.toISOString(),
to: filters.to?.toISOString(),
limit: filters.limit,
offset: filters.offset,
}
}
/**
* One page of the reservations matching `filters`, with the size of the whole
* match so the caller can page through it.
*/
export function useReservations(
filters: MaybeRefOrGetter<ReservationFilters> = () => ({}),
/** Left out, the query runs; the archive dialog only wants it while open */
options: { enabled?: MaybeRefOrGetter<boolean> } = {},
) {
const params = computed(() => toParams(toValue(filters)))
return useQuery({ return useQuery({
queryKey: RESERVATIONS_KEY, queryKey: computed(() => [...RESERVATIONS_KEY, 'search', params.value]),
enabled: computed(() => toValue(options.enabled) ?? true),
staleTime: 30 * 1000, staleTime: 30 * 1000,
// Paging or typing in the search must not blank the list under the cursor
placeholderData: keepPreviousData,
queryFn: async () => { queryFn: async () => {
const { data, response } = await getClient().GET('/api/reservations') const { data, response } = await getClient().GET('/api/reservations', {
params: { query: params.value },
})
if (response.status !== HttpStatus.OK) { if (response.status !== HttpStatus.OK) {
throw new Error(`Unexpected status code received: ${response.status}`) throw new Error(`Unexpected status code received: ${response.status}`)
} }
return data ?? [] return data ?? { items: [], total: 0 }
}, },
}) })
} }
@ -41,28 +85,30 @@ export function useSetReservationStatus() {
}) })
return { id, status } return { id, status }
}, },
onSuccess: ({ id, status }) => { // A status change moves a reservation from one filtered list to another, so
// Patch the cache so the card moves section immediately // every page of every list is stale — patching one of them in place would
queryClient.setQueryData<Reservation[]>(RESERVATIONS_KEY, (reservations) => // leave the card in the section it just left.
reservations?.map((r) => (r.id === id ? { ...r, status } : r)), onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }),
)
// "My reservations" holds its own list, and a status change moves a card
// there too
queryClient.invalidateQueries({ queryKey: MY_RESERVATIONS_KEY })
},
}) })
} }
/** /**
* The public availability calendar: the approved and ongoing reservations, * The public availability calendar: the approved and ongoing reservations over
* without any personal field. No session needed. * the week being drawn, without any personal field. No session needed.
*/ */
export function useCalendarReservations() { export function useCalendarReservations(window: MaybeRefOrGetter<Window> = () => ({})) {
const params = computed(() => {
const { from, to } = toValue(window)
return { from: from?.toISOString(), to: to?.toISOString() }
})
return useQuery({ return useQuery({
queryKey: CALENDAR_KEY, queryKey: computed(() => [...CALENDAR_KEY, params.value]),
staleTime: 30 * 1000, staleTime: 30 * 1000,
placeholderData: keepPreviousData,
queryFn: async () => { queryFn: async () => {
const { data, response } = await getClient().GET('/api/reservations/calendar') const { data, response } = await getClient().GET('/api/reservations/calendar', {
params: { query: params.value },
})
if (response.status !== HttpStatus.OK) { if (response.status !== HttpStatus.OK) {
throw new Error(`Unexpected status code received: ${response.status}`) throw new Error(`Unexpected status code received: ${response.status}`)
} }
@ -72,20 +118,27 @@ export function useCalendarReservations() {
} }
/** /**
* Everything the session user is part of: what they filed, what they were added * One page of what the session user is part of: what they filed, what they were
* to, and what lists their address among the Linka Go accounts — which is how a * added to, and what lists their address among the Linka Go accounts — which is
* reservation reaches somebody who was named before they ever logged in. * how a reservation reaches somebody who was named before they ever logged in.
*
* Same filters as the admin listing; who "mine" means is the session's business,
* not a parameter.
*/ */
export function useMyReservations() { export function useMyReservations(filters: MaybeRefOrGetter<ReservationFilters> = () => ({})) {
const params = computed(() => toParams(toValue(filters)))
return useQuery({ return useQuery({
queryKey: MY_RESERVATIONS_KEY, queryKey: computed(() => [...MY_RESERVATIONS_KEY, params.value]),
staleTime: 30 * 1000, staleTime: 30 * 1000,
placeholderData: keepPreviousData,
queryFn: async () => { queryFn: async () => {
const { data, response } = await getClient().GET('/api/reservations/mine') const { data, response } = await getClient().GET('/api/reservations/mine', {
params: { query: params.value },
})
if (response.status !== HttpStatus.OK) { if (response.status !== HttpStatus.OK) {
throw new Error(`Unexpected status code received: ${response.status}`) throw new Error(`Unexpected status code received: ${response.status}`)
} }
return data ?? [] return data ?? { items: [], total: 0 }
}, },
}) })
} }

View file

@ -0,0 +1,19 @@
/**
* The week a calendar draws, shared by the component and by whoever fetches the
* reservations for it: the fetch is narrowed to the window on the backend, so
* both sides have to agree on where a week starts.
*/
/** Monday, at midnight local time */
export function startOfWeek(date: Date): Date {
const start = new Date(date)
start.setHours(0, 0, 0, 0)
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
return start
}
export function addDays(date: Date, days: number): Date {
const next = new Date(date)
next.setDate(next.getDate() + days)
return next
}

View file

@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* Administration: the fleet, the reservations, and the week planning. * Administration: the fleet, the reservations, and the week planning.
* The three sections share one fetch of the reservations. *
* Three narrow queries rather than one wide one: the pending requests, what is
* live or coming, and the week the calendar is showing. Each asks the backend
* for its own slice, so none of them grows with the archive.
*/ */
import { computed } from 'vue' import { computed, ref } from 'vue'
import { RefreshCw } from '@lucide/vue' import { RefreshCw } from '@lucide/vue'
import BikeCalendar from '@/components/reservation/BikeCalendar.vue' import BikeCalendar from '@/components/reservation/BikeCalendar.vue'
@ -13,21 +16,48 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useBikes } from '@/services/api/bikes' import { useBikes } from '@/services/api/bikes'
import { useReservations } from '@/services/api/reservations' import { useReservations } from '@/services/api/reservations'
import { addDays, startOfWeek } from '@/utils/week'
/** What is worth showing at once; past that, the archive dialog is the way in */
const SECTION_LIMIT = 200
const { data: bikes } = useBikes() const { data: bikes } = useBikes()
const {
data: reservations,
isPending,
isError,
isFetching,
refetch,
dataUpdatedAt,
} = useReservations()
const list = computed(() => reservations.value ?? []) const pending = useReservations(() => ({ status: ['requested'], limit: SECTION_LIMIT }))
const active = useReservations(() => ({
status: ['approved', 'ongoing'],
limit: SECTION_LIMIT,
}))
// The calendar draws one week, so one week is what is fetched for it — and the
// bound `weekStart` is what moves the window when the admin changes week.
const weekStart = ref(startOfWeek(new Date()))
const week = useReservations(() => ({
status: ['approved', 'ongoing'],
from: weekStart.value,
to: addDays(weekStart.value, 7),
limit: SECTION_LIMIT,
}))
const fleet = computed(() => bikes.value ?? []) const fleet = computed(() => bikes.value ?? [])
const pendingList = computed(() => pending.data.value?.items ?? [])
const activeList = computed(() => active.data.value?.items ?? [])
const weekList = computed(() => week.data.value?.items ?? [])
const isPending = computed(() => pending.isPending.value || active.isPending.value)
const isError = computed(() => pending.isError.value || active.isError.value)
const isFetching = computed(
() => pending.isFetching.value || active.isFetching.value || week.isFetching.value,
)
function refresh() {
pending.refetch()
active.refetch()
week.refetch()
}
const updatedAt = computed(() => const updatedAt = computed(() =>
dataUpdatedAt.value ? new Date(dataUpdatedAt.value).toLocaleTimeString() : '—', active.dataUpdatedAt.value ? new Date(active.dataUpdatedAt.value).toLocaleTimeString() : '—',
) )
</script> </script>
@ -39,7 +69,7 @@ const updatedAt = computed(() =>
<CardDescription>{{ $t('admin.intro') }}</CardDescription> <CardDescription>{{ $t('admin.intro') }}</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="flex flex-wrap items-center gap-3"> <CardContent class="flex flex-wrap items-center gap-3">
<Button size="sm" :disabled="isFetching" @click="refetch()"> <Button size="sm" :disabled="isFetching" @click="refresh()">
<RefreshCw class="size-4" :class="isFetching ? 'animate-spin' : ''" /> <RefreshCw class="size-4" :class="isFetching ? 'animate-spin' : ''" />
{{ $t('admin.refresh') }} {{ $t('admin.refresh') }}
</Button> </Button>
@ -49,16 +79,22 @@ const updatedAt = computed(() =>
</CardContent> </CardContent>
</Card> </Card>
<BikeFleet class="min-w-0" :reservations="list" /> <BikeFleet class="min-w-0" :reservations="activeList" />
<ReservationAdmin <ReservationAdmin
class="min-w-0" class="min-w-0"
:reservations="list" :pending="pendingList"
:active="activeList"
:bikes="fleet" :bikes="fleet"
:is-pending="isPending" :is-pending="isPending"
:is-error="isError" :is-error="isError"
/> />
<BikeCalendar class="min-w-0" :reservations="list" :bikes="fleet" /> <BikeCalendar
v-model:week-start="weekStart"
class="min-w-0"
:reservations="weekList"
:bikes="fleet"
/>
</div> </div>
</template> </template>

View file

@ -4,12 +4,22 @@
* endpoint that carries no personal field. Anybody can look up when the bikes * endpoint that carries no personal field. Anybody can look up when the bikes
* are taken without being signed in. * are taken without being signed in.
*/ */
import { ref } from 'vue'
import BikeCalendar from '@/components/reservation/BikeCalendar.vue' import BikeCalendar from '@/components/reservation/BikeCalendar.vue'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useBikes } from '@/services/api/bikes' import { useBikes } from '@/services/api/bikes'
import { useCalendarReservations } from '@/services/api/reservations' import { useCalendarReservations } from '@/services/api/reservations'
import { addDays, startOfWeek } from '@/utils/week'
const { data: reservations, isPending, isError } = useCalendarReservations() // The week on screen is the week fetched: changing week refetches that one
// rather than shipping every reservation to the browser.
const weekStart = ref(startOfWeek(new Date()))
const {
data: reservations,
isPending,
isError,
} = useCalendarReservations(() => ({ from: weekStart.value, to: addDays(weekStart.value, 7) }))
const { data: bikes, isPending: bikesPending } = useBikes() const { data: bikes, isPending: bikesPending } = useBikes()
</script> </script>
@ -24,6 +34,11 @@ const { data: bikes, isPending: bikesPending } = useBikes()
{{ $t('calendar.load-error') }} {{ $t('calendar.load-error') }}
</p> </p>
<BikeCalendar v-else :reservations="reservations ?? []" :bikes="bikes ?? []" /> <BikeCalendar
v-else
v-model:week-start="weekStart"
:reservations="reservations ?? []"
:bikes="bikes ?? []"
/>
</div> </div>
</template> </template>

View file

@ -6,8 +6,11 @@
* What can still be changed depends on the status, and the backend applies the * What can still be changed depends on the status, and the backend applies the
* same rule: a request is fully editable, an approved or ongoing reservation * same rule: a request is fully editable, an approved or ongoing reservation
* only accepts a change of Linka Go accounts, and a final one is read-only. * only accepts a change of Linka Go accounts, and a final one is read-only.
*
* Three queries rather than one: the two live sections are short by nature, and
* the history — the part that keeps growing — is paged by the backend.
*/ */
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import ReservationDetails from '@/components/reservation/ReservationDetails.vue' import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
import ReservationEditDialog from '@/components/reservation/ReservationEditDialog.vue' import ReservationEditDialog from '@/components/reservation/ReservationEditDialog.vue'
@ -20,7 +23,24 @@ import { useBikes } from '@/services/api/bikes'
import { useMyReservations } from '@/services/api/reservations' import { useMyReservations } from '@/services/api/reservations'
import { unitLabel, type Reservation, type ReservationStatus } from '@/utils/types' import { unitLabel, type Reservation, type ReservationStatus } from '@/utils/types'
const { data: reservations, isPending, isError } = useMyReservations() /** How many past reservations one page of the history holds */
const PAGE_SIZE = 5
/** What is worth showing at once in the two live sections */
const SECTION_LIMIT = 50
const requested = useMyReservations(() => ({ status: ['requested'], limit: SECTION_LIMIT }))
const active = useMyReservations(() => ({
status: ['approved', 'ongoing'],
limit: SECTION_LIMIT,
}))
const pastPage = ref(0)
const past = useMyReservations(() => ({
status: ['refused', 'cancelled', 'archived'],
limit: PAGE_SIZE,
offset: pastPage.value * PAGE_SIZE,
}))
const { data: bikes } = useBikes() const { data: bikes } = useBikes()
const BADGE_CLASS: Record<ReservationStatus, string> = { const BADGE_CLASS: Record<ReservationStatus, string> = {
@ -32,25 +52,33 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
archived: 'bg-muted text-muted-foreground', archived: 'bg-muted text-muted-foreground',
} }
const SECTIONS = [ const sections = computed(() => [
{ key: 'requested', statuses: ['requested'] }, { key: 'requested', reservations: requested.data.value?.items ?? [] },
{ key: 'active', statuses: ['approved', 'ongoing'] }, { key: 'active', reservations: active.data.value?.items ?? [] },
{ key: 'past', statuses: ['refused', 'cancelled', 'archived'] }, { key: 'past', reservations: past.data.value?.items ?? [] },
] as const ])
const sections = computed(() => const isPending = computed(
SECTIONS.map((section) => ({ () => requested.isPending.value || active.isPending.value || past.isPending.value,
key: section.key,
reservations: (reservations.value ?? []).filter((r) =>
(section.statuses as readonly string[]).includes(r.status),
),
})),
) )
const isError = computed(
() => requested.isError.value || active.isError.value || past.isError.value,
)
const empty = computed(
() => !pastPage.value && sections.value.every((section) => !section.reservations.length),
)
const pastTotal = computed(() => past.data.value?.total ?? 0)
const pastPages = computed(() => Math.max(1, Math.ceil(pastTotal.value / PAGE_SIZE)))
// A page that no longer exists — the last one emptied by a status change, say
watch(pastPages, (pages) => {
if (pastPage.value >= pages) pastPage.value = pages - 1
})
/** Which reservation the edit dialog is on, by id */ /** Which reservation the edit dialog is on, by id */
const editing = ref<number | null>(null) const editing = ref<number | null>(null)
const editingReservation = computed(() => const editingReservation = computed(() =>
(reservations.value ?? []).find((r) => r.id === editing.value), sections.value.flatMap((section) => section.reservations).find((r) => r.id === editing.value),
) )
const dialogOpen = computed({ const dialogOpen = computed({
get: () => editing.value !== null, get: () => editing.value !== null,
@ -85,7 +113,7 @@ function editableEmails(reservation: Reservation) {
{{ $t('my-reservations.load-error') }} {{ $t('my-reservations.load-error') }}
</p> </p>
<p v-else-if="!reservations?.length" class="text-muted-foreground text-sm"> <p v-else-if="empty" class="text-muted-foreground text-sm">
{{ $t('my-reservations.empty') }} {{ $t('my-reservations.empty') }}
</p> </p>
@ -94,7 +122,7 @@ function editableEmails(reservation: Reservation) {
<h3 class="text-base font-semibold"> <h3 class="text-base font-semibold">
{{ $t(`my-reservations.${section.key}`) }} {{ $t(`my-reservations.${section.key}`) }}
<span class="text-muted-foreground font-normal"> <span class="text-muted-foreground font-normal">
({{ section.reservations.length }}) ({{ section.key === 'past' ? pastTotal : section.reservations.length }})
</span> </span>
</h3> </h3>
@ -113,9 +141,7 @@ function editableEmails(reservation: Reservation) {
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]"> <Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
{{ $t(`reservation.status.${reservation.status}`) }} {{ $t(`reservation.status.${reservation.status}`) }}
</Badge> </Badge>
<span class="text-muted-foreground text-sm"> <span class="text-sm font-semibold">{{ unitLabel(reservation.unit) }}</span>
{{ unitLabel(reservation.unit) }}
</span>
</div> </div>
<Button <Button
@ -133,7 +159,32 @@ function editableEmails(reservation: Reservation) {
</Button> </Button>
</div> </div>
<ReservationDetails class="mt-3" :reservation="reservation" :bikes="bikes ?? []" /> <ReservationDetails
class="mt-3 border-t pt-3"
:reservation="reservation"
:bikes="bikes ?? []"
/>
</div>
<!-- The history is the one section that keeps growing -->
<div
v-if="section.key === 'past' && pastPages > 1"
class="flex items-center justify-between gap-2"
>
<Button variant="outline" size="sm" :disabled="pastPage === 0" @click="pastPage -= 1">
{{ $t('pagination.previous') }}
</Button>
<span class="text-muted-foreground text-sm">
{{ $t('pagination.page', { page: pastPage + 1, pages: pastPages }) }}
</span>
<Button
variant="outline"
size="sm"
:disabled="pastPage + 1 >= pastPages"
@click="pastPage += 1"
>
{{ $t('pagination.next') }}
</Button>
</div> </div>
</section> </section>
</template> </template>

View file

@ -13,7 +13,11 @@ use aide::{
}, },
transform::TransformOperation, transform::TransformOperation,
}; };
use axum::{Json, extract::Path, http::StatusCode}; use axum::{
Json,
extract::{Path, Query},
http::StatusCode,
};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
@ -28,8 +32,8 @@ use crate::{
models::{ models::{
bike::BikeId, bike::BikeId,
reservation::{ reservation::{
CalendarReservation, NewReservation, Reservation, ReservationEdit, CalendarReservation, NewReservation, Reservation, ReservationEdit, ReservationPage,
ReservationStatus, ReservationQuery, ReservationStatus,
}, },
}, },
}, },
@ -58,19 +62,73 @@ pub fn routes() -> ApiRouter {
.api_route("/{id}/status", put_with(set_status, set_status_docs)) .api_route("/{id}/status", put_with(set_status, set_status_docs))
} }
/// The listing filters, the way a query string carries them.
///
/// Everything is optional and everything narrows, so the same route serves the
/// three sections of the admin page and the archive browser. The alternative —
/// handing the whole table to the browser and filtering it there — stops
/// working the day the archive is a few thousand rows long.
#[derive(Debug, Deserialize, JsonSchema)]
struct ReservationSearchParams {
/// Comma-separated statuses (`requested,approved`); left out means any
status: Option<String>,
/// Free text: the unit, the telegram handle, the people, the Linka Go
/// addresses, the reason, or the number of a reservation
q: Option<String>,
/// Keeps only what overlaps the window
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
limit: Option<i64>,
offset: Option<i64>,
}
impl TryFrom<ReservationSearchParams> for ReservationQuery {
type Error = String;
fn try_from(params: ReservationSearchParams) -> Result<Self, Self::Error> {
let statuses = params
.status
.as_deref()
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|status| !status.is_empty())
.map(|status| status.parse::<ReservationStatus>())
.collect::<Result<Vec<_>, _>>()
.map_err(|err| err.to_string())?;
Ok(ReservationQuery {
statuses,
// Set by the handler that needs it, from the session
involving: None,
search: params.q,
from: params.from,
to: params.to,
limit: params.limit.unwrap_or(ReservationQuery::DEFAULT_LIMIT),
offset: params.offset.unwrap_or(0),
})
}
}
#[axum::debug_handler] #[axum::debug_handler]
async fn get_reservations( async fn get_reservations(
ac: AppController, ac: AppController,
) -> Result<Json<Vec<Reservation>>, (StatusCode, String)> { Query(params): Query<ReservationSearchParams>,
match admin(ac)?.get_reservations().await { ) -> Result<Json<ReservationPage>, (StatusCode, String)> {
Ok(reservations) => Ok(Json(reservations)), let query = ReservationQuery::try_from(params).map_err(|err| (StatusCode::BAD_REQUEST, err))?;
match admin(ac)?.search_reservations(query).await {
Ok(page) => Ok(Json(page)),
Err(err) => unexpected_error("get_reservations", err), Err(err) => unexpected_error("get_reservations", err),
} }
} }
fn get_reservations_docs(op: TransformOperation) -> TransformOperation { fn get_reservations_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations") op.tag("Reservations")
.summary("Get every reservation") .summary("Get a page of the reservations, filtered")
.description(
"The filtering, the text search and the paging all happen in the database: no caller ever receives the whole table.",
)
.response_with::<400, (), _>(desc("A status in the filter is not a known one"))
.response_with::<403, (), _>(admin_desc) .response_with::<403, (), _>(admin_desc)
} }
@ -117,11 +175,19 @@ fn create_reservation_docs(op: TransformOperation) -> TransformOperation {
/// The availability calendar, open to everybody: when the bikes are taken and by /// The availability calendar, open to everybody: when the bikes are taken and by
/// which association, with nothing personal attached. /// which association, with nothing personal attached.
/// The window a calendar draws, so only that window is read.
#[derive(Debug, Deserialize, JsonSchema)]
struct CalendarWindow {
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
}
#[axum::debug_handler] #[axum::debug_handler]
async fn get_calendar_reservations( async fn get_calendar_reservations(
aac: AnonAppController, aac: AnonAppController,
Query(window): Query<CalendarWindow>,
) -> Result<Json<Vec<CalendarReservation>>, (StatusCode, String)> { ) -> Result<Json<Vec<CalendarReservation>>, (StatusCode, String)> {
match aac.get_calendar_reservations().await { match aac.get_calendar_reservations(window.from, window.to).await {
Ok(reservations) => Ok(Json(reservations)), Ok(reservations) => Ok(Json(reservations)),
Err(err) => unexpected_error("get_calendar_reservations", err), Err(err) => unexpected_error("get_calendar_reservations", err),
} }
@ -131,7 +197,8 @@ fn get_calendar_reservations_docs(op: TransformOperation) -> TransformOperation
op.tag("Reservations") op.tag("Reservations")
.summary("Get the approved and ongoing reservations, for the public calendar") .summary("Get the approved and ongoing reservations, for the public calendar")
.description( .description(
"No session needed, and no personal field travels: the telegram handle, \ "Give `from` and `to` to read one week rather than the whole table. No session \
needed, and no personal field travels: the telegram handle, \
the people, the Linka Go addresses and the reason are left out.", the people, the Linka Go addresses and the reason are left out.",
) )
} }
@ -141,21 +208,25 @@ fn get_calendar_reservations_docs(op: TransformOperation) -> TransformOperation
#[axum::debug_handler] #[axum::debug_handler]
async fn get_my_reservations( async fn get_my_reservations(
ac: AppController, ac: AppController,
) -> Result<Json<Vec<Reservation>>, (StatusCode, String)> { Query(params): Query<ReservationSearchParams>,
match ac.get_my_reservations().await { ) -> Result<Json<ReservationPage>, (StatusCode, String)> {
Ok(reservations) => Ok(Json(reservations)), let query = ReservationQuery::try_from(params).map_err(|err| (StatusCode::BAD_REQUEST, err))?;
match ac.get_my_reservations(query).await {
Ok(page) => Ok(Json(page)),
Err(err) => unexpected_error("get_my_reservations", err), Err(err) => unexpected_error("get_my_reservations", err),
} }
} }
fn get_my_reservations_docs(op: TransformOperation) -> TransformOperation { fn get_my_reservations_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations") op.tag("Reservations")
.summary("Get the reservations the session user is part of") .summary("Get a page of the reservations the session user is part of")
.description( .description(
"An address listed among the Linka Go accounts is enough, which is how \ "Takes the same filters as the listing, and answers the same page. An \
somebody added before they ever logged in finds the reservation waiting \ address listed among the Linka Go accounts is enough to be part of a \
for them.", reservation, which is how somebody added before they ever logged in \
finds the one waiting for them.",
) )
.response_with::<400, (), _>(desc("A status in the filter is not a known one"))
} }
/// Only what the admin page lets somebody change. The unit, the requester, the /// Only what the admin page lets somebody change. The unit, the requester, the

View file

@ -1,3 +1,4 @@
use chrono::{DateTime, Utc};
use thiserror::Error; use thiserror::Error;
use crate::core::{ use crate::core::{
@ -5,8 +6,8 @@ use crate::core::{
models::{ models::{
bike::BikeStatus, bike::BikeStatus,
reservation::{ reservation::{
CalendarReservation, NewReservation, NewReservationUnit, Reservation, ReservationEdit, CalendarReservation, Involvement, NewReservation, NewReservationUnit, Reservation,
ReservationId, ReservationStatus, ReservationEdit, ReservationId, ReservationPage, ReservationQuery, ReservationStatus,
}, },
unit::UnitId, unit::UnitId,
}, },
@ -15,8 +16,13 @@ use crate::core::{
/// Reading the reservations needs no session: the calendar is public. /// Reading the reservations needs no session: the calendar is public.
impl AnonAppController { impl AnonAppController {
pub async fn get_reservations(&self) -> Result<Vec<Reservation>, ControllerError> { /// One page of the reservations matching `query`. Reading the whole list is
self.db.get_reservations().await.map_err(Into::into) /// an admin action, which the API layer is what enforces.
pub async fn search_reservations(
&self,
query: ReservationQuery,
) -> Result<ReservationPage, ControllerError> {
self.db.search_reservations(query).await.map_err(Into::into)
} }
pub async fn get_unit_reservations( pub async fn get_unit_reservations(
@ -33,35 +39,50 @@ impl AnonAppController {
self.db.get_reservation(id).await.map_err(Into::into) self.db.get_reservation(id).await.map_err(Into::into)
} }
/// What the availability calendar shows: the reservations that actually hold /// What the availability calendar shows: the reservations that actually
/// a bike, stripped of everything personal. Reading it needs no session. /// hold a bike over the window asked for, stripped of everything personal.
/// Reading it needs no session.
///
/// The window is what keeps this cheap: a calendar draws one week, so one
/// week is what the database returns.
pub async fn get_calendar_reservations( pub async fn get_calendar_reservations(
&self, &self,
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
) -> Result<Vec<CalendarReservation>, ControllerError> { ) -> Result<Vec<CalendarReservation>, ControllerError> {
Ok(self let page = self
.db .db
.get_reservations() .search_reservations(ReservationQuery {
.await? statuses: vec![ReservationStatus::Approved, ReservationStatus::Ongoing],
.into_iter() from,
.filter(|reservation| { to,
matches!( limit: ReservationQuery::MAX_LIMIT,
reservation.status, ..Default::default()
ReservationStatus::Approved | ReservationStatus::Ongoing
)
}) })
.map(Into::into) .await?;
.collect()) Ok(page.items.into_iter().map(Into::into).collect())
} }
} }
/// Filing a request is done in one's own name: the requester is the session /// Filing a request is done in one's own name: the requester is the session
/// user, never something the client gets to choose. /// user, never something the client gets to choose.
impl AppController { impl AppController {
/// Everything the session user is part of, whichever way. /// One page of what the session user is part of, whichever way. The
pub async fn get_my_reservations(&self) -> Result<Vec<Reservation>, ControllerError> { /// involvement is taken from the session, never from the query: this is the
/// route on which somebody could otherwise read another person's list.
pub async fn get_my_reservations(
&self,
query: ReservationQuery,
) -> Result<ReservationPage, ControllerError> {
let user = self.user(); let user = self.user();
self.db self.db
.get_involved_reservations(user.id, &user.email) .search_reservations(ReservationQuery {
involving: Some(Involvement {
user: user.id,
email: user.email.clone(),
}),
..query
})
.await .await
.map_err(Into::into) .map_err(Into::into)
} }

View file

@ -37,12 +37,48 @@ impl ReservationStatus {
) )
} }
/// The spelling shared by the JSON, the Postgres enum and the query
/// parameters — one name, so no mapping table has to be kept in step.
pub fn as_str(self) -> &'static str {
use ReservationStatus::*;
match self {
Requested => "requested",
Refused => "refused",
Approved => "approved",
Cancelled => "cancelled",
Ongoing => "ongoing",
Archived => "archived",
}
}
pub fn is_final(self) -> bool { pub fn is_final(self) -> bool {
use ReservationStatus::*; use ReservationStatus::*;
matches!(self, Refused | Cancelled | Archived) matches!(self, Refused | Cancelled | Archived)
} }
} }
impl std::str::FromStr for ReservationStatus {
type Err = UnknownStatus;
fn from_str(value: &str) -> Result<Self, Self::Err> {
use ReservationStatus::*;
match value {
"requested" => Ok(Requested),
"refused" => Ok(Refused),
"approved" => Ok(Approved),
"cancelled" => Ok(Cancelled),
"ongoing" => Ok(Ongoing),
"archived" => Ok(Archived),
other => Err(UnknownStatus(other.to_owned())),
}
}
}
/// A status spelling that is not one of the six
#[derive(Debug, thiserror::Error)]
#[error("unknown reservation status: {0}")]
pub struct UnknownStatus(pub String);
/// The unit a reservation is filed for. /// The unit a reservation is filed for.
/// ///
/// Either one we know — a Whiskey group, with its row — or a plain name the /// Either one we know — a Whiskey group, with its row — or a plain name the
@ -148,6 +184,83 @@ impl From<Reservation> for CalendarReservation {
} }
} }
/// The three ways of being part of a reservation: having filed it, being named
/// on it, or having one's address among the Linka Go accounts.
///
/// The address is what binds a reservation to somebody who had never logged in
/// when it was filed — nothing is written at login time, the match is made when
/// the list is read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Involvement {
pub user: UserId,
pub email: String,
}
/// What a listing asks the database for.
///
/// Everything here narrows: an empty `statuses` means any status, `search` left
/// out means no text filter. It exists so that a page showing a handful of rows
/// never receives the whole table — the archive alone will outgrow that — and so
/// that the narrowing happens once, in the index, instead of in every browser.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReservationQuery {
pub statuses: Vec<ReservationStatus>,
/// Keeps only the reservations one person is part of. `None` is "anybody's",
/// which is what the admin listing asks for.
pub involving: Option<Involvement>,
/// Free text, matched against the unit, the telegram handle, the people on
/// the reservation, the Linka Go addresses, the reason and the id
pub search: Option<String>,
/// Keeps only the reservations overlapping the window
pub from: Option<DateTime<Utc>>,
pub to: Option<DateTime<Utc>>,
pub limit: i64,
pub offset: i64,
}
impl ReservationQuery {
pub const DEFAULT_LIMIT: i64 = 20;
/// A page nobody can read past. It caps what one request may cost, whatever
/// the client asks for.
pub const MAX_LIMIT: i64 = 200;
/// The same query with the parts a client controls brought back into range:
/// a limit outside `1..=MAX_LIMIT` is clamped, a negative offset is zero,
/// and a blank search is no search at all.
pub fn sanitised(mut self) -> Self {
self.limit = self.limit.clamp(1, Self::MAX_LIMIT);
self.offset = self.offset.max(0);
self.search = self
.search
.map(|search| search.trim().to_owned())
.filter(|search| !search.is_empty());
self
}
}
impl Default for ReservationQuery {
fn default() -> Self {
ReservationQuery {
statuses: Vec::new(),
involving: None,
search: None,
from: None,
to: None,
limit: Self::DEFAULT_LIMIT,
offset: 0,
}
}
}
/// One page of a listing, with the size of the whole so the caller can page
/// through it without asking twice.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
pub struct ReservationPage {
pub items: Vec<Reservation>,
/// How many reservations match the query, ignoring `limit` and `offset`
pub total: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct NewReservation { pub struct NewReservation {
pub unit: NewReservationUnit, pub unit: NewReservationUnit,
@ -218,6 +331,18 @@ impl ReservationEdit {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn every_status_survives_a_round_trip_through_its_name() {
use ReservationStatus::*;
for status in [Requested, Refused, Approved, Cancelled, Ongoing, Archived] {
assert_eq!(
status.as_str().parse::<ReservationStatus>().unwrap(),
status
);
}
assert!("started".parse::<ReservationStatus>().is_err());
}
#[test] #[test]
fn telegram_handles_match_the_database_constraint() { fn telegram_handles_match_the_database_constraint() {
assert!(telegram_valid("@milan_hyenne")); assert!(telegram_valid("@milan_hyenne"));

View file

@ -3,7 +3,8 @@ use async_trait::async_trait;
use crate::core::{ use crate::core::{
models::{ models::{
reservation::{ reservation::{
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus, NewReservation, Reservation, ReservationEdit, ReservationId, ReservationPage,
ReservationQuery, ReservationStatus,
}, },
unit::UnitId, unit::UnitId,
user::UserId, user::UserId,
@ -13,23 +14,22 @@ use crate::core::{
#[async_trait] #[async_trait]
pub trait ReservationsRepository { pub trait ReservationsRepository {
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError>; /// One page of the reservations matching `query`, most recent first, with
/// the size of the whole match.
///
/// There is deliberately no "give me every reservation": the table only
/// grows, and the narrowing belongs here, where the indexes are, rather
/// than in a browser that would have to download the archive to hide it.
async fn search_reservations(
&self,
query: ReservationQuery,
) -> Result<ReservationPage, RepositoryError>;
async fn get_unit_reservations( async fn get_unit_reservations(
&self, &self,
unit: UnitId, unit: UnitId,
) -> Result<Vec<Reservation>, RepositoryError>; ) -> Result<Vec<Reservation>, RepositoryError>;
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError>; async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError>;
/// Everything the user is part of: what they filed, what they were added
/// to, and what lists their address among the Linka Go accounts. The email
/// is what binds a reservation to somebody who had never logged in when it
/// was filed.
async fn get_involved_reservations(
&self,
user: UserId,
email: &str,
) -> Result<Vec<Reservation>, RepositoryError>;
/// Always stored as `Requested`: the state machine starts here. The /// Always stored as `Requested`: the state machine starts here. The
/// requester comes from the session, not from the request body. /// requester comes from the session, not from the request body.
async fn create_reservation( async fn create_reservation(

View file

@ -13,7 +13,7 @@ use crate::{
models::{ models::{
reservation::{ reservation::{
NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId, NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
ReservationStatus, ReservationUnit, ReservationPage, ReservationQuery, ReservationStatus, ReservationUnit,
}, },
unit::{Unit, UnitId}, unit::{Unit, UnitId},
user::UserId, user::UserId,
@ -107,6 +107,46 @@ impl TryFrom<ReservationDB> for Reservation {
} }
} }
/// The page query carries one extra column — how many rows the filter matches
/// in all, from a window function — so it needs its own row type. Everything
/// else is the shared one, which the conversion below hands back.
struct ReservationPageRowDB {
pub id: i32,
pub unit_id: Option<i32>,
pub unit_name: Option<String>,
pub unit_label: Option<String>,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub requester_id: i32,
pub telegram: String,
pub description: String,
pub status: ReservationStatusDB,
pub linka_emails: Vec<String>,
pub users: Value,
pub bikes: Vec<i32>,
pub total: i64,
}
impl From<ReservationPageRowDB> for ReservationDB {
fn from(value: ReservationPageRowDB) -> Self {
ReservationDB {
id: value.id,
unit_id: value.unit_id,
unit_name: value.unit_name,
unit_label: value.unit_label,
start_time: value.start_time,
end_time: value.end_time,
requester_id: value.requester_id,
telegram: value.telegram,
description: value.description,
status: value.status,
linka_emails: value.linka_emails,
users: value.users,
bikes: value.bikes,
}
}
}
impl SqlxDatabase { impl SqlxDatabase {
async fn set_reservation_links( async fn set_reservation_links(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
@ -170,11 +210,37 @@ fn trim_emails(emails: &[String]) -> Vec<String> {
.collect() .collect()
} }
/// Wraps the search text for `ILIKE`, escaping what the pattern language would
/// otherwise read as a wildcard — an address contains `_` often enough that
/// leaving it alone would quietly widen the search.
fn like_pattern(search: &str) -> String {
let escaped = search
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
format!("%{escaped}%")
}
#[async_trait] #[async_trait]
impl ReservationsRepository for SqlxDatabase { impl ReservationsRepository for SqlxDatabase {
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError> { async fn search_reservations(
Ok(query_as!( &self,
ReservationDB, query: ReservationQuery,
) -> Result<ReservationPage, RepositoryError> {
let query = query.sanitised();
// Every filter is "the parameter is NULL, or it matches": one prepared
// statement serves every combination the admin page can ask for.
let statuses = (!query.statuses.is_empty()).then(|| {
query
.statuses
.iter()
.map(|status| status.as_str().to_owned())
.collect::<Vec<_>>()
});
let pattern = query.search.as_deref().map(like_pattern);
let rows = query_as!(
ReservationPageRowDB,
r#"SELECT r#"SELECT
r.id, r.id,
r.unit_id, r.unit_id,
@ -203,16 +269,69 @@ impl ReservationsRepository for SqlxDatabase {
ARRAY( ARRAY(
SELECT bike_id FROM reservations_bikes SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!" ) AS "bikes!",
-- The size of the whole match, alongside the page itself: the
-- caller can show "42 results" without a second round trip
COUNT(*) OVER () AS "total!"
FROM reservations r FROM reservations r
LEFT JOIN units un ON un.id = r.unit_id LEFT JOIN units un ON un.id = r.unit_id
ORDER BY r.start_time DESC"# WHERE ($1::text[] IS NULL OR r.status::text = ANY($1))
-- The three ways of being part of a reservation. The last one is
-- what lets somebody added by address see it the first time they
-- log in, without anything having to be written at login time.
AND ($8::integer IS NULL
OR r.requester_id = $8
OR EXISTS (
SELECT 1 FROM reservations_users ru
WHERE ru.reservation_id = r.id AND ru.user_id = $8
)
OR EXISTS (
SELECT 1 FROM unnest(r.linka_emails) AS listed
WHERE lower(listed) = lower($9)
))
-- Overlap with the window, not containment: a reservation running
-- across the displayed week belongs to it
AND ($2::timestamptz IS NULL OR r.end_time > $2)
AND ($3::timestamptz IS NULL OR r.start_time < $3)
AND ($4::text IS NULL
OR COALESCE(un."name", r.unit_label, '') ILIKE $4
OR r.telegram ILIKE $4
OR r."description" ILIKE $4
OR array_to_string(r.linka_emails, ' ') ILIKE $4
-- Typing the number of a reservation is how an admin looks
-- one up from a mail or a chat
OR r.id::text = $5
OR EXISTS (
SELECT 1 FROM reservations_users ru
JOIN users u ON u.id = ru.user_id
WHERE ru.reservation_id = r.id
AND u.firstname || ' ' || u."name" || ' ' || u.email ILIKE $4
))
ORDER BY r.start_time DESC, r.id DESC
LIMIT $6 OFFSET $7"#,
statuses.as_deref(),
query.from,
query.to,
pattern,
query.search,
query.limit,
query.offset,
query.involving.as_ref().map(|who| who.user),
query.involving.as_ref().map(|who| who.email.as_str()),
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await? .await?;
.into_iter()
.map(TryInto::try_into) // The window function only rides along on the rows; an empty page is
.collect::<Result<Vec<_>, _>>()?) // an empty match, or a page past the end of one.
let total = rows.first().map_or(0, |row| row.total);
Ok(ReservationPage {
items: rows
.into_iter()
.map(|row| ReservationDB::from(row).try_into())
.collect::<Result<Vec<_>, _>>()?,
total,
})
} }
async fn get_unit_reservations( async fn get_unit_reservations(
@ -263,67 +382,6 @@ impl ReservationsRepository for SqlxDatabase {
.collect::<Result<Vec<_>, _>>()?) .collect::<Result<Vec<_>, _>>()?)
} }
async fn get_involved_reservations(
&self,
user: UserId,
email: &str,
) -> Result<Vec<Reservation>, RepositoryError> {
Ok(query_as!(
ReservationDB,
r#"SELECT
r.id,
r.unit_id,
-- `?` forces the nullability sqlx cannot infer: `units.name` is
-- NOT NULL, but the LEFT JOIN makes it null for a free label
un."name" AS "unit_name?",
r.unit_label,
r.start_time,
r.end_time,
r.requester_id,
r.telegram,
r."description",
r.linka_emails,
r.status AS "status: ReservationStatusDB",
COALESCE((
SELECT json_agg(json_build_object(
'id', u.id,
'firstname', u.firstname,
'name', u."name",
'email', u.email
) ORDER BY u."name", u.firstname)
FROM reservations_users ru
JOIN users u ON u.id = ru.user_id
WHERE ru.reservation_id = r.id
), '[]'::json) AS "users!",
ARRAY(
SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
FROM reservations r
LEFT JOIN units un ON un.id = r.unit_id
-- Three ways to be part of a reservation. The last one is what
-- lets somebody added by address see it the first time they log in,
-- without anything having to be written at login time.
WHERE r.requester_id = $1
OR EXISTS (
SELECT 1 FROM reservations_users ru
WHERE ru.reservation_id = r.id AND ru.user_id = $1
)
OR EXISTS (
SELECT 1 FROM unnest(r.linka_emails) AS listed
WHERE lower(listed) = lower($2)
)
ORDER BY r.start_time DESC"#,
user,
email
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>, _>>()?)
}
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> { async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> {
Ok(query_as!( Ok(query_as!(
ReservationDB, ReservationDB,
@ -484,3 +542,17 @@ impl ReservationsRepository for SqlxDatabase {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn like_patterns_escape_the_wildcards() {
assert_eq!(like_pattern("milan"), "%milan%");
// Underscores are ordinary in an address; they must not match anything
assert_eq!(like_pattern("a_b"), "%a\\_b%");
assert_eq!(like_pattern("100%"), "%100\\%%");
assert_eq!(like_pattern("a\\b"), "%a\\\\b%");
}
}