feat: add personnal page
This commit is contained in:
parent
6c7f08ec43
commit
46c5edf5bd
15 changed files with 1413 additions and 942 deletions
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { LogOut, Menu, Moon, Sun, User } from '@lucide/vue'
|
||||
import { ClipboardList, LogOut, Menu, Moon, Sun, User } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
|
|
@ -132,6 +132,13 @@ function signOut() {
|
|||
<span class="text-muted-foreground block truncate text-xs">{{ user?.email }}</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem as-child>
|
||||
<RouterLink :to="{ name: 'my-reservations' }">
|
||||
<ClipboardList class="size-4" />
|
||||
{{ $t('header.my-reservations') }}
|
||||
</RouterLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="signOut()">
|
||||
<LogOut class="size-4" />
|
||||
{{ $t('header.logout') }}
|
||||
|
|
@ -173,6 +180,12 @@ function signOut() {
|
|||
<p class="text-muted-foreground mt-2 truncate px-3 text-xs">
|
||||
{{ user?.firstname }} {{ user?.name }}
|
||||
</p>
|
||||
<Button variant="ghost" class="justify-start" as-child @click="menuOpen = false">
|
||||
<RouterLink :to="{ name: 'my-reservations' }">
|
||||
<ClipboardList class="size-4" />
|
||||
{{ $t('header.my-reservations') }}
|
||||
</RouterLink>
|
||||
</Button>
|
||||
<Button variant="outline" class="mt-1 justify-start" @click="signOut()">
|
||||
<LogOut class="size-4" />
|
||||
{{ $t('header.logout') }}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import ReservationDetails from '@/components/admin/ReservationDetails.vue'
|
||||
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
|
|
@ -260,7 +260,7 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
|||
<DialogTitle class="flex flex-wrap items-center gap-2">
|
||||
{{ $t('admin.calendar.reservation', { id: openedReservation.id }) }}
|
||||
<Badge variant="secondary">
|
||||
{{ $t(`admin.reservations.status.${openedReservation.status}`) }}
|
||||
{{ $t(`reservation.status.${openedReservation.status}`) }}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription>{{ unitLabel(openedReservation.unit) }}</DialogDescription>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ 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 ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
||||
import ReservationEditDialog from '@/components/reservation/ReservationEditDialog.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSetReservationStatus } from '@/services/api/reservations'
|
||||
|
|
@ -62,7 +62,7 @@ function move(status: ReservationStatus) {
|
|||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="font-semibold">#{{ reservation.id }}</span>
|
||||
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
|
||||
{{ $t(`admin.reservations.status.${reservation.status}`) }}
|
||||
{{ $t(`reservation.status.${reservation.status}`) }}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground text-sm">{{ unitLabel(reservation.unit) }}</span>
|
||||
</div>
|
||||
|
|
@ -70,7 +70,7 @@ function move(status: ReservationStatus) {
|
|||
<div v-if="transitions.length || editable" class="flex flex-wrap gap-2">
|
||||
<Button v-if="editable" size="sm" variant="outline" @click="editing = true">
|
||||
<Pencil class="size-4" />
|
||||
{{ $t('admin.reservations.edit.action') }}
|
||||
{{ $t('reservation.edit.action') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-for="status in transitions"
|
||||
|
|
|
|||
|
|
@ -48,29 +48,29 @@ function format(iso: string) {
|
|||
<template>
|
||||
<dl class="grid gap-1 text-sm">
|
||||
<div v-if="shows('period')" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.period') }}</dt>
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.period') }}</dt>
|
||||
<dd>{{ format(reservation.start_time) }} → {{ format(reservation.end_time) }}</dd>
|
||||
</div>
|
||||
<div v-if="shows('bikes')" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.bikes') }}</dt>
|
||||
<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">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.telegram') }}</dt>
|
||||
<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">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.people') }}</dt>
|
||||
<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">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.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>
|
||||
</div>
|
||||
<div v-if="shows('reason') && reservation.description" class="flex gap-2">
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.reason') }}</dt>
|
||||
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.reason') }}</dt>
|
||||
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Editing a reservation from the admin page. Only the period, the bikes and the
|
||||
* Linka Go accounts can change — the unit, the requester, the telegram handle
|
||||
* and the reason are shown for context and are not part of the request, so the
|
||||
* backend keeps them as stored.
|
||||
* Editing a reservation, from the admin page or from "my reservations". Only the
|
||||
* period, the bikes and the Linka Go accounts can change — the unit, the
|
||||
* requester, the telegram handle and the reason are shown for context and are
|
||||
* not part of the request, so the backend keeps them as stored.
|
||||
*
|
||||
* `emails-only` narrows it further, for somebody who is on an already approved
|
||||
* reservation: the period and the fleet are settled, and only the list of
|
||||
* accounts allowed to unlock stays open. The backend enforces the same rule.
|
||||
*/
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
|
@ -13,7 +17,7 @@ import { toast } from 'vue-sonner'
|
|||
|
||||
import DatePicker from '@/components/DatePicker.vue'
|
||||
import TimePicker from '@/components/TimePicker.vue'
|
||||
import ReservationDetails from '@/components/admin/ReservationDetails.vue'
|
||||
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
|
|
@ -29,7 +33,10 @@ import { Label } from '@/components/ui/label'
|
|||
import { useUpdateReservation } from '@/services/api/reservations'
|
||||
import { unitLabel, type Bike, type Reservation } from '@/utils/types'
|
||||
|
||||
const props = defineProps<{ reservation: Reservation; bikes: Bike[] }>()
|
||||
const props = withDefaults(
|
||||
defineProps<{ reservation: Reservation; bikes: Bike[]; emailsOnly?: boolean }>(),
|
||||
{ emailsOnly: false },
|
||||
)
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
|
@ -84,6 +91,11 @@ function toDate(date: DateValue | undefined, time: string | undefined): Date | n
|
|||
return local
|
||||
}
|
||||
|
||||
/** Whatever this dialog edits below is not repeated in the context block */
|
||||
const omitted = computed<('period' | 'bikes' | 'linka')[]>(() =>
|
||||
props.emailsOnly ? ['linka'] : ['period', 'bikes', 'linka'],
|
||||
)
|
||||
|
||||
const start = computed(() => toDate(startDate.value, form.startTime))
|
||||
const end = computed(() => toDate(endDate.value, form.endTime))
|
||||
|
||||
|
|
@ -114,12 +126,14 @@ function removeEmail(index: number) {
|
|||
function validate(): boolean {
|
||||
Object.keys(errors).forEach((key) => delete errors[key])
|
||||
|
||||
if (!props.emailsOnly) {
|
||||
if (!start.value) errors.start = t('reservation.error-datetime-required')
|
||||
if (!end.value) errors.end = t('reservation.error-datetime-required')
|
||||
if (start.value && end.value && end.value <= start.value) {
|
||||
errors.end = t('reservation.error-end-before-start')
|
||||
}
|
||||
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
|
||||
}
|
||||
|
||||
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
|
||||
if (emails.length === 0) errors.emails = t('reservation.error-required')
|
||||
|
|
@ -140,17 +154,19 @@ function save() {
|
|||
update.mutate(
|
||||
{
|
||||
id: props.reservation.id,
|
||||
start_time: start.value!.toISOString(),
|
||||
end_time: end.value!.toISOString(),
|
||||
bikes: [...form.bikes],
|
||||
// Sent back as stored when they are not editable, which is exactly what
|
||||
// the backend checks before accepting the change
|
||||
start_time: props.emailsOnly ? props.reservation.start_time : start.value!.toISOString(),
|
||||
end_time: props.emailsOnly ? props.reservation.end_time : end.value!.toISOString(),
|
||||
bikes: props.emailsOnly ? [...props.reservation.bikes] : [...form.bikes],
|
||||
linka_emails: form.emails.map((email) => email.trim()).filter(Boolean),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.reservations.edit.saved'))
|
||||
toast.success(t('reservation.edit.saved'))
|
||||
open.value = false
|
||||
},
|
||||
onError: (error) => toast.error(error.message || t('admin.reservations.edit.error')),
|
||||
onError: (error) => toast.error(error.message || t('reservation.edit.error')),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -161,29 +177,27 @@ function save() {
|
|||
<DialogScrollContent class="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex flex-wrap items-center gap-2">
|
||||
{{ $t('admin.reservations.edit.title', { id: reservation.id }) }}
|
||||
{{ $t('reservation.edit.title', { id: reservation.id }) }}
|
||||
<Badge variant="secondary">
|
||||
{{ $t(`admin.reservations.status.${reservation.status}`) }}
|
||||
{{ $t(`reservation.status.${reservation.status}`) }}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground text-sm font-normal">
|
||||
{{ unitLabel(reservation.unit) }}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>{{ $t('admin.reservations.edit.intro') }}</DialogDescription>
|
||||
<DialogDescription>
|
||||
{{ emailsOnly ? $t('reservation.edit.intro-emails') : $t('reservation.edit.intro') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- Context: only what this dialog does not let anybody change -->
|
||||
<div class="bg-muted/40 rounded-md border p-3">
|
||||
<ReservationDetails
|
||||
:reservation="reservation"
|
||||
:bikes="bikes"
|
||||
:omit="['period', 'bikes', 'linka']"
|
||||
/>
|
||||
<ReservationDetails :reservation="reservation" :bikes="bikes" :omit="omitted" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<!-- Period -->
|
||||
<div class="grid gap-2">
|
||||
<div v-if="!emailsOnly" class="grid gap-2">
|
||||
<span id="edit-start-label" class="text-sm leading-none font-medium">
|
||||
{{ $t('reservation.start') }}
|
||||
</span>
|
||||
|
|
@ -194,7 +208,7 @@ function save() {
|
|||
<p v-if="errors.start" class="text-destructive text-xs">{{ errors.start }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div v-if="!emailsOnly" class="grid gap-2">
|
||||
<span id="edit-end-label" class="text-sm leading-none font-medium">
|
||||
{{ $t('reservation.end') }}
|
||||
</span>
|
||||
|
|
@ -211,8 +225,8 @@ function save() {
|
|||
</div>
|
||||
|
||||
<!-- Bikes -->
|
||||
<div class="grid gap-2">
|
||||
<span class="text-sm font-medium">{{ $t('admin.reservations.edit.bikes') }}</span>
|
||||
<div v-if="!emailsOnly" class="grid gap-2">
|
||||
<span class="text-sm font-medium">{{ $t('reservation.edit.bikes') }}</span>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="bike in bikes"
|
||||
|
|
@ -275,14 +289,10 @@ function save() {
|
|||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="update.isPending.value" @click="open = false">
|
||||
{{ $t('admin.reservations.edit.cancel') }}
|
||||
{{ $t('reservation.edit.cancel') }}
|
||||
</Button>
|
||||
<Button :disabled="update.isPending.value" @click="save()">
|
||||
{{
|
||||
update.isPending.value
|
||||
? $t('admin.reservations.edit.saving')
|
||||
: $t('admin.reservations.edit.save')
|
||||
}}
|
||||
{{ update.isPending.value ? $t('reservation.edit.saving') : $t('reservation.edit.save') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
|
|
|
|||
1139
frontend/src/lib/api.d.ts
vendored
1139
frontend/src/lib/api.d.ts
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,7 @@ app:
|
|||
title: Cargobikes
|
||||
header:
|
||||
admin: Admin
|
||||
my-reservations: My reservations
|
||||
logout: Log out
|
||||
logout-error: Unable to log out.
|
||||
reserve: Book
|
||||
|
|
@ -55,6 +56,47 @@ reservation:
|
|||
submitted: Request sent. It will show up in the pending requests.
|
||||
submitting: Sending…
|
||||
submit-error: Could not send the request.
|
||||
status:
|
||||
requested: Pending
|
||||
refused: Refused
|
||||
approved: Approved
|
||||
cancelled: Cancelled
|
||||
ongoing: Ongoing
|
||||
archived: Archived
|
||||
details:
|
||||
period: Period
|
||||
bikes: Cargobike(s)
|
||||
telegram: Telegram
|
||||
people: People
|
||||
linka: Linka Go accounts
|
||||
reason: Reason
|
||||
edit:
|
||||
action: Edit
|
||||
title: 'Reservation #{id}'
|
||||
intro: >-
|
||||
Only the period, the cargobikes and the Linka Go accounts can be changed.
|
||||
intro-emails: >-
|
||||
This reservation is approved: only the authorised Linka Go accounts can still
|
||||
be changed.
|
||||
bikes: Assigned cargobikes
|
||||
save: Save
|
||||
saving: Saving…
|
||||
cancel: Cancel
|
||||
saved: Reservation updated.
|
||||
error: Could not save the changes.
|
||||
my-reservations:
|
||||
title: My reservations
|
||||
intro: >-
|
||||
The reservations you asked for, and the ones you were added to.
|
||||
empty: You have no reservation yet.
|
||||
load-error: Unable to load your reservations.
|
||||
requested: Pending requests
|
||||
requested-empty: No pending request.
|
||||
active: Ongoing and upcoming
|
||||
active-empty: No ongoing or upcoming reservation.
|
||||
past: Past and cancelled
|
||||
past-empty: No past reservation.
|
||||
edit-emails: Edit Linka accounts
|
||||
login:
|
||||
title: Log in
|
||||
intro: Log in with your AGEPoly account to book a cargobike.
|
||||
|
|
@ -94,32 +136,8 @@ admin:
|
|||
show-archived: 'Show archived requests ({n})'
|
||||
hide-archived: Hide archived requests
|
||||
archived-empty: No archived request.
|
||||
period: Period
|
||||
bikes: Cargobike(s)
|
||||
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.
|
||||
status:
|
||||
requested: Pending
|
||||
refused: Refused
|
||||
approved: Approved
|
||||
cancelled: Cancelled
|
||||
ongoing: Ongoing
|
||||
archived: Archived
|
||||
action:
|
||||
approved: Approve
|
||||
refused: Refuse
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ app:
|
|||
title: Cargobikes
|
||||
header:
|
||||
admin: Admin
|
||||
my-reservations: Mes réservations
|
||||
logout: Se déconnecter
|
||||
logout-error: Impossible de se déconnecter.
|
||||
reserve: Réserver
|
||||
|
|
@ -56,6 +57,47 @@ reservation:
|
|||
submitted: Demande envoyée. Elle apparaîtra dans les demandes en attente.
|
||||
submitting: Envoi…
|
||||
submit-error: L'envoi de la demande a échoué.
|
||||
status:
|
||||
requested: En attente
|
||||
refused: Refusée
|
||||
approved: Validée
|
||||
cancelled: Annulée
|
||||
ongoing: En cours
|
||||
archived: Archivée
|
||||
details:
|
||||
period: Période
|
||||
bikes: Cargobike(s)
|
||||
telegram: Telegram
|
||||
people: Personnes
|
||||
linka: Comptes Linka Go
|
||||
reason: Raison
|
||||
edit:
|
||||
action: Modifier
|
||||
title: 'Réservation #{id}'
|
||||
intro: >-
|
||||
Seuls la période, les cargobikes et les comptes Linka Go peuvent être modifiés.
|
||||
intro-emails: >-
|
||||
Cette réservation est validée : seuls les comptes Linka Go autorisés peuvent
|
||||
encore être modifiés.
|
||||
bikes: Cargobikes attribués
|
||||
save: Enregistrer
|
||||
saving: Enregistrement…
|
||||
cancel: Annuler
|
||||
saved: Réservation mise à jour.
|
||||
error: La modification a échoué.
|
||||
my-reservations:
|
||||
title: Mes réservations
|
||||
intro: >-
|
||||
Les réservations que vous avez demandées et celles où vous avez été ajouté.
|
||||
empty: Vous n'avez aucune réservation pour le moment.
|
||||
load-error: Impossible de charger vos réservations.
|
||||
requested: Demandes en attente
|
||||
requested-empty: Aucune demande en attente.
|
||||
active: En cours et à venir
|
||||
active-empty: Aucune réservation en cours ou à venir.
|
||||
past: Passées et annulées
|
||||
past-empty: Aucune réservation passée.
|
||||
edit-emails: Modifier les comptes Linka
|
||||
login:
|
||||
title: Connexion
|
||||
intro: Connectez-vous avec votre compte AGEPoly pour réserver un cargobike.
|
||||
|
|
@ -95,32 +137,8 @@ admin:
|
|||
show-archived: 'Voir les demandes archivées ({n})'
|
||||
hide-archived: Masquer les demandes archivées
|
||||
archived-empty: Aucune demande archivée.
|
||||
period: Période
|
||||
bikes: Cargobike(s)
|
||||
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é.
|
||||
status:
|
||||
requested: En attente
|
||||
refused: Refusée
|
||||
approved: Validée
|
||||
cancelled: Annulée
|
||||
ongoing: En cours
|
||||
archived: Archivée
|
||||
action:
|
||||
approved: Valider
|
||||
refused: Refuser
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||
import { ensureSession } from '@/services/api/auth'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
import ReservationView from '@/views/ReservationView.vue'
|
||||
import MyReservationsView from '@/views/MyReservationsView.vue'
|
||||
import CalendarView from '@/views/CalendarView.vue'
|
||||
import AdminView from '@/views/AdminView.vue'
|
||||
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
|
||||
|
|
@ -26,6 +27,12 @@ const router = createRouter({
|
|||
component: ReservationView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
name: 'my-reservations',
|
||||
path: '/my-reservations',
|
||||
component: MyReservationsView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{ name: 'calendar', path: '/calendar', component: CalendarView },
|
||||
{
|
||||
name: 'admin',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { NewReservation, Reservation, ReservationEdit, ReservationStatus }
|
|||
import { getClient } from './client'
|
||||
|
||||
export const RESERVATIONS_KEY = ['reservations']
|
||||
export const MY_RESERVATIONS_KEY = ['reservations', 'mine']
|
||||
|
||||
export function useReservations() {
|
||||
return useQuery({
|
||||
|
|
@ -44,6 +45,28 @@ export function useSetReservationStatus() {
|
|||
queryClient.setQueryData<Reservation[]>(RESERVATIONS_KEY, (reservations) =>
|
||||
reservations?.map((r) => (r.id === id ? { ...r, status } : r)),
|
||||
)
|
||||
// "My reservations" holds its own list, and a status change moves a card
|
||||
// there too
|
||||
queryClient.invalidateQueries({ queryKey: MY_RESERVATIONS_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* reservation reaches somebody who was named before they ever logged in.
|
||||
*/
|
||||
export function useMyReservations() {
|
||||
return useQuery({
|
||||
queryKey: MY_RESERVATIONS_KEY,
|
||||
staleTime: 30 * 1000,
|
||||
queryFn: async () => {
|
||||
const { data, response } = await getClient().GET('/api/reservations/mine')
|
||||
if (response.status !== HttpStatus.OK) {
|
||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||
}
|
||||
return data ?? []
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -66,8 +89,8 @@ export function useCreateReservation() {
|
|||
}
|
||||
return data as Reservation
|
||||
},
|
||||
// The admin list is a different query: let it refetch rather than guessing
|
||||
// where the new reservation belongs in its ordering.
|
||||
// Both lists are separate queries: let them refetch rather than guessing
|
||||
// where the new reservation belongs in their ordering.
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
151
frontend/src/views/MyReservationsView.vue
Normal file
151
frontend/src/views/MyReservationsView.vue
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* The reservations the session user is part of — filed by them, or naming them
|
||||
* among the people or the Linka Go accounts.
|
||||
*
|
||||
* 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
|
||||
* only accepts a change of Linka Go accounts, and a final one is read-only.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
||||
import ReservationEditDialog from '@/components/reservation/ReservationEditDialog.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Pencil, Mail } from '@lucide/vue'
|
||||
import { useBikes } from '@/services/api/bikes'
|
||||
import { useMyReservations } from '@/services/api/reservations'
|
||||
import { unitLabel, type Reservation, type ReservationStatus } from '@/utils/types'
|
||||
|
||||
const { data: reservations, isPending, isError } = useMyReservations()
|
||||
const { data: bikes } = useBikes()
|
||||
|
||||
const BADGE_CLASS: Record<ReservationStatus, string> = {
|
||||
requested: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
approved: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
ongoing: 'bg-primary/15 text-primary',
|
||||
refused: 'bg-destructive/15 text-destructive',
|
||||
cancelled: 'bg-destructive/15 text-destructive',
|
||||
archived: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: 'requested', statuses: ['requested'] },
|
||||
{ key: 'active', statuses: ['approved', 'ongoing'] },
|
||||
{ key: 'past', statuses: ['refused', 'cancelled', 'archived'] },
|
||||
] as const
|
||||
|
||||
const sections = computed(() =>
|
||||
SECTIONS.map((section) => ({
|
||||
key: section.key,
|
||||
reservations: (reservations.value ?? []).filter((r) =>
|
||||
(section.statuses as readonly string[]).includes(r.status),
|
||||
),
|
||||
})),
|
||||
)
|
||||
|
||||
/** Which reservation the edit dialog is on, by id */
|
||||
const editing = ref<number | null>(null)
|
||||
const editingReservation = computed(() =>
|
||||
(reservations.value ?? []).find((r) => r.id === editing.value),
|
||||
)
|
||||
const dialogOpen = computed({
|
||||
get: () => editing.value !== null,
|
||||
set: (value) => {
|
||||
if (!value) editing.value = null
|
||||
},
|
||||
})
|
||||
|
||||
/** A request is open to everything; once approved, only the addresses are */
|
||||
function editableFully(reservation: Reservation) {
|
||||
return reservation.status === 'requested'
|
||||
}
|
||||
function editableEmails(reservation: Reservation) {
|
||||
return reservation.status === 'approved' || reservation.status === 'ongoing'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-xl">{{ $t('my-reservations.title') }}</CardTitle>
|
||||
<CardDescription>{{ $t('my-reservations.intro') }}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="grid gap-6">
|
||||
<div v-if="isPending" class="grid gap-3">
|
||||
<Skeleton v-for="i in 3" :key="i" class="h-32 w-full" />
|
||||
</div>
|
||||
|
||||
<p v-else-if="isError" class="text-destructive text-sm">
|
||||
{{ $t('my-reservations.load-error') }}
|
||||
</p>
|
||||
|
||||
<p v-else-if="!reservations?.length" class="text-muted-foreground text-sm">
|
||||
{{ $t('my-reservations.empty') }}
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<section v-for="section in sections" :key="section.key" class="grid min-w-0 gap-3">
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ $t(`my-reservations.${section.key}`) }}
|
||||
<span class="text-muted-foreground font-normal">
|
||||
({{ section.reservations.length }})
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<p v-if="!section.reservations.length" class="text-muted-foreground text-sm">
|
||||
{{ $t(`my-reservations.${section.key}-empty`) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="reservation in section.reservations"
|
||||
:key="reservation.id"
|
||||
class="rounded-lg border p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="font-semibold">#{{ reservation.id }}</span>
|
||||
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
|
||||
{{ $t(`reservation.status.${reservation.status}`) }}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{{ unitLabel(reservation.unit) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="editableFully(reservation) || editableEmails(reservation)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="editing = reservation.id"
|
||||
>
|
||||
<component :is="editableFully(reservation) ? Pencil : Mail" class="size-4" />
|
||||
{{
|
||||
editableFully(reservation)
|
||||
? $t('reservation.edit.action')
|
||||
: $t('my-reservations.edit-emails')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ReservationDetails class="mt-3" :reservation="reservation" :bikes="bikes ?? []" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<ReservationEditDialog
|
||||
v-if="editingReservation"
|
||||
v-model:open="dialogOpen"
|
||||
:reservation="editingReservation"
|
||||
:bikes="bikes ?? []"
|
||||
:emails-only="editableEmails(editingReservation)"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -36,6 +36,11 @@ 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"
|
||||
.api_route(
|
||||
"/mine",
|
||||
get_with(get_my_reservations, get_my_reservations_docs),
|
||||
)
|
||||
.api_route(
|
||||
"/{id}",
|
||||
put_with(update_reservation, update_reservation_docs),
|
||||
|
|
@ -100,6 +105,28 @@ fn create_reservation_docs(op: TransformOperation) -> TransformOperation {
|
|||
.response_with::<422, (), _>(desc("The unit or one of the bikes does not exist"))
|
||||
}
|
||||
|
||||
/// 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]
|
||||
async fn get_my_reservations(
|
||||
ac: AppController,
|
||||
) -> Result<Json<Vec<Reservation>>, (StatusCode, String)> {
|
||||
match ac.get_my_reservations().await {
|
||||
Ok(reservations) => Ok(Json(reservations)),
|
||||
Err(err) => unexpected_error("get_my_reservations", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_my_reservations_docs(op: TransformOperation) -> TransformOperation {
|
||||
op.tag("Reservations")
|
||||
.summary("Get 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.",
|
||||
)
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -138,10 +165,7 @@ async fn update_reservation(
|
|||
linka_emails: form.linka_emails,
|
||||
};
|
||||
|
||||
match manager(ac, current.unit.scope())?
|
||||
.update_reservation(edit)
|
||||
.await
|
||||
{
|
||||
match ac.update_reservation(edit).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(ControllerError::Reservation(
|
||||
err @ ReservationsControllerError::ReservationInvalid,
|
||||
|
|
@ -150,6 +174,10 @@ async fn update_reservation(
|
|||
err @ (ReservationsControllerError::BikeOutOfService(_)
|
||||
| ReservationsControllerError::ReservationFinal(_)),
|
||||
)) => Err((StatusCode::CONFLICT, err.to_string())),
|
||||
Err(ControllerError::Reservation(
|
||||
err @ (ReservationsControllerError::NotOnTheReservation
|
||||
| ReservationsControllerError::OnlyEmailsEditable(_)),
|
||||
)) => Err((StatusCode::FORBIDDEN, err.to_string())),
|
||||
Err(err) if err.is_not_found() => {
|
||||
Err((StatusCode::UNPROCESSABLE_ENTITY, "Unknown bike".to_owned()))
|
||||
}
|
||||
|
|
@ -161,10 +189,14 @@ 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.",
|
||||
"Everything else is left as stored. A manager may change all three until \
|
||||
the reservation is final; anybody else on it may do so only while it is \
|
||||
still a request, and afterwards only the Linka Go accounts.",
|
||||
)
|
||||
.response_with::<403, (), _>(manager_desc)
|
||||
.response_with::<403, (), _>(desc(
|
||||
"The user is not on the reservation, or tried to change more than the \
|
||||
Linka Go accounts on one that is already approved",
|
||||
))
|
||||
.response::<404, ()>()
|
||||
.response_with::<400, (), _>(desc("The reservation would become malformed"))
|
||||
.response_with::<409, (), _>(desc(
|
||||
|
|
|
|||
|
|
@ -37,6 +37,15 @@ impl AnonAppController {
|
|||
/// Filing a request is done in one's own name: the requester is the session
|
||||
/// user, never something the client gets to choose.
|
||||
impl AppController {
|
||||
/// Everything the session user is part of, whichever way.
|
||||
pub async fn get_my_reservations(&self) -> Result<Vec<Reservation>, ControllerError> {
|
||||
let user = self.user();
|
||||
self.db
|
||||
.get_involved_reservations(user.id, &user.email)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn create_reservation(
|
||||
&self,
|
||||
reservation: NewReservation,
|
||||
|
|
@ -71,8 +80,15 @@ impl AppController {
|
|||
}
|
||||
}
|
||||
|
||||
/// Touching an existing reservation is reserved to its unit (or an admin)
|
||||
impl ManagerAppController {
|
||||
impl AppController {
|
||||
/// Who may edit, and how much:
|
||||
///
|
||||
/// - the unit's managers and the admins: everything, until the reservation
|
||||
/// reaches a final state;
|
||||
/// - anybody the reservation names — the requester, the people on it, the
|
||||
/// Linka Go addresses — everything while it is still a request, and only
|
||||
/// the addresses once it has been approved;
|
||||
/// - anybody else: nothing.
|
||||
pub async fn update_reservation(
|
||||
&self,
|
||||
reservation: ReservationEdit,
|
||||
|
|
@ -82,13 +98,32 @@ impl ManagerAppController {
|
|||
}
|
||||
|
||||
let current = self.db.get_reservation(reservation.id).await?;
|
||||
if current.unit.scope() != self.unit {
|
||||
return Err(ControllerError::ImmutableUnitModificationError);
|
||||
}
|
||||
if current.status.is_final() {
|
||||
return Err(ReservationsControllerError::ReservationFinal(current.status).into());
|
||||
}
|
||||
|
||||
let user = self.user();
|
||||
let manages = user.admin
|
||||
|| current
|
||||
.unit
|
||||
.scope()
|
||||
.is_some_and(|unit| user.units.iter().any(|own| own.id == unit));
|
||||
|
||||
if !manages {
|
||||
if !self.is_involved_in(¤t) {
|
||||
return Err(ReservationsControllerError::NotOnTheReservation.into());
|
||||
}
|
||||
// Past the request stage the period and the fleet are settled, and
|
||||
// only the list of accounts allowed to unlock stays open.
|
||||
if current.status != ReservationStatus::Requested
|
||||
&& (reservation.start_time != current.start_time
|
||||
|| reservation.end_time != current.end_time
|
||||
|| !same_bikes(&reservation.bikes, ¤t.bikes))
|
||||
{
|
||||
return Err(ReservationsControllerError::OnlyEmailsEditable(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 {
|
||||
|
|
@ -106,6 +141,32 @@ impl ManagerAppController {
|
|||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// The same three ways `get_involved_reservations` looks for, applied to one
|
||||
/// reservation already in hand.
|
||||
fn is_involved_in(&self, reservation: &Reservation) -> bool {
|
||||
let user = self.user();
|
||||
reservation.requester == user.id
|
||||
|| reservation.users.iter().any(|other| other.id == user.id)
|
||||
|| reservation
|
||||
.linka_emails
|
||||
.iter()
|
||||
.any(|listed| listed.eq_ignore_ascii_case(&user.email))
|
||||
}
|
||||
}
|
||||
|
||||
/// Order carries no meaning here, so the two lists are compared as sets
|
||||
fn same_bikes(left: &[i32], right: &[i32]) -> bool {
|
||||
let mut left = left.to_vec();
|
||||
let mut right = right.to_vec();
|
||||
left.sort_unstable();
|
||||
left.dedup();
|
||||
right.sort_unstable();
|
||||
right.dedup();
|
||||
left == right
|
||||
}
|
||||
|
||||
/// Touching an existing reservation is reserved to its unit (or an admin)
|
||||
impl ManagerAppController {
|
||||
pub async fn set_reservation_status(
|
||||
&self,
|
||||
id: ReservationId,
|
||||
|
|
@ -148,4 +209,24 @@ pub enum ReservationsControllerError {
|
|||
BikeOutOfService(i32),
|
||||
#[error("The requester does not belong to unit {0}")]
|
||||
NotAMemberOfUnit(UnitId),
|
||||
#[error("The user is not part of this reservation")]
|
||||
NotOnTheReservation,
|
||||
#[error("A reservation that is {0:?} only accepts a change of Linka Go accounts")]
|
||||
OnlyEmailsEditable(ReservationStatus),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::same_bikes;
|
||||
|
||||
#[test]
|
||||
fn bike_lists_compare_as_sets() {
|
||||
assert!(same_bikes(&[1, 2], &[2, 1]), "order carries no meaning");
|
||||
assert!(same_bikes(&[1, 1, 2], &[2, 1]), "nor do repeats");
|
||||
assert!(same_bikes(&[], &[]));
|
||||
|
||||
assert!(!same_bikes(&[1, 2], &[1]), "one was removed");
|
||||
assert!(!same_bikes(&[1], &[1, 2]), "one was added");
|
||||
assert!(!same_bikes(&[1], &[2]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ pub trait ReservationsRepository {
|
|||
) -> Result<Vec<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
|
||||
/// requester comes from the session, not from the request body.
|
||||
async fn create_reservation(
|
||||
|
|
|
|||
|
|
@ -159,9 +159,15 @@ fn split_unit(unit: &NewReservationUnit) -> (Option<i32>, Option<String>) {
|
|||
|
||||
/// Surrounding spaces never belong to an address, and trimming is what turns a
|
||||
/// whitespace-only one into the empty string the `reservations_linka_emails_filled`
|
||||
/// check rejects.
|
||||
/// check rejects. Duplicates are dropped, ignoring case: authorising the same
|
||||
/// account twice means nothing, and the first spelling is the one kept.
|
||||
fn trim_emails(emails: &[String]) -> Vec<String> {
|
||||
emails.iter().map(|email| email.trim().to_owned()).collect()
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
emails
|
||||
.iter()
|
||||
.map(|email| email.trim().to_owned())
|
||||
.filter(|email| seen.insert(email.to_lowercase()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -257,6 +263,67 @@ impl ReservationsRepository for SqlxDatabase {
|
|||
.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> {
|
||||
Ok(query_as!(
|
||||
ReservationDB,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue