feat(bookings): allow cancelling booking confirmations
Add API endpoint to revert confirmed bookings to pending status Add unconfirm buttons to the bookings list and confirmation page Update inventory summary when a confirmation is cancelled
This commit is contained in:
@@ -302,6 +302,17 @@
|
||||
icon="i-lucide-receipt"
|
||||
size="sm"
|
||||
/>
|
||||
<UButton
|
||||
v-if="row.original.status === 'confirmed'"
|
||||
label="Unconfirm"
|
||||
color="error"
|
||||
variant="outline"
|
||||
icon="i-lucide-x-circle"
|
||||
size="sm"
|
||||
:loading="cancellingBookingId === row.original.id"
|
||||
:disabled="cancellingBookingId !== null && cancellingBookingId !== row.original.id"
|
||||
@click="cancelBookingConfirmation(row.original)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
@@ -365,7 +376,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking } from '~~/shared/booking'
|
||||
import type { BookingCapacitySettings, BookingInventorySummary, CancelBookingConfirmationResponse, PublicBooking } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
formatBookingCurrency,
|
||||
@@ -387,6 +398,7 @@ const bookings = ref<PublicBooking[]>([])
|
||||
const loadingBookings = ref(false)
|
||||
const savingCapacity = ref(false)
|
||||
const savingRemark = ref(false)
|
||||
const cancellingBookingId = ref<string | null>(null)
|
||||
const remarkModalOpen = ref(false)
|
||||
const editingBooking = ref<PublicBooking | null>(null)
|
||||
const searchQuery = ref('')
|
||||
@@ -528,6 +540,12 @@ function replaceBooking(updatedBooking: PublicBooking) {
|
||||
bookings.value.splice(index, 1, updatedBooking)
|
||||
}
|
||||
|
||||
function applyCancelledConfirmationToSummary(booking: PublicBooking) {
|
||||
summary.soldSeats = Math.max(summary.soldSeats - booking.seatCount, 0)
|
||||
summary.pendingSeats += booking.seatCount
|
||||
summary.leftSeats = summary.totalSeats === null ? null : Math.max(summary.totalSeats - summary.soldSeats, 0)
|
||||
}
|
||||
|
||||
function openRemarkEditor(booking: PublicBooking) {
|
||||
editingBooking.value = booking
|
||||
remarkForm.remark = booking.remark || ''
|
||||
@@ -650,4 +668,46 @@ async function saveRemark() {
|
||||
savingRemark.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelBookingConfirmation(booking: PublicBooking) {
|
||||
if (booking.status !== 'confirmed' || cancellingBookingId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (import.meta.client && !window.confirm(`Cancel confirmation for ${booking.customerName}? The booking will return to pending and the seats will be released.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
cancellingBookingId.value = booking.id
|
||||
|
||||
try {
|
||||
const response = await apiClient<CancelBookingConfirmationResponse>(`/api/public/bookings/${booking.confirmationToken}/cancel`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
replaceBooking(response.booking)
|
||||
|
||||
if (!response.alreadyPending) {
|
||||
applyCancelledConfirmationToSummary(booking)
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: response.alreadyPending ? 'Booking already pending' : 'Confirmation cancelled',
|
||||
description: response.alreadyPending
|
||||
? `${booking.customerName} was already pending confirmation.`
|
||||
: `${booking.customerName} has been returned to pending status.`,
|
||||
color: response.alreadyPending ? 'warning' : 'success',
|
||||
icon: 'i-lucide-x-circle'
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Cancellation failed',
|
||||
description: getErrorMessage(error, 'Unable to cancel the booking confirmation.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
cancellingBookingId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
|
||||
import type { CancelBookingConfirmationResponse, ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
formatBookingCurrency,
|
||||
@@ -15,6 +15,7 @@ const apiClient = useApiClient()
|
||||
|
||||
const token = String(route.params.token || '')
|
||||
const confirming = ref(false)
|
||||
const cancelling = ref(false)
|
||||
|
||||
let initialBooking: PublicBooking
|
||||
|
||||
@@ -114,6 +115,47 @@ async function confirmBooking() {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelBookingConfirmation() {
|
||||
if (booking.value.status !== 'confirmed') {
|
||||
return
|
||||
}
|
||||
|
||||
if (import.meta.client && !window.confirm('Cancel this confirmation? The booking will return to pending and the seats will be released.')) {
|
||||
return
|
||||
}
|
||||
|
||||
cancelling.value = true
|
||||
|
||||
try {
|
||||
const response = await apiClient<CancelBookingConfirmationResponse>(
|
||||
`/api/public/bookings/${token}/cancel`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
|
||||
booking.value = response.booking
|
||||
|
||||
toast.add({
|
||||
title: response.alreadyPending ? 'Booking already pending' : 'Confirmation cancelled',
|
||||
description: response.alreadyPending
|
||||
? 'This booking was already pending confirmation.'
|
||||
: 'The booking has been returned to pending status.',
|
||||
color: response.alreadyPending ? 'warning' : 'success',
|
||||
icon: 'i-lucide-x-circle'
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
title: 'Cancellation failed',
|
||||
description: getErrorMessage(error, 'Please try again in a moment.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -198,13 +240,24 @@ async function confirmBooking() {
|
||||
/>
|
||||
|
||||
<UButton
|
||||
v-if="booking.status === 'pending'"
|
||||
label="Confirm This Booking"
|
||||
icon="i-lucide-check-check"
|
||||
class="justify-center"
|
||||
:disabled="booking.status === 'confirmed'"
|
||||
:loading="confirming"
|
||||
@click="confirmBooking"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
v-else
|
||||
label="Cancel Confirmation"
|
||||
color="error"
|
||||
variant="outline"
|
||||
icon="i-lucide-x-circle"
|
||||
class="justify-center"
|
||||
:loading="cancelling"
|
||||
@click="cancelBookingConfirmation"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
31
server/api/public/bookings/[token]/cancel.post.ts
Normal file
31
server/api/public/bookings/[token]/cancel.post.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { CancelBookingConfirmationResponse } from '~~/shared/booking'
|
||||
|
||||
import { cancelBookingConfirmationByConfirmationToken, getBookingByConfirmationToken } from '../../../../utils/booking-repository'
|
||||
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<CancelBookingConfirmationResponse> => {
|
||||
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||
const existingBooking = await getBookingByConfirmationToken(token)
|
||||
|
||||
if (!existingBooking) {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
if (existingBooking.status === 'pending') {
|
||||
return {
|
||||
booking: existingBooking,
|
||||
alreadyPending: true
|
||||
}
|
||||
}
|
||||
|
||||
const booking = await cancelBookingConfirmationByConfirmationToken(token)
|
||||
|
||||
if (!booking) {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
return {
|
||||
booking,
|
||||
alreadyPending: false
|
||||
}
|
||||
})
|
||||
@@ -859,3 +859,30 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
|
||||
|
||||
return await getBookingByConfirmationToken(confirmationToken)
|
||||
}
|
||||
|
||||
export async function cancelBookingConfirmationByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
with updated_booking as (
|
||||
update bookings
|
||||
set
|
||||
status = 'pending',
|
||||
confirmed_at = null,
|
||||
updated_at = now()
|
||||
where confirmation_token = ${confirmationToken}
|
||||
and status = 'confirmed'
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from updated_booking as bookings
|
||||
${bookingJoins(sql)}
|
||||
`
|
||||
|
||||
if (row) {
|
||||
return mapBooking(row)
|
||||
}
|
||||
|
||||
return await getBookingByConfirmationToken(confirmationToken)
|
||||
}
|
||||
|
||||
@@ -145,6 +145,11 @@ export interface ConfirmBookingResponse {
|
||||
ticketReceiptWhatsApp: WhatsAppDeliveryResult
|
||||
}
|
||||
|
||||
export interface CancelBookingConfirmationResponse {
|
||||
booking: PublicBooking
|
||||
alreadyPending: boolean
|
||||
}
|
||||
|
||||
export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
|
||||
return value === 'pending' || value === 'confirmed'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user