Compare commits

...

1 Commits

Author SHA1 Message Date
13e85cfcd0 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
2026-05-05 07:04:42 +08:00
5 changed files with 179 additions and 3 deletions

View File

@@ -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>

View File

@@ -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>

View 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
}
})

View File

@@ -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)
}

View File

@@ -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'
}