Compare commits
4 commits
035f82cb88
...
34fbc2f3de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34fbc2f3de | ||
|
|
46c5edf5bd | ||
|
|
6c7f08ec43 | ||
|
|
0b161d83f6 |
31 changed files with 1672 additions and 146 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 }
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@
|
||||||
config.yml
|
config.yml
|
||||||
config.yaml
|
config.yaml
|
||||||
/LEGACY
|
/LEGACY
|
||||||
|
summary.ai
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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 { useRouter } from 'vue-router'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
|
@ -132,6 +132,13 @@ function signOut() {
|
||||||
<span class="text-muted-foreground block truncate text-xs">{{ user?.email }}</span>
|
<span class="text-muted-foreground block truncate text-xs">{{ user?.email }}</span>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem as-child>
|
||||||
|
<RouterLink :to="{ name: 'my-reservations' }">
|
||||||
|
<ClipboardList class="size-4" />
|
||||||
|
{{ $t('header.my-reservations') }}
|
||||||
|
</RouterLink>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem @click="signOut()">
|
<DropdownMenuItem @click="signOut()">
|
||||||
<LogOut class="size-4" />
|
<LogOut class="size-4" />
|
||||||
{{ $t('header.logout') }}
|
{{ $t('header.logout') }}
|
||||||
|
|
@ -173,6 +180,12 @@ function signOut() {
|
||||||
<p class="text-muted-foreground mt-2 truncate px-3 text-xs">
|
<p class="text-muted-foreground mt-2 truncate px-3 text-xs">
|
||||||
{{ user?.firstname }} {{ user?.name }}
|
{{ user?.firstname }} {{ user?.name }}
|
||||||
</p>
|
</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()">
|
<Button variant="outline" class="mt-1 justify-start" @click="signOut()">
|
||||||
<LogOut class="size-4" />
|
<LogOut class="size-4" />
|
||||||
{{ $t('header.logout') }}
|
{{ $t('header.logout') }}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@
|
||||||
* mirror `ReservationStatus::can_transition_to` on the backend, which re-checks
|
* mirror `ReservationStatus::can_transition_to` on the backend, which re-checks
|
||||||
* and answers 409 on anything else.
|
* and answers 409 on anything else.
|
||||||
*/
|
*/
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Pencil } from '@lucide/vue'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
||||||
|
import ReservationEditDialog from '@/components/reservation/ReservationEditDialog.vue'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useSetReservationStatus } from '@/services/api/reservations'
|
import { useSetReservationStatus } from '@/services/api/reservations'
|
||||||
|
|
@ -15,13 +18,15 @@ import { unitLabel, type Bike, type Reservation, type ReservationStatus } from '
|
||||||
|
|
||||||
const props = defineProps<{ reservation: Reservation; bikes: Bike[] }>()
|
const props = defineProps<{ reservation: Reservation; bikes: Bike[] }>()
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t } = useI18n()
|
||||||
const setStatus = useSetReservationStatus()
|
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: [],
|
||||||
|
|
@ -39,23 +44,11 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
|
||||||
|
|
||||||
const transitions = computed(() => TRANSITIONS[props.reservation.status])
|
const transitions = computed(() => TRANSITIONS[props.reservation.status])
|
||||||
|
|
||||||
const bikeNames = computed(() =>
|
/** A reservation nobody can act on any more is not worth an edit button */
|
||||||
props.reservation.bikes
|
const editable = computed(
|
||||||
.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`)
|
() => !['refused', 'cancelled', 'archived'].includes(props.reservation.status),
|
||||||
.join(', '),
|
|
||||||
)
|
)
|
||||||
|
const editing = ref(false)
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
function move(status: ReservationStatus) {
|
function move(status: ReservationStatus) {
|
||||||
setStatus.mutate(
|
setStatus.mutate(
|
||||||
|
|
@ -71,12 +64,16 @@ function move(status: ReservationStatus) {
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<span class="font-semibold">#{{ reservation.id }}</span>
|
<span class="font-semibold">#{{ reservation.id }}</span>
|
||||||
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
|
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
|
||||||
{{ $t(`admin.reservations.status.${reservation.status}`) }}
|
{{ $t(`reservation.status.${reservation.status}`) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span class="text-muted-foreground text-sm">{{ unitLabel(reservation.unit) }}</span>
|
<span class="text-muted-foreground text-sm">{{ unitLabel(reservation.unit) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="transitions.length" class="flex flex-wrap gap-2">
|
<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('reservation.edit.action') }}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-for="status in transitions"
|
v-for="status in transitions"
|
||||||
:key="status"
|
:key="status"
|
||||||
|
|
@ -90,33 +87,13 @@ function move(status: ReservationStatus) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl class="mt-3 grid gap-1 text-sm">
|
<ReservationDetails class="mt-3" :reservation="reservation" :bikes="bikes" />
|
||||||
<div class="flex gap-2">
|
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.period') }}</dt>
|
<ReservationEditDialog
|
||||||
<dd>{{ format(reservation.start_time) }} → {{ format(reservation.end_time) }}</dd>
|
v-if="editable"
|
||||||
</div>
|
v-model:open="editing"
|
||||||
<div class="flex gap-2">
|
:reservation="reservation"
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.bikes') }}</dt>
|
:bikes="bikes"
|
||||||
<dd>{{ bikeNames }}</dd>
|
/>
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.telegram') }}</dt>
|
|
||||||
<dd class="truncate">{{ reservation.telegram }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.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="reservation.linka_emails.length" class="flex gap-2">
|
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.linka') }}</dt>
|
|
||||||
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd>
|
|
||||||
</div>
|
|
||||||
<div v-if="reservation.description" class="flex gap-2">
|
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.reason') }}</dt>
|
|
||||||
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,28 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { unitLabel, type Bike, type Reservation } from '@/utils/types'
|
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogScrollContent,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
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')
|
||||||
|
|
@ -85,12 +98,24 @@ const booked = computed(() =>
|
||||||
props.reservations.filter((r) => r.status === 'approved' || r.status === 'ongoing'),
|
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<number | null>(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()
|
const today = new Date()
|
||||||
function isToday(day: Date) {
|
function isToday(day: Date) {
|
||||||
return day.toDateString() === today.toDateString()
|
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[] {
|
function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
const dayStart = new Date(day)
|
const dayStart = new Date(day)
|
||||||
|
|
@ -109,7 +134,7 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
|
|
||||||
const top = ((from.getTime() - dayStart.getTime()) / 3_600_000) * HOUR_HEIGHT
|
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)
|
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 }]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -119,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>
|
||||||
|
|
@ -142,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>
|
||||||
|
|
@ -157,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 -->
|
||||||
|
|
@ -165,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"
|
||||||
|
|
@ -214,15 +239,16 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
<!-- One lane per bike, blocks positioned inside -->
|
<!-- One lane per bike, blocks positioned inside -->
|
||||||
<div class="absolute inset-0 flex gap-px px-px">
|
<div class="absolute inset-0 flex gap-px px-px">
|
||||||
<div v-for="bike in lanes" :key="bike.id" class="relative min-w-0 flex-1">
|
<div v-for="bike in lanes" :key="bike.id" class="relative min-w-0 flex-1">
|
||||||
<div
|
<button
|
||||||
v-for="block in blocksFor(day, bike)"
|
v-for="block in blocksFor(day, bike)"
|
||||||
:key="`${block.id}-${bike.id}`"
|
:key="`${block.id}-${bike.id}`"
|
||||||
class="bg-primary text-primary-foreground absolute inset-x-0 overflow-hidden rounded-sm px-0.5 text-[0.6rem] leading-tight"
|
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` }"
|
:style="{ top: `${block.top}px`, height: `${block.height}px` }"
|
||||||
:title="`#${block.id} — ${block.label}`"
|
:title="$t('calendar.open', { id: block.id })"
|
||||||
>
|
:aria-label="$t('calendar.open', { id: block.id })"
|
||||||
{{ block.label }}
|
@click="opened = block.id"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -230,7 +256,22 @@ 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">
|
||||||
|
<DialogScrollContent v-if="openedReservation" class="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex flex-wrap items-center gap-2">
|
||||||
|
{{ $t('calendar.reservation', { id: openedReservation.id }) }}
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{ $t(`reservation.status.${openedReservation.status}`) }}
|
||||||
|
</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{{ unitLabel(openedReservation.unit) }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ReservationDetails :reservation="openedReservation" :bikes="bikes" />
|
||||||
|
</DialogScrollContent>
|
||||||
|
</Dialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
77
frontend/src/components/reservation/ReservationDetails.vue
Normal file
77
frontend/src/components/reservation/ReservationDetails.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* The read-only body of a reservation, shared by the admin card and the two
|
||||||
|
* dialogs so the three never drift apart.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { Bike, ReservationLike } from '@/utils/types'
|
||||||
|
|
||||||
|
type Field = 'period' | 'bikes' | 'telegram' | 'people' | 'linka' | 'reason'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
reservation: ReservationLike
|
||||||
|
bikes: Bike[]
|
||||||
|
/** Fields the caller renders itself — the edit dialog omits what it edits */
|
||||||
|
omit?: Field[]
|
||||||
|
}>(),
|
||||||
|
{ omit: () => [] },
|
||||||
|
)
|
||||||
|
|
||||||
|
function shows(field: Field) {
|
||||||
|
return !props.omit.includes(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
const bikeNames = computed(() =>
|
||||||
|
props.reservation.bikes
|
||||||
|
.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`)
|
||||||
|
.join(', '),
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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('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('reservation.details.bikes') }}</dt>
|
||||||
|
<dd>{{ bikeNames }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="shows('telegram') && reservation.telegram" class="flex gap-2">
|
||||||
|
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.telegram') }}</dt>
|
||||||
|
<dd class="truncate">{{ reservation.telegram }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="shows('people') && reservation.users?.length" class="flex gap-2">
|
||||||
|
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.people') }}</dt>
|
||||||
|
<dd class="min-w-0 break-words">
|
||||||
|
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="shows('linka') && reservation.linka_emails?.length" class="flex gap-2">
|
||||||
|
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.linka') }}</dt>
|
||||||
|
<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('reservation.details.reason') }}</dt>
|
||||||
|
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</template>
|
||||||
300
frontend/src/components/reservation/ReservationEditDialog.vue
Normal file
300
frontend/src/components/reservation/ReservationEditDialog.vue
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
import { Plus, X } from '@lucide/vue'
|
||||||
|
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
import DatePicker from '@/components/DatePicker.vue'
|
||||||
|
import TimePicker from '@/components/TimePicker.vue'
|
||||||
|
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogScrollContent,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useUpdateReservation } from '@/services/api/reservations'
|
||||||
|
import { unitLabel, type Bike, type Reservation } from '@/utils/types'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{ reservation: Reservation; bikes: Bike[]; emailsOnly?: boolean }>(),
|
||||||
|
{ emailsOnly: false },
|
||||||
|
)
|
||||||
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const update = useUpdateReservation()
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
const SLOT_MINUTES = 15
|
||||||
|
|
||||||
|
const startDate = shallowRef<DateValue>()
|
||||||
|
const endDate = shallowRef<DateValue>()
|
||||||
|
const form = reactive({
|
||||||
|
startTime: '' as string | undefined,
|
||||||
|
endTime: '' as string | undefined,
|
||||||
|
bikes: [] as number[],
|
||||||
|
emails: [''],
|
||||||
|
})
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
|
function toCalendarDate(date: Date): DateValue {
|
||||||
|
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picker only offers quarter hours; anything else snaps to the nearest one */
|
||||||
|
function toSlot(date: Date): string {
|
||||||
|
const minutes = Math.round((date.getHours() * 60 + date.getMinutes()) / SLOT_MINUTES)
|
||||||
|
const total = (minutes * SLOT_MINUTES) % (24 * 60)
|
||||||
|
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reloads the fields from the reservation, so cancelling really cancels */
|
||||||
|
function load() {
|
||||||
|
const start = new Date(props.reservation.start_time)
|
||||||
|
const end = new Date(props.reservation.end_time)
|
||||||
|
startDate.value = toCalendarDate(start)
|
||||||
|
endDate.value = toCalendarDate(end)
|
||||||
|
form.startTime = toSlot(start)
|
||||||
|
form.endTime = toSlot(end)
|
||||||
|
form.bikes = [...props.reservation.bikes]
|
||||||
|
form.emails = props.reservation.linka_emails.length ? [...props.reservation.linka_emails] : ['']
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key])
|
||||||
|
submitted.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, (isOpen) => isOpen && load(), { immediate: true })
|
||||||
|
|
||||||
|
function toDate(date: DateValue | undefined, time: string | undefined): Date | null {
|
||||||
|
if (!date || !time) return null
|
||||||
|
const [hours, minutes] = time.split(':').map(Number)
|
||||||
|
const local = date.toDate(getLocalTimeZone())
|
||||||
|
local.setHours(hours, minutes, 0, 0)
|
||||||
|
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))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bike already on the reservation stays pickable even if it has since gone out
|
||||||
|
* of service — the same rule the backend applies.
|
||||||
|
*/
|
||||||
|
function isBlocked(bike: Bike) {
|
||||||
|
return bike.status === 'out_of_service' && !props.reservation.bikes.includes(bike.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBike(bike: Bike) {
|
||||||
|
if (isBlocked(bike)) return
|
||||||
|
const index = form.bikes.indexOf(bike.id)
|
||||||
|
if (index >= 0) form.bikes.splice(index, 1)
|
||||||
|
else form.bikes.push(bike.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEmail() {
|
||||||
|
form.emails.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEmail(index: number) {
|
||||||
|
form.emails.splice(index, 1)
|
||||||
|
if (form.emails.length === 0) form.emails.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
else if (!emails.every((email) => EMAIL_RE.test(email))) {
|
||||||
|
errors.emails = t('reservation.error-email')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(errors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
submitted.value = true
|
||||||
|
if (!validate()) {
|
||||||
|
toast.error(t('reservation.error-form'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
update.mutate(
|
||||||
|
{
|
||||||
|
id: props.reservation.id,
|
||||||
|
// 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('reservation.edit.saved'))
|
||||||
|
open.value = false
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message || t('reservation.edit.error')),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="open">
|
||||||
|
<DialogScrollContent class="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="flex flex-wrap items-center gap-2">
|
||||||
|
{{ $t('reservation.edit.title', { id: reservation.id }) }}
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{ $t(`reservation.status.${reservation.status}`) }}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground text-sm font-normal">
|
||||||
|
{{ unitLabel(reservation.unit) }}
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<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="omitted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<!-- Period -->
|
||||||
|
<div v-if="!emailsOnly" class="grid gap-2">
|
||||||
|
<span id="edit-start-label" class="text-sm leading-none font-medium">
|
||||||
|
{{ $t('reservation.start') }}
|
||||||
|
</span>
|
||||||
|
<div role="group" aria-labelledby="edit-start-label" class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<DatePicker id="edit-start-date" v-model="startDate" :invalid="!!errors.start" />
|
||||||
|
<TimePicker id="edit-start-time" v-model="form.startTime" :invalid="!!errors.start" />
|
||||||
|
</div>
|
||||||
|
<p v-if="errors.start" class="text-destructive text-xs">{{ errors.start }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!emailsOnly" class="grid gap-2">
|
||||||
|
<span id="edit-end-label" class="text-sm leading-none font-medium">
|
||||||
|
{{ $t('reservation.end') }}
|
||||||
|
</span>
|
||||||
|
<div role="group" aria-labelledby="edit-end-label" class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<DatePicker
|
||||||
|
id="edit-end-date"
|
||||||
|
v-model="endDate"
|
||||||
|
:min-value="startDate"
|
||||||
|
:invalid="!!errors.end"
|
||||||
|
/>
|
||||||
|
<TimePicker id="edit-end-time" v-model="form.endTime" :invalid="!!errors.end" />
|
||||||
|
</div>
|
||||||
|
<p v-if="errors.end" class="text-destructive text-xs">{{ errors.end }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bikes -->
|
||||||
|
<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"
|
||||||
|
:key="bike.id"
|
||||||
|
type="button"
|
||||||
|
:disabled="isBlocked(bike)"
|
||||||
|
:aria-pressed="form.bikes.includes(bike.id)"
|
||||||
|
class="flex h-9 items-center justify-between gap-2 rounded-md border px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:class="
|
||||||
|
form.bikes.includes(bike.id)
|
||||||
|
? 'border-primary bg-primary/10 text-foreground'
|
||||||
|
: 'hover:bg-accent hover:text-accent-foreground'
|
||||||
|
"
|
||||||
|
@click="toggleBike(bike)"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ bike.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="bike.status === 'out_of_service'"
|
||||||
|
class="text-destructive shrink-0 text-[0.65rem] font-bold uppercase"
|
||||||
|
>
|
||||||
|
{{ $t('reservation.bike-out-of-service') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Linka Go accounts -->
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="edit-email-0">{{ $t('reservation.emails') }}</Label>
|
||||||
|
<div v-for="(_, index) in form.emails" :key="index" class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
:id="`edit-email-${index}`"
|
||||||
|
v-model.trim="form.emails[index]"
|
||||||
|
type="email"
|
||||||
|
:aria-label="$t('reservation.email-nth', { n: index + 1 })"
|
||||||
|
placeholder="prenom.nom@exemple.com"
|
||||||
|
:aria-invalid="!!errors.emails || undefined"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="form.emails.length > 1"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="$t('reservation.remove-email')"
|
||||||
|
@click="removeEmail(index)"
|
||||||
|
>
|
||||||
|
<X class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button type="button" variant="secondary" size="sm" @click="addEmail()">
|
||||||
|
<Plus class="size-4" />
|
||||||
|
{{ $t('reservation.add-email') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p v-if="errors.emails" class="text-destructive text-xs">{{ errors.emails }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" :disabled="update.isPending.value" @click="open = false">
|
||||||
|
{{ $t('reservation.edit.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button :disabled="update.isPending.value" @click="save()">
|
||||||
|
{{ update.isPending.value ? $t('reservation.edit.saving') : $t('reservation.edit.save') }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogScrollContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
15
frontend/src/components/ui/dialog/Dialog.vue
Normal file
15
frontend/src/components/ui/dialog/Dialog.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||||
|
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot v-bind="forwarded">
|
||||||
|
<slot />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
12
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
12
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogCloseProps } from 'reka-ui'
|
||||||
|
import { DialogClose } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
47
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
47
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { X } from '@lucide/vue'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
|
/>
|
||||||
|
<DialogContent
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
22
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
22
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogDescriptionProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogDescription, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
12
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
12
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
14
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
14
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
60
frontend/src/components/ui/dialog/DialogScrollContent.vue
Normal file
60
frontend/src/components/ui/dialog/DialogScrollContent.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { X } from '@lucide/vue'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="forwarded"
|
||||||
|
@pointer-down-outside="
|
||||||
|
(event) => {
|
||||||
|
const originalEvent = event.detail.originalEvent
|
||||||
|
const target = originalEvent.target as HTMLElement
|
||||||
|
if (
|
||||||
|
originalEvent.offsetX > target.clientWidth ||
|
||||||
|
originalEvent.offsetY > target.clientHeight
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="absolute top-3 right-3 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogOverlay>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
22
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
22
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTitleProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogTitle, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-lg font-semibold leading-none tracking-tight', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
12
frontend/src/components/ui/dialog/DialogTrigger.vue
Normal file
12
frontend/src/components/ui/dialog/DialogTrigger.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTriggerProps } from 'reka-ui'
|
||||||
|
import { DialogTrigger } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
9
frontend/src/components/ui/dialog/index.ts
Normal file
9
frontend/src/components/ui/dialog/index.ts
Normal file
|
|
@ -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'
|
||||||
205
frontend/src/lib/api.d.ts
vendored
205
frontend/src/lib/api.d.ts
vendored
|
|
@ -507,6 +507,180 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
|
'/api/reservations/mine': {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody?: never
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Reservation'][]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** @description Unauthenticated - a session is required */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content?: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put?: never
|
||||||
|
post?: never
|
||||||
|
delete?: never
|
||||||
|
options?: never
|
||||||
|
head?: never
|
||||||
|
patch?: never
|
||||||
|
trace?: never
|
||||||
|
}
|
||||||
|
'/api/reservations/calendar': {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody?: never
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['CalendarReservation'][]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put?: never
|
||||||
|
post?: never
|
||||||
|
delete?: never
|
||||||
|
options?: never
|
||||||
|
head?: never
|
||||||
|
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 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.
|
||||||
|
*/
|
||||||
|
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 The user is not on the reservation, or tried to change more than the Linka Go accounts on one that is already approved */
|
||||||
|
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': {
|
'/api/reservations/{id}/status': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
|
@ -602,6 +776,23 @@ export interface components {
|
||||||
BikeSize: 'large' | 'small'
|
BikeSize: 'large' | 'small'
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
BikeStatus: 'in_service' | 'out_of_service'
|
BikeStatus: 'in_service' | 'out_of_service'
|
||||||
|
/**
|
||||||
|
* @description 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.
|
||||||
|
*/
|
||||||
|
CalendarReservation: {
|
||||||
|
bikes: number[]
|
||||||
|
/** Format: date-time */
|
||||||
|
end_time: string
|
||||||
|
/** Format: int32 */
|
||||||
|
id: number
|
||||||
|
/** Format: date-time */
|
||||||
|
start_time: string
|
||||||
|
status: components['schemas']['ReservationStatus']
|
||||||
|
unit: components['schemas']['ReservationUnit']
|
||||||
|
}
|
||||||
DevUser: {
|
DevUser: {
|
||||||
admin: boolean
|
admin: boolean
|
||||||
email: string
|
email: string
|
||||||
|
|
@ -670,6 +861,20 @@ export interface components {
|
||||||
unit: components['schemas']['ReservationUnit']
|
unit: components['schemas']['ReservationUnit']
|
||||||
users: components['schemas']['UserSummary'][]
|
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} */
|
/** @enum {string} */
|
||||||
ReservationStatus: 'requested' | 'refused' | 'approved' | 'cancelled' | 'ongoing' | 'archived'
|
ReservationStatus: 'requested' | 'refused' | 'approved' | 'cancelled' | 'ongoing' | 'archived'
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ app:
|
||||||
title: Cargobikes
|
title: Cargobikes
|
||||||
header:
|
header:
|
||||||
admin: Admin
|
admin: Admin
|
||||||
|
my-reservations: My reservations
|
||||||
logout: Log out
|
logout: Log out
|
||||||
logout-error: Unable to log out.
|
logout-error: Unable to log out.
|
||||||
reserve: Book
|
reserve: Book
|
||||||
|
|
@ -55,6 +56,47 @@ reservation:
|
||||||
submitted: Request sent. It will show up in the pending requests.
|
submitted: Request sent. It will show up in the pending requests.
|
||||||
submitting: Sending…
|
submitting: Sending…
|
||||||
submit-error: Could not send the request.
|
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:
|
login:
|
||||||
title: Log in
|
title: Log in
|
||||||
intro: Log in with your AGEPoly account to book a cargobike.
|
intro: Log in with your AGEPoly account to book a cargobike.
|
||||||
|
|
@ -65,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.
|
||||||
|
|
@ -94,35 +147,11 @@ admin:
|
||||||
show-archived: 'Show archived requests ({n})'
|
show-archived: 'Show archived requests ({n})'
|
||||||
hide-archived: Hide archived requests
|
hide-archived: Hide archived requests
|
||||||
archived-empty: No archived request.
|
archived-empty: No archived request.
|
||||||
period: Period
|
|
||||||
bikes: Cargobike(s)
|
|
||||||
telegram: Telegram
|
|
||||||
people: People
|
|
||||||
linka: Linka Go accounts
|
|
||||||
reason: Reason
|
|
||||||
load-error: Unable to load the reservations.
|
load-error: Unable to load the reservations.
|
||||||
error: The status change failed.
|
error: The status change failed.
|
||||||
status:
|
|
||||||
requested: Pending
|
|
||||||
refused: Refused
|
|
||||||
approved: Approved
|
|
||||||
cancelled: Cancelled
|
|
||||||
ongoing: Ongoing
|
|
||||||
archived: Archived
|
|
||||||
action:
|
action:
|
||||||
approved: Approve
|
approved: Approve
|
||||||
refused: Refuse
|
refused: Refuse
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ app:
|
||||||
title: Cargobikes
|
title: Cargobikes
|
||||||
header:
|
header:
|
||||||
admin: Admin
|
admin: Admin
|
||||||
|
my-reservations: Mes réservations
|
||||||
logout: Se déconnecter
|
logout: Se déconnecter
|
||||||
logout-error: Impossible de se déconnecter.
|
logout-error: Impossible de se déconnecter.
|
||||||
reserve: Réserver
|
reserve: Réserver
|
||||||
|
|
@ -56,6 +57,47 @@ reservation:
|
||||||
submitted: Demande envoyée. Elle apparaîtra dans les demandes en attente.
|
submitted: Demande envoyée. Elle apparaîtra dans les demandes en attente.
|
||||||
submitting: Envoi…
|
submitting: Envoi…
|
||||||
submit-error: L'envoi de la demande a échoué.
|
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:
|
login:
|
||||||
title: Connexion
|
title: Connexion
|
||||||
intro: Connectez-vous avec votre compte AGEPoly pour réserver un cargobike.
|
intro: Connectez-vous avec votre compte AGEPoly pour réserver un cargobike.
|
||||||
|
|
@ -66,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.
|
||||||
|
|
@ -95,35 +148,11 @@ admin:
|
||||||
show-archived: 'Voir les demandes archivées ({n})'
|
show-archived: 'Voir les demandes archivées ({n})'
|
||||||
hide-archived: Masquer les demandes archivées
|
hide-archived: Masquer les demandes archivées
|
||||||
archived-empty: Aucune demande archivée.
|
archived-empty: Aucune demande archivée.
|
||||||
period: Période
|
|
||||||
bikes: Cargobike(s)
|
|
||||||
telegram: Telegram
|
|
||||||
people: Personnes
|
|
||||||
linka: Comptes Linka Go
|
|
||||||
reason: Raison
|
|
||||||
load-error: Impossible de charger les réservations.
|
load-error: Impossible de charger les réservations.
|
||||||
error: Le changement de statut a échoué.
|
error: Le changement de statut a échoué.
|
||||||
status:
|
|
||||||
requested: En attente
|
|
||||||
refused: Refusée
|
|
||||||
approved: Validée
|
|
||||||
cancelled: Annulée
|
|
||||||
ongoing: En cours
|
|
||||||
archived: Archivée
|
|
||||||
action:
|
action:
|
||||||
approved: Valider
|
approved: Valider
|
||||||
refused: Refuser
|
refused: Refuser
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { ensureSession } from '@/services/api/auth'
|
import { ensureSession } from '@/services/api/auth'
|
||||||
import LoginView from '@/views/LoginView.vue'
|
import LoginView from '@/views/LoginView.vue'
|
||||||
import ReservationView from '@/views/ReservationView.vue'
|
import ReservationView from '@/views/ReservationView.vue'
|
||||||
|
import MyReservationsView from '@/views/MyReservationsView.vue'
|
||||||
import CalendarView from '@/views/CalendarView.vue'
|
import CalendarView from '@/views/CalendarView.vue'
|
||||||
import AdminView from '@/views/AdminView.vue'
|
import AdminView from '@/views/AdminView.vue'
|
||||||
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
|
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
|
||||||
|
|
@ -26,6 +27,12 @@ const router = createRouter({
|
||||||
component: ReservationView,
|
component: ReservationView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'my-reservations',
|
||||||
|
path: '/my-reservations',
|
||||||
|
component: MyReservationsView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{ name: 'calendar', path: '/calendar', component: CalendarView },
|
{ name: 'calendar', path: '/calendar', component: CalendarView },
|
||||||
{
|
{
|
||||||
name: 'admin',
|
name: 'admin',
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { HttpStatus } from 'http-status-ts'
|
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'
|
import { getClient } from './client'
|
||||||
|
|
||||||
export const RESERVATIONS_KEY = ['reservations']
|
export const RESERVATIONS_KEY = ['reservations']
|
||||||
|
export const MY_RESERVATIONS_KEY = ['reservations', 'mine']
|
||||||
|
export const CALENDAR_KEY = ['reservations', 'calendar']
|
||||||
|
|
||||||
export function useReservations() {
|
export function useReservations() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|
@ -44,6 +46,46 @@ export function useSetReservationStatus() {
|
||||||
queryClient.setQueryData<Reservation[]>(RESERVATIONS_KEY, (reservations) =>
|
queryClient.setQueryData<Reservation[]>(RESERVATIONS_KEY, (reservations) =>
|
||||||
reservations?.map((r) => (r.id === id ? { ...r, status } : r)),
|
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 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* 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 +108,29 @@ export function useCreateReservation() {
|
||||||
}
|
}
|
||||||
return data as Reservation
|
return data as Reservation
|
||||||
},
|
},
|
||||||
// The admin list is a different query: let it refetch rather than guessing
|
// Both lists are separate queries: let them refetch rather than guessing
|
||||||
// where the new reservation belongs in its ordering.
|
// where the new reservation belongs in their ordering.
|
||||||
|
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 }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,17 @@ export type ReservationStatus = components['schemas']['ReservationStatus']
|
||||||
export type UserSummary = components['schemas']['UserSummary']
|
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 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>
|
||||||
|
|
|
||||||
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>
|
||||||
|
|
@ -14,14 +14,24 @@ use aide::{
|
||||||
transform::TransformOperation,
|
transform::TransformOperation,
|
||||||
};
|
};
|
||||||
use axum::{Json, extract::Path, http::StatusCode};
|
use axum::{Json, extract::Path, http::StatusCode};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
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::{
|
||||||
models::reservation::{NewReservation, Reservation, ReservationStatus},
|
AnonAppController, AppController, ControllerError,
|
||||||
|
reservations::ReservationsControllerError,
|
||||||
|
},
|
||||||
|
models::{
|
||||||
|
bike::BikeId,
|
||||||
|
reservation::{
|
||||||
|
CalendarReservation, NewReservation, Reservation, ReservationEdit,
|
||||||
|
ReservationStatus,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -32,6 +42,19 @@ 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 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),
|
||||||
|
)
|
||||||
.api_route("/{id}/status", put_with(set_status, set_status_docs))
|
.api_route("/{id}/status", put_with(set_status, set_status_docs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +115,127 @@ 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
|
||||||
|
/// 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
|
||||||
|
/// rather than trusting a client to send them unchanged.
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct ReservationEditForm {
|
||||||
|
start_time: DateTime<Utc>,
|
||||||
|
end_time: DateTime<Utc>,
|
||||||
|
bikes: Vec<BikeId>,
|
||||||
|
linka_emails: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
async fn update_reservation(
|
||||||
|
ac: AppController,
|
||||||
|
Path(IdPath { id }): Path<IdPath>,
|
||||||
|
Json(form): Json<ReservationEditForm>,
|
||||||
|
) -> 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 ac.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(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()))
|
||||||
|
}
|
||||||
|
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 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, (), _>(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(
|
||||||
|
"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)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct SetStatusForm {
|
struct SetStatusForm {
|
||||||
status: ReservationStatus,
|
status: ReservationStatus,
|
||||||
|
|
|
||||||
|
|
@ -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,11 +32,40 @@ 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
|
||||||
/// user, never something the client gets to choose.
|
/// user, never something the client gets to choose.
|
||||||
impl AppController {
|
impl AppController {
|
||||||
|
/// Everything the session user is part of, whichever way.
|
||||||
|
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(
|
pub async fn create_reservation(
|
||||||
&self,
|
&self,
|
||||||
reservation: NewReservation,
|
reservation: NewReservation,
|
||||||
|
|
@ -71,8 +100,15 @@ impl AppController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Touching an existing reservation is reserved to its unit (or an admin)
|
impl AppController {
|
||||||
impl ManagerAppController {
|
/// 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(
|
pub async fn update_reservation(
|
||||||
&self,
|
&self,
|
||||||
reservation: ReservationEdit,
|
reservation: ReservationEdit,
|
||||||
|
|
@ -82,19 +118,75 @@ impl ManagerAppController {
|
||||||
}
|
}
|
||||||
|
|
||||||
let current = self.db.get_reservation(reservation.id).await?;
|
let current = self.db.get_reservation(reservation.id).await?;
|
||||||
if current.unit.scope() != self.unit {
|
|
||||||
return Err(ControllerError::ImmutableUnitModificationError);
|
|
||||||
}
|
|
||||||
if current.status.is_final() {
|
if current.status.is_final() {
|
||||||
return Err(ReservationsControllerError::ReservationFinal(current.status).into());
|
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 {
|
||||||
|
if current.bikes.contains(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.get_bike(*id).await?.status == BikeStatus::OutOfService {
|
||||||
|
return Err(ReservationsControllerError::BikeOutOfService(*id).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.db
|
self.db
|
||||||
.update_reservation(reservation)
|
.update_reservation(reservation)
|
||||||
.await
|
.await
|
||||||
.map_err(Into::into)
|
.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(
|
pub async fn set_reservation_status(
|
||||||
&self,
|
&self,
|
||||||
id: ReservationId,
|
id: ReservationId,
|
||||||
|
|
@ -137,4 +229,24 @@ pub enum ReservationsControllerError {
|
||||||
BikeOutOfService(i32),
|
BikeOutOfService(i32),
|
||||||
#[error("The requester does not belong to unit {0}")]
|
#[error("The requester does not belong to unit {0}")]
|
||||||
NotAMemberOfUnit(UnitId),
|
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]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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
|
/// typed name: nobody is the manager of a unit we do not know, so only an
|
||||||
/// admin qualifies.
|
/// admin qualifies.
|
||||||
|
|
@ -112,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,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,16 @@ pub trait ReservationsRepository {
|
||||||
) -> Result<Vec<Reservation>, RepositoryError>;
|
) -> Result<Vec<Reservation>, RepositoryError>;
|
||||||
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError>;
|
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError>;
|
||||||
|
|
||||||
|
/// Everything the user is part of: what they filed, what they were added
|
||||||
|
/// to, and what lists their address among the Linka Go accounts. The email
|
||||||
|
/// is what binds a reservation to somebody who had never logged in when it
|
||||||
|
/// was filed.
|
||||||
|
async fn get_involved_reservations(
|
||||||
|
&self,
|
||||||
|
user: UserId,
|
||||||
|
email: &str,
|
||||||
|
) -> Result<Vec<Reservation>, RepositoryError>;
|
||||||
|
|
||||||
/// Always stored as `Requested`: the state machine starts here. The
|
/// Always stored as `Requested`: the state machine starts here. The
|
||||||
/// requester comes from the session, not from the request body.
|
/// requester comes from the session, not from the request body.
|
||||||
async fn create_reservation(
|
async fn create_reservation(
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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`
|
/// 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> {
|
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]
|
#[async_trait]
|
||||||
|
|
@ -257,6 +263,67 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
.collect::<Result<Vec<_>, _>>()?)
|
.collect::<Result<Vec<_>, _>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_involved_reservations(
|
||||||
|
&self,
|
||||||
|
user: UserId,
|
||||||
|
email: &str,
|
||||||
|
) -> Result<Vec<Reservation>, RepositoryError> {
|
||||||
|
Ok(query_as!(
|
||||||
|
ReservationDB,
|
||||||
|
r#"SELECT
|
||||||
|
r.id,
|
||||||
|
r.unit_id,
|
||||||
|
-- `?` forces the nullability sqlx cannot infer: `units.name` is
|
||||||
|
-- NOT NULL, but the LEFT JOIN makes it null for a free label
|
||||||
|
un."name" AS "unit_name?",
|
||||||
|
r.unit_label,
|
||||||
|
r.start_time,
|
||||||
|
r.end_time,
|
||||||
|
r.requester_id,
|
||||||
|
r.telegram,
|
||||||
|
r."description",
|
||||||
|
r.linka_emails,
|
||||||
|
r.status AS "status: ReservationStatusDB",
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object(
|
||||||
|
'id', u.id,
|
||||||
|
'firstname', u.firstname,
|
||||||
|
'name', u."name",
|
||||||
|
'email', u.email
|
||||||
|
) ORDER BY u."name", u.firstname)
|
||||||
|
FROM reservations_users ru
|
||||||
|
JOIN users u ON u.id = ru.user_id
|
||||||
|
WHERE ru.reservation_id = r.id
|
||||||
|
), '[]'::json) AS "users!",
|
||||||
|
ARRAY(
|
||||||
|
SELECT bike_id FROM reservations_bikes
|
||||||
|
WHERE reservation_id = r.id ORDER BY bike_id
|
||||||
|
) AS "bikes!"
|
||||||
|
FROM reservations r
|
||||||
|
LEFT JOIN units un ON un.id = r.unit_id
|
||||||
|
-- Three ways to be part of a reservation. The last one is what
|
||||||
|
-- lets somebody added by address see it the first time they log in,
|
||||||
|
-- without anything having to be written at login time.
|
||||||
|
WHERE r.requester_id = $1
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM reservations_users ru
|
||||||
|
WHERE ru.reservation_id = r.id AND ru.user_id = $1
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM unnest(r.linka_emails) AS listed
|
||||||
|
WHERE lower(listed) = lower($2)
|
||||||
|
)
|
||||||
|
ORDER BY r.start_time DESC"#,
|
||||||
|
user,
|
||||||
|
email
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(TryInto::try_into)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> {
|
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> {
|
||||||
Ok(query_as!(
|
Ok(query_as!(
|
||||||
ReservationDB,
|
ReservationDB,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue