feat(bookings): allow editing and soft-deleting bookings

Add edit modal to update guest details, ticket selection, and quantity
Implement soft delete functionality to archive bookings
This commit is contained in:
2026-05-08 15:57:32 +08:00
parent 1318e766d5
commit e05c238495
7 changed files with 754 additions and 14 deletions

View File

@@ -302,6 +302,15 @@
icon="i-lucide-receipt"
size="sm"
/>
<UButton
label="Edit"
color="neutral"
variant="outline"
icon="i-lucide-pencil-line"
size="sm"
:disabled="!bookingConfig"
@click="openBookingEditor(row.original)"
/>
<UButton
label="Transfer"
color="neutral"
@@ -322,12 +331,158 @@
:disabled="cancellingBookingId !== null && cancellingBookingId !== row.original.id"
@click="cancelBookingConfirmation(row.original)"
/>
<UButton
label="Delete"
color="error"
variant="outline"
icon="i-lucide-trash-2"
size="sm"
:loading="deletingBookingId === row.original.id"
:disabled="Boolean(deletingBookingId) && deletingBookingId !== row.original.id"
@click="deleteBooking(row.original)"
/>
</div>
</template>
</UTable>
</div>
</UCard>
<UModal
v-model:open="detailsModalOpen"
title="Edit Booking"
description="Update guest details, ticket selection, quantity, and internal remark."
:dismissible="!savingDetails"
:close="!savingDetails"
:content="{ class: 'sm:max-w-2xl' }"
>
<template #body>
<UForm
id="bookingDetailsForm"
:state="detailsForm"
:validate="validateBookingDetails"
class="space-y-4"
@submit="saveBookingDetails"
>
<div v-if="detailsBooking" class="rounded-lg border border-default bg-muted/20 px-3 py-2">
<p class="text-sm font-medium text-highlighted">
{{ detailsBooking.customerName }}
</p>
<p class="text-xs text-muted">
Current: {{ ticketLabel(detailsBooking) }} - {{ detailsBooking.seatCount }} seats
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<UFormField name="customerName" label="Guest / Organizer" required>
<UInput
v-model="detailsForm.customerName"
size="lg"
class="w-full"
placeholder="e.g. John Doe"
/>
</UFormField>
<UFormField name="customerPhone" label="Phone Number" required>
<UInput
v-model="detailsForm.customerPhone"
size="lg"
type="tel"
class="w-full"
placeholder="e.g. +60123456789"
/>
</UFormField>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<UFormField name="bookingMode" label="Booking Mode" required>
<USelect
v-model="detailsForm.bookingMode"
:items="bookingModeItems"
:disabled="savingDetails || !bookingModeItems.length"
size="lg"
class="w-full"
/>
</UFormField>
<UFormField name="quantity" :label="selectedDetailsBookingMode?.quantityLabel || 'Quantity'" required>
<UInputNumber
v-model="detailsForm.quantity"
:min="1"
:step="1"
:disabled="savingDetails"
size="lg"
class="w-full"
/>
</UFormField>
</div>
<UFormField name="ticketType" label="Ticket Category" required>
<URadioGroup
v-model="detailsForm.ticketType"
orientation="horizontal"
variant="card"
indicator="hidden"
:items="ticketCatalogItems"
:disabled="savingDetails || !ticketCatalogItems.length"
:ui="{
fieldset: 'grid grid-cols-2 gap-3',
item: 'rounded-lg border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}"
/>
</UFormField>
<div class="rounded-lg border border-default bg-muted/30 px-4 py-3">
<div class="flex items-center justify-between gap-4">
<span class="text-sm font-medium text-muted">Updated total</span>
<div class="text-right">
<div class="text-lg font-semibold text-highlighted">
{{ detailsTotalFormatted }}
</div>
<div class="text-xs text-muted">
{{ detailsSeatCount }} seats
</div>
</div>
</div>
</div>
<UFormField name="remark" label="Remark">
<UTextarea
v-model="detailsForm.remark"
:rows="4"
:maxlength="remarkLimit"
autoresize
class="w-full"
placeholder="Internal handling note"
/>
<template #help>
{{ detailsForm.remark.length }}/{{ remarkLimit }}
</template>
</UFormField>
</UForm>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
label="Cancel"
color="neutral"
variant="ghost"
class="justify-center"
:disabled="savingDetails"
@click="closeBookingEditor"
/>
<UButton
type="submit"
form="bookingDetailsForm"
label="Save Booking"
icon="i-lucide-save"
class="justify-center"
:loading="savingDetails"
/>
</div>
</template>
</UModal>
<UModal
v-model:open="remarkModalOpen"
title="Booking Remark"
@@ -434,12 +589,33 @@
</template>
<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import type { PublicContact } from '~~/shared/auth'
import type { BookingCapacitySettings, BookingInventorySummary, CancelBookingConfirmationResponse, PublicBooking, TransferBookingPicResponse } from '~~/shared/booking'
import type {
BookingCapacitySettings,
BookingInventorySummary,
BookingModeOption,
BookingMode,
CancelBookingConfirmationResponse,
DeleteBookingResponse,
PublicBooking,
PublicBookingConfig,
TicketCatalogItem,
TicketType,
TransferBookingPicResponse,
UpdateBookingDetailsResponse
} from '~~/shared/booking'
import {
DEFAULT_PHONE_COUNTRY_CODE,
isValidPhoneNumber,
normalizePhoneNumber
} from '~~/shared/auth'
import {
formatBookingCurrency,
getBookingStatusLabel
getBookingStatusLabel,
getSeatCount
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
@@ -454,15 +630,21 @@ const apiClient = useApiClient()
const auth = useAuth()
const bookings = ref<PublicBooking[]>([])
const bookingConfig = ref<PublicBookingConfig | null>(null)
const contacts = ref<PublicContact[]>([])
const loadingBookings = ref(false)
const loadingBookingConfig = ref(false)
const loadingContacts = ref(false)
const savingCapacity = ref(false)
const savingDetails = ref(false)
const savingRemark = ref(false)
const savingTransfer = ref(false)
const cancellingBookingId = ref<string | null>(null)
const deletingBookingId = ref<string | null>(null)
const detailsModalOpen = ref(false)
const remarkModalOpen = ref(false)
const transferModalOpen = ref(false)
const detailsBooking = ref<PublicBooking | null>(null)
const editingBooking = ref<PublicBooking | null>(null)
const transferringBooking = ref<PublicBooking | null>(null)
const searchQuery = ref('')
@@ -479,6 +661,14 @@ const summary = reactive<BookingInventorySummary>({
const capacityForm = reactive({
totalSeats: ''
})
const detailsForm = reactive({
customerName: '',
customerPhone: DEFAULT_PHONE_COUNTRY_CODE,
bookingMode: '' as BookingMode,
quantity: 1,
ticketType: '' as TicketType,
remark: ''
})
const remarkForm = reactive({
remark: ''
})
@@ -487,6 +677,32 @@ const transferForm = reactive({
})
const remarkLimit = 1000
const bookingModeItems = computed(() => {
return bookingConfig.value?.bookingModes.map((mode: BookingModeOption) => ({
label: mode.label,
value: mode.value
})) || []
})
const ticketCatalogItems = computed(() => {
return bookingConfig.value?.ticketCatalog.map((ticket: TicketCatalogItem) => ({
label: ticket.label,
value: ticket.value,
description: ticket.description
})) || []
})
const selectedDetailsBookingMode = computed(() => {
return bookingConfig.value?.bookingModes.find((mode) => mode.value === detailsForm.bookingMode) ?? null
})
const selectedDetailsTicket = computed(() => {
return bookingConfig.value?.ticketCatalog.find((ticket) => ticket.value === detailsForm.ticketType) ?? null
})
const detailsSeatCount = computed(() => getSeatCount(selectedDetailsBookingMode.value, detailsForm.quantity))
const detailsTotalFormatted = computed(() => formatBookingCurrency(detailsSeatCount.value * (selectedDetailsTicket.value?.price ?? 0)))
const columns = [
{ accessorKey: 'customerName', header: 'Guest' },
{ accessorKey: 'quantity', header: 'Booking' },
@@ -560,6 +776,7 @@ const transferPersonInChargeItems = computed(() => {
})
await Promise.all([
refreshBookingConfig(),
refreshBookings(),
refreshContacts()
])
@@ -581,6 +798,82 @@ function receiptPath(booking: PublicBooking) {
return `/receipt/${booking.receiptToken}`
}
function openBookingEditor(booking: PublicBooking) {
detailsBooking.value = booking
detailsForm.customerName = booking.customerName
detailsForm.customerPhone = booking.customerPhone
detailsForm.bookingMode = booking.bookingMode
detailsForm.quantity = booking.quantity
detailsForm.ticketType = booking.ticketType
detailsForm.remark = booking.remark || ''
detailsModalOpen.value = true
}
function closeBookingEditor() {
if (savingDetails.value) {
return
}
detailsModalOpen.value = false
detailsBooking.value = null
detailsForm.customerName = ''
detailsForm.customerPhone = DEFAULT_PHONE_COUNTRY_CODE
detailsForm.bookingMode = ''
detailsForm.quantity = 1
detailsForm.ticketType = ''
detailsForm.remark = ''
}
function validateBookingDetails(state: typeof detailsForm): FormError[] {
const errors: FormError[] = []
if (!state.customerName.trim()) {
errors.push({ name: 'customerName', message: 'Please enter the guest or organizer name.' })
}
if (!state.customerPhone.trim()) {
errors.push({ name: 'customerPhone', message: 'Please enter a contact number.' })
} else if (!isValidPhoneNumber(state.customerPhone.trim())) {
errors.push({ name: 'customerPhone', message: 'Use a valid phone number with country code, e.g. +60123456789.' })
}
if (state.quantity < 1) {
errors.push({ name: 'quantity', message: 'Quantity must be at least 1.' })
}
if (!selectedDetailsBookingMode.value) {
errors.push({ name: 'bookingMode', message: 'Please select a booking mode.' })
}
if (!selectedDetailsTicket.value) {
errors.push({ name: 'ticketType', message: 'Please select a ticket category.' })
}
return errors
}
async function refreshBookingConfig() {
if (loadingBookingConfig.value) {
return
}
loadingBookingConfig.value = true
try {
const response = await apiClient<PublicBookingConfig>('/api/public/booking-config')
bookingConfig.value = response
} catch (error: any) {
toast.add({
title: 'Unable to load booking settings',
description: getErrorMessage(error, 'The booking editor could not load the active event settings.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
loadingBookingConfig.value = false
}
}
function formatInventoryNumber(value: number | null) {
return value === null ? 'Not set' : String(value)
}
@@ -720,6 +1013,90 @@ async function refreshContacts() {
}
}
async function saveBookingDetails(event: FormSubmitEvent<typeof detailsForm>) {
const booking = detailsBooking.value
event.preventDefault()
if (!booking || savingDetails.value) {
return
}
savingDetails.value = true
try {
const response = await apiClient<UpdateBookingDetailsResponse>(`/api/bookings/${booking.id}`, {
method: 'PATCH',
body: {
customerName: detailsForm.customerName.trim(),
customerPhone: normalizePhoneNumber(detailsForm.customerPhone),
bookingMode: detailsForm.bookingMode,
quantity: detailsForm.quantity,
ticketType: detailsForm.ticketType,
remark: detailsForm.remark
}
})
replaceBooking(response.booking)
await refreshBookings()
detailsModalOpen.value = false
detailsBooking.value = null
toast.add({
title: 'Booking updated',
description: 'The booking details have been saved.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error: any) {
toast.add({
title: 'Booking update failed',
description: getErrorMessage(error, 'Unable to save the booking details.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
savingDetails.value = false
}
}
async function deleteBooking(booking: PublicBooking) {
if (deletingBookingId.value) {
return
}
if (import.meta.client && !window.confirm(`Delete booking for ${booking.customerName}? It will be archived and removed from the active list.`)) {
return
}
deletingBookingId.value = booking.id
try {
const response = await apiClient<DeleteBookingResponse>(`/api/bookings/${booking.id}`, {
method: 'DELETE'
})
removeBooking(response.booking.id)
await refreshBookings()
toast.add({
title: 'Booking deleted',
description: 'The booking has been moved to archived state.',
color: 'success',
icon: 'i-lucide-trash-2'
})
} catch (error: any) {
toast.add({
title: 'Delete failed',
description: getErrorMessage(error, 'Unable to delete the booking.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
deletingBookingId.value = null
}
}
async function saveCapacity(event: Event) {
event.preventDefault()