diff --git a/frontend/src/components/admin/BikeCalendar.vue b/frontend/src/components/admin/BikeCalendar.vue index 552005e..99c3d81 100644 --- a/frontend/src/components/admin/BikeCalendar.vue +++ b/frontend/src/components/admin/BikeCalendar.vue @@ -12,6 +12,15 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import ReservationDetails from '@/components/admin/ReservationDetails.vue' +import { + Dialog, + DialogDescription, + DialogHeader, + DialogScrollContent, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' import { unitLabel, type Bike, type Reservation } from '@/utils/types' const props = defineProps<{ reservations: Reservation[]; bikes: Bike[] }>() @@ -85,12 +94,24 @@ const booked = computed(() => props.reservations.filter((r) => r.status === 'approved' || r.status === 'ongoing'), ) +/** Which reservation the modal is showing, by id, or `null` when closed */ +const opened = ref(null) +const openedReservation = computed(() => + props.reservations.find((reservation) => reservation.id === opened.value), +) +const dialogOpen = computed({ + get: () => opened.value !== null, + set: (value) => { + if (!value) opened.value = null + }, +}) + const today = new Date() function isToday(day: Date) { return day.toDateString() === today.toDateString() } -type Block = { id: number; top: number; height: number; label: string } +type Block = { id: number; top: number; height: number } function blocksFor(day: Date, bike: Bike): Block[] { const dayStart = new Date(day) @@ -109,7 +130,7 @@ function blocksFor(day: Date, bike: Bike): Block[] { const top = ((from.getTime() - dayStart.getTime()) / 3_600_000) * HOUR_HEIGHT const height = Math.max(((to.getTime() - from.getTime()) / 3_600_000) * HOUR_HEIGHT, 6) - return [{ id: reservation.id, top, height, label: unitLabel(reservation.unit) }] + return [{ id: reservation.id, top, height }] }) } @@ -214,15 +235,16 @@ function blocksFor(day: Date, bike: Bike): Block[] {
-
- {{ block.label }} -
+ :title="$t('admin.calendar.open', { id: block.id })" + :aria-label="$t('admin.calendar.open', { id: block.id })" + @click="opened = block.id" + />
@@ -231,6 +253,21 @@ function blocksFor(day: Date, bike: Bike): Block[] {

{{ $t('admin.calendar.legend') }}

+ + + + + + {{ $t('admin.calendar.reservation', { id: openedReservation.id }) }} + + {{ $t(`admin.reservations.status.${openedReservation.status}`) }} + + + {{ unitLabel(openedReservation.unit) }} + + + + diff --git a/frontend/src/components/admin/ReservationCard.vue b/frontend/src/components/admin/ReservationCard.vue index c24c4d8..8b8d9d5 100644 --- a/frontend/src/components/admin/ReservationCard.vue +++ b/frontend/src/components/admin/ReservationCard.vue @@ -4,10 +4,13 @@ * mirror `ReservationStatus::can_transition_to` on the backend, which re-checks * and answers 409 on anything else. */ -import { computed } from 'vue' +import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' +import { Pencil } from '@lucide/vue' import { toast } from 'vue-sonner' +import ReservationDetails from '@/components/admin/ReservationDetails.vue' +import ReservationEditDialog from '@/components/admin/ReservationEditDialog.vue' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { useSetReservationStatus } from '@/services/api/reservations' @@ -15,7 +18,7 @@ import { unitLabel, type Bike, type Reservation, type ReservationStatus } from ' const props = defineProps<{ reservation: Reservation; bikes: Bike[] }>() -const { t, locale } = useI18n() +const { t } = useI18n() const setStatus = useSetReservationStatus() /** Kept in step with the state machine drawn on the backend enum */ @@ -39,23 +42,11 @@ const BADGE_CLASS: Record = { const transitions = computed(() => TRANSITIONS[props.reservation.status]) -const bikeNames = computed(() => - props.reservation.bikes - .map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`) - .join(', '), +/** A reservation nobody can act on any more is not worth an edit button */ +const editable = computed( + () => !['refused', 'cancelled', 'archived'].includes(props.reservation.status), ) - -const formatter = computed( - () => - new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', { - dateStyle: 'short', - timeStyle: 'short', - }), -) - -function format(iso: string) { - return formatter.value.format(new Date(iso)) -} +const editing = ref(false) function move(status: ReservationStatus) { setStatus.mutate( @@ -76,7 +67,11 @@ function move(status: ReservationStatus) { {{ unitLabel(reservation.unit) }} -
+
+
-
-
-
{{ $t('admin.reservations.period') }}
-
{{ format(reservation.start_time) }} → {{ format(reservation.end_time) }}
-
-
-
{{ $t('admin.reservations.bikes') }}
-
{{ bikeNames }}
-
-
-
{{ $t('admin.reservations.telegram') }}
-
{{ reservation.telegram }}
-
-
-
{{ $t('admin.reservations.people') }}
-
- {{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }} -
-
-
-
{{ $t('admin.reservations.linka') }}
-
{{ reservation.linka_emails.join(', ') }}
-
-
-
{{ $t('admin.reservations.reason') }}
-
{{ reservation.description }}
-
-
+ + +
diff --git a/frontend/src/components/admin/ReservationDetails.vue b/frontend/src/components/admin/ReservationDetails.vue new file mode 100644 index 0000000..08883a7 --- /dev/null +++ b/frontend/src/components/admin/ReservationDetails.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/components/admin/ReservationEditDialog.vue b/frontend/src/components/admin/ReservationEditDialog.vue new file mode 100644 index 0000000..eb3e133 --- /dev/null +++ b/frontend/src/components/admin/ReservationEditDialog.vue @@ -0,0 +1,290 @@ + + + diff --git a/frontend/src/components/ui/dialog/Dialog.vue b/frontend/src/components/ui/dialog/Dialog.vue new file mode 100644 index 0000000..ff50359 --- /dev/null +++ b/frontend/src/components/ui/dialog/Dialog.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogClose.vue b/frontend/src/components/ui/dialog/DialogClose.vue new file mode 100644 index 0000000..e49c359 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogClose.vue @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogContent.vue b/frontend/src/components/ui/dialog/DialogContent.vue new file mode 100644 index 0000000..b2bf24a --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogContent.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogDescription.vue b/frontend/src/components/ui/dialog/DialogDescription.vue new file mode 100644 index 0000000..bf04fdc --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogDescription.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogFooter.vue b/frontend/src/components/ui/dialog/DialogFooter.vue new file mode 100644 index 0000000..c6f5d4e --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogFooter.vue @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogHeader.vue b/frontend/src/components/ui/dialog/DialogHeader.vue new file mode 100644 index 0000000..e22a1fd --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogHeader.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogScrollContent.vue b/frontend/src/components/ui/dialog/DialogScrollContent.vue new file mode 100644 index 0000000..46bb80e --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogScrollContent.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogTitle.vue b/frontend/src/components/ui/dialog/DialogTitle.vue new file mode 100644 index 0000000..b4dfbeb --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogTitle.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogTrigger.vue b/frontend/src/components/ui/dialog/DialogTrigger.vue new file mode 100644 index 0000000..4723d52 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogTrigger.vue @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/components/ui/dialog/index.ts b/frontend/src/components/ui/dialog/index.ts new file mode 100644 index 0000000..ca8cfea --- /dev/null +++ b/frontend/src/components/ui/dialog/index.ts @@ -0,0 +1,9 @@ +export { default as Dialog } from './Dialog.vue' +export { default as DialogClose } from './DialogClose.vue' +export { default as DialogContent } from './DialogContent.vue' +export { default as DialogDescription } from './DialogDescription.vue' +export { default as DialogFooter } from './DialogFooter.vue' +export { default as DialogHeader } from './DialogHeader.vue' +export { default as DialogScrollContent } from './DialogScrollContent.vue' +export { default as DialogTitle } from './DialogTitle.vue' +export { default as DialogTrigger } from './DialogTrigger.vue' diff --git a/frontend/src/lib/api.d.ts b/frontend/src/lib/api.d.ts index dbd891a..fe57b2c 100644 --- a/frontend/src/lib/api.d.ts +++ b/frontend/src/lib/api.d.ts @@ -507,6 +507,97 @@ export interface paths { patch?: never trace?: never } + '/api/reservations/{id}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + /** + * Edit the period, the bikes and the Linka Go accounts + * @description Everything else is left as stored. A reservation in a final state cannot be edited any more. + */ + put: { + parameters: { + query?: never + header?: never + path: { + id: number + } + cookie?: never + } + /** + * @description Only what the admin page lets somebody change. The unit, the requester, the + * telegram handle and the reason are shown but not editable, so they are not + * in the body at all: the handler reads them back from the stored reservation + * rather than trusting a client to send them unchanged. + */ + requestBody: { + content: { + 'application/json': components['schemas']['ReservationEditForm'] + } + } + responses: { + /** @description no content */ + 200: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description The reservation would become malformed */ + 400: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Unauthenticated - a session is required */ + 401: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Forbidden - the user must be part of the unit, or an admin when the reservation names no known unit */ + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description no content */ + 404: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description A newly added bike is out of service, or the reservation is final */ + 409: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description One of the bikes does not exist */ + 422: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/reservations/{id}/status': { parameters: { query?: never @@ -670,6 +761,20 @@ export interface components { unit: components['schemas']['ReservationUnit'] users: components['schemas']['UserSummary'][] } + /** + * @description Only what the admin page lets somebody change. The unit, the requester, the + * telegram handle and the reason are shown but not editable, so they are not + * in the body at all: the handler reads them back from the stored reservation + * rather than trusting a client to send them unchanged. + */ + ReservationEditForm: { + bikes: number[] + /** Format: date-time */ + end_time: string + linka_emails: string[] + /** Format: date-time */ + start_time: string + } /** @enum {string} */ ReservationStatus: 'requested' | 'refused' | 'approved' | 'cancelled' | 'ongoing' | 'archived' /** diff --git a/frontend/src/locales/en.yml b/frontend/src/locales/en.yml index 1a8e77c..750db94 100644 --- a/frontend/src/locales/en.yml +++ b/frontend/src/locales/en.yml @@ -99,6 +99,17 @@ admin: telegram: Telegram people: People linka: Linka Go accounts + edit: + action: Edit + title: 'Reservation #{id}' + intro: >- + Only the period, the cargobikes and the Linka Go accounts can be changed. + bikes: Assigned cargobikes + save: Save + saving: Saving… + cancel: Cancel + saved: Reservation updated. + error: Could not save the changes. reason: Reason load-error: Unable to load the reservations. error: The status change failed. @@ -125,4 +136,6 @@ admin: today: Today hour: H no-bike: No cargobike to show. - legend: One bar per booked cargobike, split by day. + legend: One bar per booked cargobike, split by day. Click one for the details. + open: 'Open reservation #{id}' + reservation: 'Reservation #{id}' diff --git a/frontend/src/locales/fr.yml b/frontend/src/locales/fr.yml index a70412a..f34b661 100644 --- a/frontend/src/locales/fr.yml +++ b/frontend/src/locales/fr.yml @@ -100,6 +100,17 @@ admin: telegram: Telegram people: Personnes linka: Comptes Linka Go + edit: + action: Modifier + title: 'Réservation #{id}' + intro: >- + Seuls la période, les cargobikes et les comptes Linka Go peuvent être modifiés. + bikes: Cargobikes attribués + save: Enregistrer + saving: Enregistrement… + cancel: Annuler + saved: Réservation mise à jour. + error: La modification a échoué. reason: Raison load-error: Impossible de charger les réservations. error: Le changement de statut a échoué. @@ -126,4 +137,6 @@ admin: today: Aujourd'hui hour: H no-bike: Aucun cargobike à afficher. - legend: Une barre par cargobike réservé, découpée par jour. + legend: Une barre par cargobike réservé, découpée par jour. Cliquez pour voir le détail. + open: 'Voir la réservation #{id}' + reservation: 'Réservation #{id}' diff --git a/frontend/src/services/api/reservations.ts b/frontend/src/services/api/reservations.ts index b7bd8bb..f4e5127 100644 --- a/frontend/src/services/api/reservations.ts +++ b/frontend/src/services/api/reservations.ts @@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { HttpStatus } from 'http-status-ts' -import type { NewReservation, Reservation, ReservationStatus } from '@/utils/types' +import type { NewReservation, Reservation, ReservationEdit, ReservationStatus } from '@/utils/types' import { getClient } from './client' export const RESERVATIONS_KEY = ['reservations'] @@ -71,3 +71,24 @@ export function useCreateReservation() { onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }), }) } + +/** + * Edits the period, the bikes and the Linka Go accounts. Everything else is + * left as stored: the backend reads it back rather than taking it from here. + */ +export function useUpdateReservation() { + const queryClient = useQueryClient() + return useMutation({ + retry: 0, + mutationFn: async ({ id, ...edit }: ReservationEdit & { id: number }) => { + const { response, error } = await getClient().PUT('/api/reservations/{id}', { + params: { path: { id } }, + body: edit, + }) + if (response.status !== HttpStatus.OK) { + throw new Error(typeof error === 'string' ? error : `Unexpected status: ${response.status}`) + } + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }), + }) +} diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index 4557f5d..1a1c510 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -28,6 +28,7 @@ export type ReservationStatus = components['schemas']['ReservationStatus'] export type UserSummary = components['schemas']['UserSummary'] export type ReservationUnit = components['schemas']['ReservationUnit'] export type NewReservation = components['schemas']['NewReservation'] +export type ReservationEdit = components['schemas']['ReservationEditForm'] /** * What to display for a reservation's unit: the name of the unit it points at, diff --git a/src/api/reservations.rs b/src/api/reservations.rs index 3a412bb..df13a66 100644 --- a/src/api/reservations.rs +++ b/src/api/reservations.rs @@ -14,6 +14,7 @@ use aide::{ transform::TransformOperation, }; use axum::{Json, extract::Path, http::StatusCode}; +use chrono::{DateTime, Utc}; use schemars::JsonSchema; use serde::Deserialize; @@ -21,7 +22,10 @@ use crate::{ api::helpers::{IdPath, admin, admin_desc, desc, manager, manager_desc, unexpected_error}, core::{ controller::{AppController, ControllerError, reservations::ReservationsControllerError}, - models::reservation::{NewReservation, Reservation, ReservationStatus}, + models::{ + bike::BikeId, + reservation::{NewReservation, Reservation, ReservationEdit, ReservationStatus}, + }, }, }; @@ -32,6 +36,10 @@ pub fn routes() -> ApiRouter { get_with(get_reservations, get_reservations_docs) .post_with(create_reservation, create_reservation_docs), ) + .api_route( + "/{id}", + put_with(update_reservation, update_reservation_docs), + ) .api_route("/{id}/status", put_with(set_status, set_status_docs)) } @@ -92,6 +100,79 @@ fn create_reservation_docs(op: TransformOperation) -> TransformOperation { .response_with::<422, (), _>(desc("The unit or one of the bikes does not exist")) } +/// Only what the admin page lets somebody change. The unit, the requester, the +/// telegram handle and the reason are shown but not editable, so they are not +/// in the body at all: the handler reads them back from the stored reservation +/// rather than trusting a client to send them unchanged. +#[derive(Debug, Deserialize, JsonSchema)] +struct ReservationEditForm { + start_time: DateTime, + end_time: DateTime, + bikes: Vec, + linka_emails: Vec, +} + +#[axum::debug_handler] +async fn update_reservation( + ac: AppController, + Path(IdPath { id }): Path, + Json(form): Json, +) -> Result<(), (StatusCode, String)> { + let current = match ac.get_reservation(id).await { + Ok(reservation) => reservation, + Err(err) if err.is_not_found() => { + return Err((StatusCode::NOT_FOUND, "No such reservation".to_owned())); + } + Err(err) => return unexpected_error("update_reservation", err), + }; + + let edit = ReservationEdit { + id, + unit: current.unit.as_new(), + start_time: form.start_time, + end_time: form.end_time, + users: current.users.iter().map(|user| user.id).collect(), + telegram: current.telegram, + description: current.description, + bikes: form.bikes, + linka_emails: form.linka_emails, + }; + + match manager(ac, current.unit.scope())? + .update_reservation(edit) + .await + { + Ok(()) => Ok(()), + Err(ControllerError::Reservation( + err @ ReservationsControllerError::ReservationInvalid, + )) => Err((StatusCode::BAD_REQUEST, err.to_string())), + Err(ControllerError::Reservation( + err @ (ReservationsControllerError::BikeOutOfService(_) + | ReservationsControllerError::ReservationFinal(_)), + )) => Err((StatusCode::CONFLICT, err.to_string())), + Err(err) if err.is_not_found() => { + Err((StatusCode::UNPROCESSABLE_ENTITY, "Unknown bike".to_owned())) + } + Err(err) => unexpected_error("update_reservation", err), + } +} + +fn update_reservation_docs(op: TransformOperation) -> TransformOperation { + op.tag("Reservations") + .summary("Edit the period, the bikes and the Linka Go accounts") + .description( + "Everything else is left as stored. A reservation in a final state \ + cannot be edited any more.", + ) + .response_with::<403, (), _>(manager_desc) + .response::<404, ()>() + .response_with::<400, (), _>(desc("The reservation would become malformed")) + .response_with::<409, (), _>(desc( + "A newly added bike is out of service, or the reservation is final", + )) + .response_with::<422, (), _>(desc("One of the bikes does not exist")) +} + #[derive(Debug, Deserialize, JsonSchema)] struct SetStatusForm { status: ReservationStatus, diff --git a/src/core/controller/reservations.rs b/src/core/controller/reservations.rs index 0f41ac0..9cf9eda 100644 --- a/src/core/controller/reservations.rs +++ b/src/core/controller/reservations.rs @@ -89,6 +89,17 @@ impl ManagerAppController { return Err(ReservationsControllerError::ReservationFinal(current.status).into()); } + // A bike already on the reservation may well have broken down since: + // only a newly added one has to be in service. + for id in &reservation.bikes { + if current.bikes.contains(id) { + continue; + } + if self.get_bike(*id).await?.status == BikeStatus::OutOfService { + return Err(ReservationsControllerError::BikeOutOfService(*id).into()); + } + } + self.db .update_reservation(reservation) .await diff --git a/src/core/models/reservation.rs b/src/core/models/reservation.rs index c617ae5..deab292 100644 --- a/src/core/models/reservation.rs +++ b/src/core/models/reservation.rs @@ -66,6 +66,15 @@ impl ReservationUnit { } } + /// The same choice in the shape an edit carries. Editing never changes the + /// unit, so a handler rebuilds it from the stored reservation. + pub fn as_new(&self) -> NewReservationUnit { + match self { + ReservationUnit::Known { unit } => NewReservationUnit::Known { id: unit.id }, + ReservationUnit::Free { name } => NewReservationUnit::Free { name: name.clone() }, + } + } + /// The scope somebody must manage to act on this reservation. `None` for a /// typed name: nobody is the manager of a unit we do not know, so only an /// admin qualifies.