feat: public calendar
This commit is contained in:
parent
46c5edf5bd
commit
34fbc2f3de
15 changed files with 1135 additions and 938 deletions
12
.cargo/config.toml
Normal file
12
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Development overrides for the app configuration. `cargo run` and `cargo test`
|
||||||
|
# inject these into the process environment; the `config` crate reads them last,
|
||||||
|
# so they win over `config.yml` without it having to be edited.
|
||||||
|
#
|
||||||
|
# The `cargagep` client exists on Hydra but has no matching redirect uri, so we
|
||||||
|
# borrow the one registered for `trust`. Drop this file once `cargagep` is
|
||||||
|
# properly registered and `config.yml` takes over again.
|
||||||
|
#
|
||||||
|
# `force = false`: a value given on the command line still takes precedence,
|
||||||
|
# APP__OIDC__CLIENT_ID=other cargo run
|
||||||
|
[env]
|
||||||
|
APP__OIDC__CLIENT_ID = { value = "trust", force = false }
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
/target
|
/target
|
||||||
config.yml
|
config.yml
|
||||||
config.yaml
|
config.yaml
|
||||||
/LEGACY
|
/LEGACY
|
||||||
|
summary.ai
|
||||||
|
|
@ -24,7 +24,9 @@ const setStatus = useSetReservationStatus()
|
||||||
/** Kept in step with the state machine drawn on the backend enum */
|
/** Kept in step with the state machine drawn on the backend enum */
|
||||||
const TRANSITIONS: Record<ReservationStatus, ReservationStatus[]> = {
|
const TRANSITIONS: Record<ReservationStatus, ReservationStatus[]> = {
|
||||||
requested: ['approved', 'refused'],
|
requested: ['approved', 'refused'],
|
||||||
approved: ['ongoing', 'cancelled'],
|
// No "start" here on purpose: a reservation becomes `ongoing` on its own, not
|
||||||
|
// by an admin pressing a button. The backend still allows the transition.
|
||||||
|
approved: ['cancelled'],
|
||||||
ongoing: ['archived', 'cancelled'],
|
ongoing: ['archived', 'cancelled'],
|
||||||
refused: [],
|
refused: [],
|
||||||
cancelled: [],
|
cancelled: [],
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,19 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} 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 Reservation } from '@/utils/types'
|
import { unitLabel, type Bike, type CalendarReservation } from '@/utils/types'
|
||||||
|
|
||||||
const props = defineProps<{ reservations: Reservation[]; bikes: Bike[] }>()
|
// Takes the narrow shape the public calendar receives; a full `Reservation`
|
||||||
|
// satisfies it too, so the admin page keeps passing its own list unchanged.
|
||||||
|
const props = defineProps<{ reservations: CalendarReservation[]; bikes: Bike[] }>()
|
||||||
|
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
const FIRST_HOUR = 7
|
// The whole day, 00:00 to 23:59. 24 rows at 44px would make the card very tall,
|
||||||
const LAST_HOUR = 20
|
// hence the shorter row.
|
||||||
const HOUR_HEIGHT = 44
|
const FIRST_HOUR = 0
|
||||||
|
const LAST_HOUR = 24
|
||||||
|
const HOUR_HEIGHT = 32
|
||||||
|
|
||||||
const weekStart = ref(startOfWeek(new Date()))
|
const weekStart = ref(startOfWeek(new Date()))
|
||||||
const selectedBike = ref<string>('all')
|
const selectedBike = ref<string>('all')
|
||||||
|
|
@ -140,18 +144,18 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
<CardHeader class="gap-3">
|
<CardHeader class="gap-3">
|
||||||
<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('admin.calendar.title') }}</CardTitle>
|
<CardTitle>{{ $t('calendar.title') }}</CardTitle>
|
||||||
<CardDescription>{{ $t('admin.calendar.intro') }}</CardDescription>
|
<CardDescription>{{ $t('calendar.intro') }}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-muted-foreground text-sm">{{ $t('admin.calendar.bike') }}</span>
|
<span class="text-muted-foreground text-sm">{{ $t('calendar.bike') }}</span>
|
||||||
<Select v-model="selectedBike">
|
<Select v-model="selectedBike">
|
||||||
<SelectTrigger class="w-32">
|
<SelectTrigger class="w-32">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{{ $t('admin.calendar.all-bikes') }}</SelectItem>
|
<SelectItem value="all">{{ $t('calendar.all-bikes') }}</SelectItem>
|
||||||
<SelectItem v-for="bike in bikes" :key="bike.id" :value="String(bike.id)">
|
<SelectItem v-for="bike in bikes" :key="bike.id" :value="String(bike.id)">
|
||||||
{{ bike.name }}
|
{{ bike.name }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -163,13 +167,13 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Button variant="outline" size="sm" @click="shiftWeek(-1)">
|
<Button variant="outline" size="sm" @click="shiftWeek(-1)">
|
||||||
<ChevronLeft class="size-4" />
|
<ChevronLeft class="size-4" />
|
||||||
{{ $t('admin.calendar.previous') }}
|
{{ $t('calendar.previous') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" @click="weekStart = startOfWeek(new Date())">
|
<Button variant="secondary" size="sm" @click="weekStart = startOfWeek(new Date())">
|
||||||
{{ $t('admin.calendar.today') }}
|
{{ $t('calendar.today') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" @click="shiftWeek(1)">
|
<Button variant="outline" size="sm" @click="shiftWeek(1)">
|
||||||
{{ $t('admin.calendar.next') }}
|
{{ $t('calendar.next') }}
|
||||||
<ChevronRight class="size-4" />
|
<ChevronRight class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span class="text-muted-foreground text-sm">{{ range }}</span>
|
<span class="text-muted-foreground text-sm">{{ range }}</span>
|
||||||
|
|
@ -178,7 +182,7 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p v-if="!lanes.length" class="text-muted-foreground text-sm">
|
<p v-if="!lanes.length" class="text-muted-foreground text-sm">
|
||||||
{{ $t('admin.calendar.no-bike') }}
|
{{ $t('calendar.no-bike') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- The grid is wide: it scrolls on its own rather than the page -->
|
<!-- The grid is wide: it scrolls on its own rather than the page -->
|
||||||
|
|
@ -186,7 +190,7 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
<div class="min-w-[52rem]">
|
<div class="min-w-[52rem]">
|
||||||
<div class="flex border-b">
|
<div class="flex border-b">
|
||||||
<div class="text-muted-foreground w-12 shrink-0 py-2 text-xs">
|
<div class="text-muted-foreground w-12 shrink-0 py-2 text-xs">
|
||||||
{{ $t('admin.calendar.hour') }}
|
{{ $t('calendar.hour') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="day in days"
|
v-for="day in days"
|
||||||
|
|
@ -241,8 +245,8 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
type="button"
|
type="button"
|
||||||
class="bg-primary hover:bg-primary/80 focus-visible:ring-ring absolute inset-x-0 rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
class="bg-primary hover:bg-primary/80 focus-visible:ring-ring absolute inset-x-0 rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||||
:style="{ top: `${block.top}px`, height: `${block.height}px` }"
|
:style="{ top: `${block.top}px`, height: `${block.height}px` }"
|
||||||
:title="$t('admin.calendar.open', { id: block.id })"
|
:title="$t('calendar.open', { id: block.id })"
|
||||||
:aria-label="$t('admin.calendar.open', { id: block.id })"
|
:aria-label="$t('calendar.open', { id: block.id })"
|
||||||
@click="opened = block.id"
|
@click="opened = block.id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -252,13 +256,13 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-muted-foreground mt-3 text-xs">{{ $t('admin.calendar.legend') }}</p>
|
<p class="text-muted-foreground mt-3 text-xs">{{ $t('calendar.legend') }}</p>
|
||||||
|
|
||||||
<Dialog v-model:open="dialogOpen">
|
<Dialog v-model:open="dialogOpen">
|
||||||
<DialogScrollContent v-if="openedReservation" class="sm:max-w-lg">
|
<DialogScrollContent v-if="openedReservation" class="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle class="flex flex-wrap items-center gap-2">
|
<DialogTitle class="flex flex-wrap items-center gap-2">
|
||||||
{{ $t('admin.calendar.reservation', { id: openedReservation.id }) }}
|
{{ $t('calendar.reservation', { id: openedReservation.id }) }}
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{{ $t(`reservation.status.${openedReservation.status}`) }}
|
{{ $t(`reservation.status.${openedReservation.status}`) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -6,13 +6,13 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import type { Bike, Reservation } 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'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
reservation: Reservation
|
reservation: ReservationLike
|
||||||
bikes: Bike[]
|
bikes: Bike[]
|
||||||
/** Fields the caller renders itself — the edit dialog omits what it edits */
|
/** Fields the caller renders itself — the edit dialog omits what it edits */
|
||||||
omit?: Field[]
|
omit?: Field[]
|
||||||
|
|
@ -55,17 +55,17 @@ function format(iso: string) {
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.bikes') }}</dt>
|
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.bikes') }}</dt>
|
||||||
<dd>{{ bikeNames }}</dd>
|
<dd>{{ bikeNames }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shows('telegram')" class="flex gap-2">
|
<div v-if="shows('telegram') && reservation.telegram" class="flex gap-2">
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.telegram') }}</dt>
|
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.telegram') }}</dt>
|
||||||
<dd class="truncate">{{ reservation.telegram }}</dd>
|
<dd class="truncate">{{ reservation.telegram }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shows('people')" class="flex gap-2">
|
<div v-if="shows('people') && reservation.users?.length" class="flex gap-2">
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.people') }}</dt>
|
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.people') }}</dt>
|
||||||
<dd class="min-w-0 break-words">
|
<dd class="min-w-0 break-words">
|
||||||
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
|
{{ 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">
|
<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>
|
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.linka') }}</dt>
|
||||||
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd>
|
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
1803
frontend/src/lib/api.d.ts
vendored
1803
frontend/src/lib/api.d.ts
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -107,8 +107,19 @@ 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.
|
||||||
calendar:
|
calendar:
|
||||||
title: Calendar
|
load-error: Unable to load the calendar.
|
||||||
todo: This page is not built yet.
|
title: Calendar per cargobike
|
||||||
|
intro: Approved and ongoing reservations are shown in the calendar.
|
||||||
|
bike: Cargobike
|
||||||
|
all-bikes: All
|
||||||
|
previous: Week
|
||||||
|
next: Week
|
||||||
|
today: Today
|
||||||
|
hour: H
|
||||||
|
no-bike: No cargobike to show.
|
||||||
|
legend: One bar per booked cargobike, split by day. Click one for the details.
|
||||||
|
open: 'Open reservation #{id}'
|
||||||
|
reservation: 'Reservation #{id}'
|
||||||
admin:
|
admin:
|
||||||
title: Reservation administration
|
title: Reservation administration
|
||||||
intro: Approve, refuse and follow the reservations, and see the planning of each cargobike.
|
intro: Approve, refuse and follow the reservations, and see the planning of each cargobike.
|
||||||
|
|
@ -144,16 +155,3 @@ admin:
|
||||||
cancelled: Cancel
|
cancelled: Cancel
|
||||||
ongoing: Start
|
ongoing: Start
|
||||||
archived: Archive
|
archived: Archive
|
||||||
calendar:
|
|
||||||
title: Calendar per cargobike
|
|
||||||
intro: Approved and ongoing reservations are shown in the calendar.
|
|
||||||
bike: Cargobike
|
|
||||||
all-bikes: All
|
|
||||||
previous: Week
|
|
||||||
next: Week
|
|
||||||
today: Today
|
|
||||||
hour: H
|
|
||||||
no-bike: No cargobike to show.
|
|
||||||
legend: One bar per booked cargobike, split by day. Click one for the details.
|
|
||||||
open: 'Open reservation #{id}'
|
|
||||||
reservation: 'Reservation #{id}'
|
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,19 @@ login:
|
||||||
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é.
|
||||||
calendar:
|
calendar:
|
||||||
title: Calendrier
|
load-error: Impossible de charger le calendrier.
|
||||||
todo: Cette page n'est pas encore construite.
|
title: Calendrier par cargobike
|
||||||
|
intro: Les réservations validées et en cours sont affichées dans le calendrier.
|
||||||
|
bike: Cargobike
|
||||||
|
all-bikes: Tous
|
||||||
|
previous: Sem.
|
||||||
|
next: Sem.
|
||||||
|
today: Aujourd'hui
|
||||||
|
hour: H
|
||||||
|
no-bike: Aucun cargobike à afficher.
|
||||||
|
legend: Une barre par cargobike réservé, découpée par jour. Cliquez pour voir le détail.
|
||||||
|
open: 'Voir la réservation #{id}'
|
||||||
|
reservation: 'Réservation #{id}'
|
||||||
admin:
|
admin:
|
||||||
title: Administration des réservations
|
title: Administration des réservations
|
||||||
intro: Validez, refusez et suivez les réservations, et visualisez les plannings par cargobike.
|
intro: Validez, refusez et suivez les réservations, et visualisez les plannings par cargobike.
|
||||||
|
|
@ -145,16 +156,3 @@ admin:
|
||||||
cancelled: Annuler
|
cancelled: Annuler
|
||||||
ongoing: Démarrer
|
ongoing: Démarrer
|
||||||
archived: Archiver
|
archived: Archiver
|
||||||
calendar:
|
|
||||||
title: Calendrier par cargobike
|
|
||||||
intro: Les réservations validées et en cours sont affichées dans le calendrier.
|
|
||||||
bike: Cargobike
|
|
||||||
all-bikes: Tous
|
|
||||||
previous: Sem.
|
|
||||||
next: Sem.
|
|
||||||
today: Aujourd'hui
|
|
||||||
hour: H
|
|
||||||
no-bike: Aucun cargobike à afficher.
|
|
||||||
legend: Une barre par cargobike réservé, découpée par jour. Cliquez pour voir le détail.
|
|
||||||
open: 'Voir la réservation #{id}'
|
|
||||||
reservation: 'Réservation #{id}'
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { getClient } from './client'
|
||||||
|
|
||||||
export const RESERVATIONS_KEY = ['reservations']
|
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 function useReservations() {
|
export function useReservations() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|
@ -52,6 +53,24 @@ export function useSetReservationStatus() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public availability calendar: the approved and ongoing reservations,
|
||||||
|
* without any personal field. No session needed.
|
||||||
|
*/
|
||||||
|
export function useCalendarReservations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: CALENDAR_KEY,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await getClient().GET('/api/reservations/calendar')
|
||||||
|
if (response.status !== HttpStatus.OK) {
|
||||||
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||||
|
}
|
||||||
|
return data ?? []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything the session user is part of: what they filed, what they were added
|
* Everything the session user is part of: what they filed, what they were added
|
||||||
* to, and what lists their address among the Linka Go accounts — which is how a
|
* to, and what lists their address among the Linka Go accounts — which is how a
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,16 @@ export type UserSummary = components['schemas']['UserSummary']
|
||||||
export type ReservationUnit = components['schemas']['ReservationUnit']
|
export type ReservationUnit = components['schemas']['ReservationUnit']
|
||||||
export type NewReservation = components['schemas']['NewReservation']
|
export type NewReservation = components['schemas']['NewReservation']
|
||||||
export type ReservationEdit = components['schemas']['ReservationEditForm']
|
export type ReservationEdit = components['schemas']['ReservationEditForm']
|
||||||
|
export type CalendarReservation = components['schemas']['CalendarReservation']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the details block can render: the fields the public calendar carries,
|
||||||
|
* plus whatever personal ones the caller was entitled to receive. A full
|
||||||
|
* `Reservation` satisfies it; a `CalendarReservation` satisfies it with the
|
||||||
|
* personal half missing, and the block simply leaves those rows out.
|
||||||
|
*/
|
||||||
|
export type ReservationLike = CalendarReservation &
|
||||||
|
Partial<Pick<Reservation, 'telegram' | 'users' | 'linka_emails' | 'description'>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What to display for a reservation's unit: the name of the unit it points at,
|
* What to display for a reservation's unit: the name of the unit it points at,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { RefreshCw } from '@lucide/vue'
|
import { RefreshCw } from '@lucide/vue'
|
||||||
|
|
||||||
import BikeCalendar from '@/components/admin/BikeCalendar.vue'
|
import BikeCalendar from '@/components/reservation/BikeCalendar.vue'
|
||||||
import BikeFleet from '@/components/admin/BikeFleet.vue'
|
import BikeFleet from '@/components/admin/BikeFleet.vue'
|
||||||
import ReservationAdmin from '@/components/admin/ReservationAdmin.vue'
|
import ReservationAdmin from '@/components/admin/ReservationAdmin.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,29 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Placeholder: the header links here, and a dead link would be worse than an
|
* The public availability calendar: the same grid as the admin page, fed by the
|
||||||
* explicit "not built yet". Replace with the real availability calendar.
|
* endpoint that carries no personal field. Anybody can look up when the bikes
|
||||||
|
* are taken without being signed in.
|
||||||
*/
|
*/
|
||||||
|
import BikeCalendar from '@/components/reservation/BikeCalendar.vue'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useBikes } from '@/services/api/bikes'
|
||||||
|
import { useCalendarReservations } from '@/services/api/reservations'
|
||||||
|
|
||||||
|
const { data: reservations, isPending, isError } = useCalendarReservations()
|
||||||
|
const { data: bikes, isPending: bikesPending } = useBikes()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto w-full max-w-2xl">
|
<div class="mx-auto w-full max-w-6xl">
|
||||||
<h1 class="text-xl font-semibold">{{ $t('calendar.title') }}</h1>
|
<div v-if="isPending || bikesPending" class="grid gap-3">
|
||||||
<p class="text-muted-foreground mt-2 text-sm">{{ $t('calendar.todo') }}</p>
|
<Skeleton class="h-24 w-full" />
|
||||||
|
<Skeleton class="h-[40rem] w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="isError" class="text-destructive text-sm">
|
||||||
|
{{ $t('calendar.load-error') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<BikeCalendar v-else :reservations="reservations ?? []" :bikes="bikes ?? []" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,16 @@ use serde::Deserialize;
|
||||||
use crate::{
|
use crate::{
|
||||||
api::helpers::{IdPath, admin, admin_desc, desc, manager, manager_desc, unexpected_error},
|
api::helpers::{IdPath, admin, admin_desc, desc, manager, manager_desc, unexpected_error},
|
||||||
core::{
|
core::{
|
||||||
controller::{AppController, ControllerError, reservations::ReservationsControllerError},
|
controller::{
|
||||||
|
AnonAppController, AppController, ControllerError,
|
||||||
|
reservations::ReservationsControllerError,
|
||||||
|
},
|
||||||
models::{
|
models::{
|
||||||
bike::BikeId,
|
bike::BikeId,
|
||||||
reservation::{NewReservation, Reservation, ReservationEdit, ReservationStatus},
|
reservation::{
|
||||||
|
CalendarReservation, NewReservation, Reservation, ReservationEdit,
|
||||||
|
ReservationStatus,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -36,11 +42,15 @@ pub fn routes() -> ApiRouter {
|
||||||
get_with(get_reservations, get_reservations_docs)
|
get_with(get_reservations, get_reservations_docs)
|
||||||
.post_with(create_reservation, create_reservation_docs),
|
.post_with(create_reservation, create_reservation_docs),
|
||||||
)
|
)
|
||||||
// Before `/{id}`, which would otherwise be a candidate for "mine"
|
// Before `/{id}`, which would otherwise be a candidate for these
|
||||||
.api_route(
|
.api_route(
|
||||||
"/mine",
|
"/mine",
|
||||||
get_with(get_my_reservations, get_my_reservations_docs),
|
get_with(get_my_reservations, get_my_reservations_docs),
|
||||||
)
|
)
|
||||||
|
.api_route(
|
||||||
|
"/calendar",
|
||||||
|
get_with(get_calendar_reservations, get_calendar_reservations_docs),
|
||||||
|
)
|
||||||
.api_route(
|
.api_route(
|
||||||
"/{id}",
|
"/{id}",
|
||||||
put_with(update_reservation, update_reservation_docs),
|
put_with(update_reservation, update_reservation_docs),
|
||||||
|
|
@ -105,6 +115,27 @@ fn create_reservation_docs(op: TransformOperation) -> TransformOperation {
|
||||||
.response_with::<422, (), _>(desc("The unit or one of the bikes does not exist"))
|
.response_with::<422, (), _>(desc("The unit or one of the bikes does not exist"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The availability calendar, open to everybody: when the bikes are taken and by
|
||||||
|
/// which association, with nothing personal attached.
|
||||||
|
#[axum::debug_handler]
|
||||||
|
async fn get_calendar_reservations(
|
||||||
|
aac: AnonAppController,
|
||||||
|
) -> Result<Json<Vec<CalendarReservation>>, (StatusCode, String)> {
|
||||||
|
match aac.get_calendar_reservations().await {
|
||||||
|
Ok(reservations) => Ok(Json(reservations)),
|
||||||
|
Err(err) => unexpected_error("get_calendar_reservations", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_calendar_reservations_docs(op: TransformOperation) -> TransformOperation {
|
||||||
|
op.tag("Reservations")
|
||||||
|
.summary("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.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything the session user is part of: what they filed, what they were
|
/// Everything the session user is part of: what they filed, what they were
|
||||||
/// added to, and what lists their address among the Linka Go accounts.
|
/// added to, and what lists their address among the Linka Go accounts.
|
||||||
#[axum::debug_handler]
|
#[axum::debug_handler]
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ use crate::core::{
|
||||||
models::{
|
models::{
|
||||||
bike::BikeStatus,
|
bike::BikeStatus,
|
||||||
reservation::{
|
reservation::{
|
||||||
NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
|
CalendarReservation, NewReservation, NewReservationUnit, Reservation, ReservationEdit,
|
||||||
ReservationStatus,
|
ReservationId, ReservationStatus,
|
||||||
},
|
},
|
||||||
unit::UnitId,
|
unit::UnitId,
|
||||||
},
|
},
|
||||||
|
|
@ -32,6 +32,26 @@ impl AnonAppController {
|
||||||
pub async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, ControllerError> {
|
pub async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, ControllerError> {
|
||||||
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
|
||||||
|
/// a bike, stripped of everything personal. Reading it needs no session.
|
||||||
|
pub async fn get_calendar_reservations(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<CalendarReservation>, ControllerError> {
|
||||||
|
Ok(self
|
||||||
|
.db
|
||||||
|
.get_reservations()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|reservation| {
|
||||||
|
matches!(
|
||||||
|
reservation.status,
|
||||||
|
ReservationStatus::Approved | ReservationStatus::Ongoing
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.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
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,33 @@ pub struct Reservation {
|
||||||
pub status: ReservationStatus,
|
pub status: ReservationStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A reservation as the public availability calendar sees it: when the bikes are
|
||||||
|
/// taken and by which association, and nothing else. The telegram handle, the
|
||||||
|
/// people, the Linka Go addresses and the reason are deliberately absent — this
|
||||||
|
/// travels to anybody, signed in or not.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||||
|
pub struct CalendarReservation {
|
||||||
|
pub id: ReservationId,
|
||||||
|
pub unit: ReservationUnit,
|
||||||
|
pub start_time: DateTime<Utc>,
|
||||||
|
pub end_time: DateTime<Utc>,
|
||||||
|
pub bikes: Vec<BikeId>,
|
||||||
|
pub status: ReservationStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Reservation> for CalendarReservation {
|
||||||
|
fn from(value: Reservation) -> Self {
|
||||||
|
CalendarReservation {
|
||||||
|
id: value.id,
|
||||||
|
unit: value.unit,
|
||||||
|
start_time: value.start_time,
|
||||||
|
end_time: value.end_time,
|
||||||
|
bikes: value.bikes,
|
||||||
|
status: value.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue