wip
This commit is contained in:
parent
34fbc2f3de
commit
c6460a0a77
20 changed files with 1099 additions and 316 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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="showArchived = !showArchived">
|
<Button variant="outline" size="sm" @click="archiveOpen = true">
|
||||||
{{
|
<Archive class="size-4" />
|
||||||
showArchived
|
{{ $t('admin.reservations.archive.action') }}
|
||||||
? $t('admin.reservations.hide-archived')
|
|
||||||
: $t('admin.reservations.show-archived', { n: archived.length })
|
|
||||||
}}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
|
|
|
||||||
140
frontend/src/components/admin/ReservationArchiveDialog.vue
Normal file
140
frontend/src/components/admin/ReservationArchiveDialog.vue
Normal 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>
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|
|
||||||
123
frontend/src/lib/api.d.ts
vendored
123
frontend/src/lib/api.d.ts
vendored
|
|
@ -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'
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
frontend/src/utils/week.ts
Normal file
19
frontend/src/utils/week.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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?;
|
||||||
|
|
||||||
|
// The window function only rides along on the rows; an empty page is
|
||||||
|
// 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()
|
.into_iter()
|
||||||
.map(TryInto::try_into)
|
.map(|row| ReservationDB::from(row).try_into())
|
||||||
.collect::<Result<Vec<_>, _>>()?)
|
.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%");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue