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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user