99 lines
3.5 KiB
Vue
99 lines
3.5 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* One reservation, with the transitions its current status allows. The buttons
|
|
* mirror `ReservationStatus::can_transition_to` on the backend, which re-checks
|
|
* and answers 409 on anything else.
|
|
*/
|
|
import { computed, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { Pencil } from '@lucide/vue'
|
|
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 { Button } from '@/components/ui/button'
|
|
import { useSetReservationStatus } from '@/services/api/reservations'
|
|
import { unitLabel, type Bike, type Reservation, type ReservationStatus } from '@/utils/types'
|
|
|
|
const props = defineProps<{ reservation: Reservation; bikes: Bike[] }>()
|
|
|
|
const { t } = useI18n()
|
|
const setStatus = useSetReservationStatus()
|
|
|
|
/** Kept in step with the state machine drawn on the backend enum */
|
|
const TRANSITIONS: Record<ReservationStatus, ReservationStatus[]> = {
|
|
requested: ['approved', 'refused'],
|
|
// No "start" here on purpose: a reservation becomes `ongoing` on its own, not
|
|
// by an admin pressing a button. The backend still allows the transition.
|
|
approved: ['cancelled'],
|
|
ongoing: ['archived', 'cancelled'],
|
|
refused: [],
|
|
cancelled: [],
|
|
archived: [],
|
|
}
|
|
|
|
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 transitions = computed(() => TRANSITIONS[props.reservation.status])
|
|
|
|
/** A reservation nobody can act on any more is not worth an edit button */
|
|
const editable = computed(
|
|
() => !['refused', 'cancelled', 'archived'].includes(props.reservation.status),
|
|
)
|
|
const editing = ref(false)
|
|
|
|
function move(status: ReservationStatus) {
|
|
setStatus.mutate(
|
|
{ id: props.reservation.id, status },
|
|
{ onError: () => toast.error(t('admin.reservations.error')) },
|
|
)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div 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>
|
|
|
|
<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
|
|
v-for="status in transitions"
|
|
:key="status"
|
|
size="sm"
|
|
:variant="status === 'approved' ? 'default' : 'outline'"
|
|
:disabled="setStatus.isPending.value"
|
|
@click="move(status)"
|
|
>
|
|
{{ $t(`admin.reservations.action.${status}`) }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<ReservationDetails class="mt-3" :reservation="reservation" :bikes="bikes" />
|
|
|
|
<ReservationEditDialog
|
|
v-if="editable"
|
|
v-model:open="editing"
|
|
:reservation="reservation"
|
|
:bikes="bikes"
|
|
/>
|
|
</div>
|
|
</template>
|