189 lines
7 KiB
TypeScript
189 lines
7 KiB
TypeScript
/**
|
|
* Reservations. Filing a request only needs a session; reading the list is an
|
|
* admin action, so `useReservations` is only ever mounted on /admin.
|
|
*
|
|
* Nothing here ever asks for "all the reservations": the status filter, the text
|
|
* search and the paging are query parameters the database applies. The table
|
|
* only grows, and an admin looking at three pending requests must not pay for
|
|
* the archive behind them.
|
|
*/
|
|
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
|
import { HttpStatus } from 'http-status-ts'
|
|
|
|
import type { NewReservation, Reservation, ReservationEdit, ReservationStatus } from '@/utils/types'
|
|
import { getClient } from './client'
|
|
|
|
export const RESERVATIONS_KEY = ['reservations']
|
|
export const MY_RESERVATIONS_KEY = ['reservations', 'mine']
|
|
export const CALENDAR_KEY = ['reservations', 'calendar']
|
|
|
|
/** A window of time, as the two calendar-shaped endpoints take it */
|
|
export type Window = { from?: Date; to?: Date }
|
|
|
|
export type ReservationFilters = Window & {
|
|
/** Empty or absent means any status */
|
|
status?: ReservationStatus[]
|
|
/** Free text, matched by the database across the whole reservation */
|
|
q?: string
|
|
limit?: number
|
|
offset?: number
|
|
}
|
|
|
|
/** The filters in the shape the query string takes: absent means "no filter" */
|
|
function toParams(filters: ReservationFilters) {
|
|
return {
|
|
status: filters.status?.length ? filters.status.join(',') : undefined,
|
|
q: filters.q?.trim() || undefined,
|
|
from: filters.from?.toISOString(),
|
|
to: filters.to?.toISOString(),
|
|
limit: filters.limit,
|
|
offset: filters.offset,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One page of the reservations matching `filters`, with the size of the whole
|
|
* match so the caller can page through it.
|
|
*/
|
|
export function useReservations(
|
|
filters: MaybeRefOrGetter<ReservationFilters> = () => ({}),
|
|
/** Left out, the query runs; the archive dialog only wants it while open */
|
|
options: { enabled?: MaybeRefOrGetter<boolean> } = {},
|
|
) {
|
|
const params = computed(() => toParams(toValue(filters)))
|
|
return useQuery({
|
|
queryKey: computed(() => [...RESERVATIONS_KEY, 'search', params.value]),
|
|
enabled: computed(() => toValue(options.enabled) ?? true),
|
|
staleTime: 30 * 1000,
|
|
// Paging or typing in the search must not blank the list under the cursor
|
|
placeholderData: keepPreviousData,
|
|
queryFn: async () => {
|
|
const { data, response } = await getClient().GET('/api/reservations', {
|
|
params: { query: params.value },
|
|
})
|
|
if (response.status !== HttpStatus.OK) {
|
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
|
}
|
|
return data ?? { items: [], total: 0 }
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The backend re-checks the state machine and answers 409 on a transition it
|
|
* does not allow, so the buttons only have to offer the plausible ones.
|
|
*/
|
|
export function useSetReservationStatus() {
|
|
const queryClient = useQueryClient()
|
|
return useMutation({
|
|
retry: 0,
|
|
mutationFn: async ({ id, status }: { id: number; status: ReservationStatus }) => {
|
|
await getClient().PUT('/api/reservations/{id}/status', {
|
|
params: { path: { id } },
|
|
body: { status },
|
|
})
|
|
return { id, status }
|
|
},
|
|
// A status change moves a reservation from one filtered list to another, so
|
|
// every page of every list is stale — patching one of them in place would
|
|
// leave the card in the section it just left.
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The public availability calendar: the approved and ongoing reservations over
|
|
* the week being drawn, without any personal field. No session needed.
|
|
*/
|
|
export function useCalendarReservations(window: MaybeRefOrGetter<Window> = () => ({})) {
|
|
const params = computed(() => {
|
|
const { from, to } = toValue(window)
|
|
return { from: from?.toISOString(), to: to?.toISOString() }
|
|
})
|
|
return useQuery({
|
|
queryKey: computed(() => [...CALENDAR_KEY, params.value]),
|
|
staleTime: 30 * 1000,
|
|
placeholderData: keepPreviousData,
|
|
queryFn: async () => {
|
|
const { data, response } = await getClient().GET('/api/reservations/calendar', {
|
|
params: { query: params.value },
|
|
})
|
|
if (response.status !== HttpStatus.OK) {
|
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
|
}
|
|
return data ?? []
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* One page of what 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.
|
|
*
|
|
* Same filters as the admin listing; who "mine" means is the session's business,
|
|
* not a parameter.
|
|
*/
|
|
export function useMyReservations(filters: MaybeRefOrGetter<ReservationFilters> = () => ({})) {
|
|
const params = computed(() => toParams(toValue(filters)))
|
|
return useQuery({
|
|
queryKey: computed(() => [...MY_RESERVATIONS_KEY, params.value]),
|
|
staleTime: 30 * 1000,
|
|
placeholderData: keepPreviousData,
|
|
queryFn: async () => {
|
|
const { data, response } = await getClient().GET('/api/reservations/mine', {
|
|
params: { query: params.value },
|
|
})
|
|
if (response.status !== HttpStatus.OK) {
|
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
|
}
|
|
return data ?? { items: [], total: 0 }
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Files a request. The backend fills in the requester and the `requested`
|
|
* status, so neither is part of the body.
|
|
*/
|
|
export function useCreateReservation() {
|
|
const queryClient = useQueryClient()
|
|
return useMutation({
|
|
retry: 0,
|
|
mutationFn: async (reservation: NewReservation) => {
|
|
const { data, response, error } = await getClient().POST('/api/reservations', {
|
|
body: reservation,
|
|
})
|
|
if (response.status !== HttpStatus.CREATED) {
|
|
// The handler answers with a plain string, which is what to show
|
|
throw new Error(typeof error === 'string' ? error : `Unexpected status: ${response.status}`)
|
|
}
|
|
return data as Reservation
|
|
},
|
|
// Both lists are separate queries: let them refetch rather than guessing
|
|
// where the new reservation belongs in their ordering.
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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 }),
|
|
})
|
|
}
|