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"
|
icon="i-lucide-receipt"
|
||||||
size="sm"
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
@@ -365,7 +376,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking } from '~~/shared/booking'
|
import type { BookingCapacitySettings, BookingInventorySummary, CancelBookingConfirmationResponse, PublicBooking } from '~~/shared/booking'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
@@ -387,6 +398,7 @@ const bookings = ref<PublicBooking[]>([])
|
|||||||
const loadingBookings = ref(false)
|
const loadingBookings = ref(false)
|
||||||
const savingCapacity = ref(false)
|
const savingCapacity = ref(false)
|
||||||
const savingRemark = ref(false)
|
const savingRemark = ref(false)
|
||||||
|
const cancellingBookingId = ref<string | null>(null)
|
||||||
const remarkModalOpen = ref(false)
|
const remarkModalOpen = ref(false)
|
||||||
const editingBooking = ref<PublicBooking | null>(null)
|
const editingBooking = ref<PublicBooking | null>(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
@@ -528,6 +540,12 @@ function replaceBooking(updatedBooking: PublicBooking) {
|
|||||||
bookings.value.splice(index, 1, updatedBooking)
|
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) {
|
function openRemarkEditor(booking: PublicBooking) {
|
||||||
editingBooking.value = booking
|
editingBooking.value = booking
|
||||||
remarkForm.remark = booking.remark || ''
|
remarkForm.remark = booking.remark || ''
|
||||||
@@ -650,4 +668,46 @@ async function saveRemark() {
|
|||||||
savingRemark.value = false
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
|
import type { CancelBookingConfirmationResponse, ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
@@ -15,6 +15,7 @@ const apiClient = useApiClient()
|
|||||||
|
|
||||||
const token = String(route.params.token || '')
|
const token = String(route.params.token || '')
|
||||||
const confirming = ref(false)
|
const confirming = ref(false)
|
||||||
|
const cancelling = ref(false)
|
||||||
|
|
||||||
let initialBooking: PublicBooking
|
let initialBooking: PublicBooking
|
||||||
|
|
||||||
@@ -114,6 +115,47 @@ async function confirmBooking() {
|
|||||||
confirming.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -198,13 +240,24 @@ async function confirmBooking() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
|
v-if="booking.status === 'pending'"
|
||||||
label="Confirm This Booking"
|
label="Confirm This Booking"
|
||||||
icon="i-lucide-check-check"
|
icon="i-lucide-check-check"
|
||||||
class="justify-center"
|
class="justify-center"
|
||||||
:disabled="booking.status === 'confirmed'"
|
|
||||||
:loading="confirming"
|
:loading="confirming"
|
||||||
@click="confirmBooking"
|
@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>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</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)
|
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
|
ticketReceiptWhatsApp: WhatsAppDeliveryResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CancelBookingConfirmationResponse {
|
||||||
|
booking: PublicBooking
|
||||||
|
alreadyPending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
|
export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
|
||||||
return value === 'pending' || value === 'confirmed'
|
return value === 'pending' || value === 'confirmed'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user