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
|
||||
config.yml
|
||||
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 */
|
||||
const TRANSITIONS: Record<ReservationStatus, ReservationStatus[]> = {
|
||||
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'],
|
||||
refused: [],
|
||||
cancelled: [],
|
||||
|
|
|
|||
|
|
@ -21,15 +21,19 @@ import {
|
|||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
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 FIRST_HOUR = 7
|
||||
const LAST_HOUR = 20
|
||||
const HOUR_HEIGHT = 44
|
||||
// The whole day, 00:00 to 23:59. 24 rows at 44px would make the card very tall,
|
||||
// hence the shorter row.
|
||||
const FIRST_HOUR = 0
|
||||
const LAST_HOUR = 24
|
||||
const HOUR_HEIGHT = 32
|
||||
|
||||
const weekStart = ref(startOfWeek(new Date()))
|
||||
const selectedBike = ref<string>('all')
|
||||
|
|
@ -140,18 +144,18 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
|||
<CardHeader class="gap-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>{{ $t('admin.calendar.title') }}</CardTitle>
|
||||
<CardDescription>{{ $t('admin.calendar.intro') }}</CardDescription>
|
||||
<CardTitle>{{ $t('calendar.title') }}</CardTitle>
|
||||
<CardDescription>{{ $t('calendar.intro') }}</CardDescription>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<SelectTrigger class="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<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)">
|
||||
{{ bike.name }}
|
||||
</SelectItem>
|
||||
|
|
@ -163,13 +167,13 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
|||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="shiftWeek(-1)">
|
||||
<ChevronLeft class="size-4" />
|
||||
{{ $t('admin.calendar.previous') }}
|
||||
{{ $t('calendar.previous') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" @click="weekStart = startOfWeek(new Date())">
|
||||
{{ $t('admin.calendar.today') }}
|
||||
{{ $t('calendar.today') }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="shiftWeek(1)">
|
||||
{{ $t('admin.calendar.next') }}
|
||||
{{ $t('calendar.next') }}
|
||||
<ChevronRight class="size-4" />
|
||||
</Button>
|
||||
<span class="text-muted-foreground text-sm">{{ range }}</span>
|
||||
|
|
@ -178,7 +182,7 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
|||
|
||||
<CardContent>
|
||||
<p v-if="!lanes.length" class="text-muted-foreground text-sm">
|
||||
{{ $t('admin.calendar.no-bike') }}
|
||||
{{ $t('calendar.no-bike') }}
|
||||
</p>
|
||||
|
||||
<!-- 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="flex border-b">
|
||||
<div class="text-muted-foreground w-12 shrink-0 py-2 text-xs">
|
||||
{{ $t('admin.calendar.hour') }}
|
||||
{{ $t('calendar.hour') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="day in days"
|
||||
|
|
@ -241,8 +245,8 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
|||
type="button"
|
||||
class="bg-primary hover:bg-primary/80 focus-visible:ring-ring absolute inset-x-0 rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
:style="{ top: `${block.top}px`, height: `${block.height}px` }"
|
||||
:title="$t('admin.calendar.open', { id: block.id })"
|
||||
:aria-label="$t('admin.calendar.open', { id: block.id })"
|
||||
:title="$t('calendar.open', { id: block.id })"
|
||||
:aria-label="$t('calendar.open', { id: block.id })"
|
||||
@click="opened = block.id"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -252,13 +256,13 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
|||
</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">
|
||||
<DialogScrollContent v-if="openedReservation" class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex flex-wrap items-center gap-2">
|
||||
{{ $t('admin.calendar.reservation', { id: openedReservation.id }) }}
|
||||
{{ $t('calendar.reservation', { id: openedReservation.id }) }}
|
||||
<Badge variant="secondary">
|
||||
{{ $t(`reservation.status.${openedReservation.status}`) }}
|
||||
</Badge>
|
||||
|
|
@ -6,13 +6,13 @@
|
|||
import { computed } from 'vue'
|
||||
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'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
reservation: Reservation
|
||||
reservation: ReservationLike
|
||||
bikes: Bike[]
|
||||
/** Fields the caller renders itself — the edit dialog omits what it edits */
|
||||
omit?: Field[]
|
||||
|
|
@ -55,17 +55,17 @@ function format(iso: string) {
|
|||
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.bikes') }}</dt>
|
||||
<dd>{{ bikeNames }}</dd>
|
||||
</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>
|
||||
<dd class="truncate">{{ reservation.telegram }}</dd>
|
||||
</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>
|
||||
<dd class="min-w-0 break-words">
|
||||
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="shows('linka') && reservation.linka_emails.length" class="flex gap-2">
|
||||
<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>
|
||||
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd>
|
||||
</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.
|
||||
error: The login failed.
|
||||
calendar:
|
||||
title: Calendar
|
||||
todo: This page is not built yet.
|
||||
load-error: Unable to load the 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}'
|
||||
admin:
|
||||
title: Reservation administration
|
||||
intro: Approve, refuse and follow the reservations, and see the planning of each cargobike.
|
||||
|
|
@ -144,16 +155,3 @@ admin:
|
|||
cancelled: Cancel
|
||||
ongoing: Start
|
||||
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.
|
||||
error: La connexion a échoué.
|
||||
calendar:
|
||||
title: Calendrier
|
||||
todo: Cette page n'est pas encore construite.
|
||||
load-error: Impossible de charger le calendrier.
|
||||
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:
|
||||
title: Administration des réservations
|
||||
intro: Validez, refusez et suivez les réservations, et visualisez les plannings par cargobike.
|
||||
|
|
@ -145,16 +156,3 @@ admin:
|
|||
cancelled: Annuler
|
||||
ongoing: Démarrer
|
||||
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 MY_RESERVATIONS_KEY = ['reservations', 'mine']
|
||||
export const CALENDAR_KEY = ['reservations', 'calendar']
|
||||
|
||||
export function useReservations() {
|
||||
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
|
||||
* 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 NewReservation = components['schemas']['NewReservation']
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { computed } from '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 ReservationAdmin from '@/components/admin/ReservationAdmin.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
|
|||
|
|
@ -1,13 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Placeholder: the header links here, and a dead link would be worse than an
|
||||
* explicit "not built yet". Replace with the real availability calendar.
|
||||
* The public availability calendar: the same grid as the admin page, fed by the
|
||||
* 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>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-2xl">
|
||||
<h1 class="text-xl font-semibold">{{ $t('calendar.title') }}</h1>
|
||||
<p class="text-muted-foreground mt-2 text-sm">{{ $t('calendar.todo') }}</p>
|
||||
<div class="mx-auto w-full max-w-6xl">
|
||||
<div v-if="isPending || bikesPending" class="grid gap-3">
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -21,10 +21,16 @@ use serde::Deserialize;
|
|||
use crate::{
|
||||
api::helpers::{IdPath, admin, admin_desc, desc, manager, manager_desc, unexpected_error},
|
||||
core::{
|
||||
controller::{AppController, ControllerError, reservations::ReservationsControllerError},
|
||||
controller::{
|
||||
AnonAppController, AppController, ControllerError,
|
||||
reservations::ReservationsControllerError,
|
||||
},
|
||||
models::{
|
||||
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)
|
||||
.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(
|
||||
"/mine",
|
||||
get_with(get_my_reservations, get_my_reservations_docs),
|
||||
)
|
||||
.api_route(
|
||||
"/calendar",
|
||||
get_with(get_calendar_reservations, get_calendar_reservations_docs),
|
||||
)
|
||||
.api_route(
|
||||
"/{id}",
|
||||
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"))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// added to, and what lists their address among the Linka Go accounts.
|
||||
#[axum::debug_handler]
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ use crate::core::{
|
|||
models::{
|
||||
bike::BikeStatus,
|
||||
reservation::{
|
||||
NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
|
||||
ReservationStatus,
|
||||
CalendarReservation, NewReservation, NewReservationUnit, Reservation, ReservationEdit,
|
||||
ReservationId, ReservationStatus,
|
||||
},
|
||||
unit::UnitId,
|
||||
},
|
||||
|
|
@ -32,6 +32,26 @@ impl AnonAppController {
|
|||
pub async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, ControllerError> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -121,6 +121,33 @@ pub struct Reservation {
|
|||
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)]
|
||||
pub struct NewReservation {
|
||||
pub unit: NewReservationUnit,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue