Files
dticket.tootaio.com/app/pages/bookings/index.vue
xiaomai 227c64d346 refactor(ui): standardize page layouts and component styling
Introduce structural CSS classes for page shells, headers, and surface cards
Update primary theme color to red and neutral to zinc across the application
2026-05-08 16:25:42 +08:00

1268 lines
39 KiB
Vue

<template>
<UContainer class="page-shell">
<div class="space-y-5">
<div class="page-header lg:flex-row lg:items-end lg:justify-between">
<div class="space-y-1">
<UBadge label="Bookings" color="primary" variant="soft" size="sm" class="page-eyebrow" />
<h1 class="page-title">
Booking list
</h1>
<p class="page-description">
{{ auth.isSuperAdmin.value ? 'All submitted bookings across every PIC.' : 'Bookings assigned to you as PIC.' }}
</p>
</div>
<UBadge
:label="auth.isSuperAdmin.value ? 'Super Admin View' : 'Staff View'"
:color="auth.isSuperAdmin.value ? 'primary' : 'neutral'"
variant="soft"
size="sm"
class="rounded-full"
/>
</div>
<UAlert
title="Inventory counting rule"
:description="inventoryDescription"
color="info"
icon="i-lucide-info"
variant="soft"
:ui="{ root: 'py-3', title: 'text-sm font-medium', description: 'text-xs sm:text-sm' }"
/>
<div v-if="auth.isSuperAdmin.value" class="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_18rem]">
<UCard class="surface-card rounded-lg" :ui="{ header: 'px-4 py-3', body: 'px-4 py-3' }">
<template #header>
<div class="space-y-1">
<h2 class="text-base font-semibold text-highlighted">
Capacity settings
</h2>
<p class="text-xs text-muted sm:text-sm">
Configure the event capacity used to calculate sold and left inventory.
</p>
</div>
</template>
<UForm :state="capacityForm" class="space-y-3" @submit="saveCapacity">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<UFormField name="totalSeats" label="Total Seats" class="flex-1">
<UInput
v-model="capacityForm.totalSeats"
type="number"
inputmode="numeric"
min="0"
size="md"
class="w-full"
placeholder="Leave blank for no seat limit"
/>
</UFormField>
<UButton
type="submit"
label="Save Capacity"
icon="i-lucide-save"
size="md"
:loading="savingCapacity"
class="justify-center sm:min-w-32"
/>
</div>
<div class="flex items-center justify-between gap-3 border-t border-default pt-3">
<p class="text-xs text-muted sm:text-sm">
Last updated {{ settings.updatedAt ? formatDateTime(settings.updatedAt) : 'Not set yet' }}
</p>
</div>
</UForm>
</UCard>
<UCard class="surface-card rounded-lg" :ui="{ header: 'px-4 py-3', body: 'px-4 py-3' }">
<template #header>
<div class="space-y-1">
<h2 class="text-base font-semibold text-highlighted">
Pending reservations
</h2>
<p class="text-xs text-muted sm:text-sm">
Pending bookings are not counted as sold until the PIC confirms them.
</p>
</div>
</template>
<div class="grid gap-4 sm:grid-cols-2">
<div class="surface-panel rounded-lg p-3">
<p class="text-xs uppercase tracking-wide text-muted">
Pending bookings
</p>
<p class="mt-1 text-xl font-semibold text-highlighted">
{{ pendingCount }}
</p>
</div>
<div class="surface-panel rounded-lg p-3">
<p class="text-xs uppercase tracking-wide text-muted">
Pending seats
</p>
<p class="mt-1 text-xl font-semibold text-highlighted">
{{ summary.pendingSeats }}
</p>
</div>
</div>
</UCard>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<UCard
v-for="item in inventoryCards"
:key="item.label"
class="metric-card rounded-lg"
:ui="{ body: 'px-4 py-3' }"
>
<div class="space-y-0.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
{{ item.label }}
</p>
<p class="text-2xl font-semibold leading-none text-highlighted">
{{ item.value }}
</p>
</div>
</UCard>
</div>
<div class="grid gap-3 md:grid-cols-3">
<UCard class="metric-card rounded-lg" :ui="{ body: 'px-4 py-3' }">
<div class="space-y-0.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
Total Seats
</p>
<p class="text-2xl font-semibold leading-none text-highlighted">
{{ formatInventoryNumber(summary.totalSeats) }}
</p>
</div>
</UCard>
<UCard class="metric-card rounded-lg" :ui="{ body: 'px-4 py-3' }">
<div class="space-y-0.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
Total bookings
</p>
<p class="text-2xl font-semibold leading-none text-highlighted">
{{ bookings.length }}
</p>
</div>
</UCard>
<UCard class="metric-card rounded-lg" :ui="{ body: 'px-4 py-3' }">
<div class="space-y-0.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
Booking status
</p>
<p class="text-sm font-medium text-default">
{{ confirmedCount }} confirmed, {{ pendingCount }} pending
</p>
</div>
</UCard>
</div>
<UCard
class="surface-card overflow-hidden rounded-lg"
:ui="{ header: 'px-4 py-3', body: 'p-0 sm:p-0' }"
>
<template #header>
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<UInput
v-model="searchQuery"
size="md"
class="w-full sm:w-72"
placeholder="Search guest, phone, PIC, ticket, or remark"
/>
<UButton
label="Refresh"
color="neutral"
variant="outline"
icon="i-lucide-refresh-cw"
size="md"
:loading="loadingBookings"
@click="refreshBookings"
/>
</div>
</div>
</template>
<div class="overflow-x-auto">
<UTable
:data="filteredBookings"
:columns="columns"
:loading="loadingBookings"
:empty="searchQuery.trim() ? 'No matching bookings found.' : 'No bookings available yet.'"
sticky="header"
caption="Bookings"
class="compact-table min-w-[1120px]"
>
<template #customerName-cell="{ row }">
<div class="min-w-0 space-y-0.5 py-0.5">
<div class="text-sm font-semibold leading-tight text-highlighted">
{{ row.original.customerName }}
</div>
<div class="text-xs text-muted">
{{ row.original.customerPhone }}
</div>
</div>
</template>
<template #quantity-cell="{ row }">
<div class="space-y-0.5 py-0.5">
<div class="text-sm font-medium text-default">
{{ ticketLabel(row.original) }}
</div>
</div>
</template>
<template #seatCount-cell="{ row }">
<div class="space-y-0.5 py-0.5">
<div class="text-sm font-medium text-default">
{{ row.original.seatCount }} seats
</div>
<div class="text-xs text-muted">
{{ formatBookingCurrency(row.original.totalPrice) }}
</div>
</div>
</template>
<template #personInChargeName-cell="{ row }">
<div class="min-w-0 space-y-0.5 py-0.5">
<div class="text-sm font-medium text-default">
{{ row.original.personInChargeName }}
</div>
<div class="text-xs text-muted">
{{ row.original.personInChargePhoneNumber }}
</div>
</div>
</template>
<template #remark-cell="{ row }">
<div class="max-w-64 space-y-1 py-0.5">
<p
v-if="row.original.remark"
class="whitespace-pre-wrap break-words text-sm leading-snug text-default"
>
{{ remarkPreview(row.original.remark) }}
</p>
<p v-else class="text-xs text-muted">
No remark
</p>
<UButton
:label="row.original.remark ? 'Edit remark' : 'Add remark'"
color="neutral"
variant="ghost"
icon="i-lucide-message-square-text"
size="xs"
class="-ms-2"
@click="openRemarkEditor(row.original)"
/>
</div>
</template>
<template #status-cell="{ row }">
<div class="space-y-1 py-0.5">
<UBadge
:label="getBookingStatusLabel(row.original.status, row.original.statusLabel)"
:color="row.original.status === 'confirmed' ? 'success' : 'warning'"
variant="soft"
size="sm"
/>
<div class="text-xs text-muted">
{{ row.original.status === 'confirmed' ? `Confirmed ${formatDateTime(row.original.confirmedAt)}` : 'Awaiting PIC confirmation' }}
</div>
</div>
</template>
<template #createdAt-cell="{ row }">
<span class="text-xs text-muted">
{{ formatDateTime(row.original.createdAt) }}
</span>
</template>
<template #actions-cell="{ row }">
<div class="flex flex-wrap justify-end gap-1.5 py-0.5">
<UButton
:to="confirmationPath(row.original)"
label="Open"
color="neutral"
variant="outline"
icon="i-lucide-external-link"
size="sm"
/>
<UButton
:to="receiptPath(row.original)"
label="Receipt"
color="neutral"
variant="outline"
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"
variant="outline"
icon="i-lucide-send"
size="sm"
:disabled="!hasTransferTargets(row.original)"
@click="openTransferEditor(row.original)"
/>
<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)"
/>
<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="surface-panel rounded-lg 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 transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}"
/>
</UFormField>
<div class="surface-panel rounded-lg 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"
description="Management-only note for this booking."
>
<template #body>
<div class="space-y-4">
<div v-if="editingBooking" class="surface-panel rounded-lg px-3 py-2">
<p class="text-sm font-medium text-highlighted">
{{ editingBooking.customerName }}
</p>
<p class="text-xs text-muted">
{{ ticketLabel(editingBooking) }} - {{ editingBooking.seatCount }} seats
</p>
</div>
<UFormField name="remark" label="Remark">
<UTextarea
v-model="remarkForm.remark"
:rows="5"
:maxlength="remarkLimit"
autoresize
class="w-full"
placeholder="Internal handling note"
/>
<template #help>
{{ remarkForm.remark.length }}/{{ remarkLimit }}
</template>
</UFormField>
</div>
</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="savingRemark"
@click="closeRemarkEditor"
/>
<UButton
label="Save Remark"
icon="i-lucide-save"
class="justify-center"
:loading="savingRemark"
@click="saveRemark"
/>
</div>
</template>
</UModal>
<UModal
v-model:open="transferModalOpen"
title="Transfer Booking"
description="Assign this booking to another PIC."
>
<template #body>
<div class="space-y-4">
<div v-if="transferringBooking" class="surface-panel rounded-lg px-3 py-2">
<p class="text-sm font-medium text-highlighted">
{{ transferringBooking.customerName }}
</p>
<p class="text-xs text-muted">
Current PIC: {{ transferringBooking.personInChargeName }}
</p>
</div>
<UFormField name="personInChargeId" label="New PIC">
<USelect
v-model="transferForm.personInChargeId"
:items="transferPersonInChargeItems"
:disabled="savingTransfer || !transferPersonInChargeItems.length"
class="w-full"
/>
</UFormField>
</div>
</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="savingTransfer"
@click="closeTransferEditor"
/>
<UButton
label="Transfer Booking"
icon="i-lucide-send"
class="justify-center"
:loading="savingTransfer"
:disabled="!transferForm.personInChargeId"
@click="saveTransfer"
/>
</div>
</template>
</UModal>
</div>
</UContainer>
</template>
<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import type { PublicContact } from '~~/shared/auth'
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,
getSeatCount
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
import { formatDateTime } from '../../utils/formatters'
definePageMeta({
middleware: 'auth'
})
const toast = useToast()
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('')
const settings = reactive<BookingCapacitySettings>({
totalSeats: null,
updatedAt: null
})
const summary = reactive<BookingInventorySummary>({
totalSeats: null,
soldSeats: 0,
pendingSeats: 0,
leftSeats: null
})
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: ''
})
const transferForm = reactive({
personInChargeId: ''
})
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' },
{ accessorKey: 'seatCount', header: 'Seats / Total' },
{ accessorKey: 'personInChargeName', header: 'PIC' },
{ accessorKey: 'remark', header: 'Remark' },
{ id: 'status', header: 'Status' },
{ accessorKey: 'createdAt', header: 'Submitted' },
{ id: 'actions', header: 'Actions' }
]
const inventoryDescription = computed(() => {
return 'Every booking is converted into seats immediately, so sold and remaining capacity are tracked only in seats.'
})
const inventoryCards = computed(() => {
return [
{
label: 'Seats Sold',
value: String(summary.soldSeats)
},
{
label: 'Pending Seats',
value: String(summary.pendingSeats)
},
{
label: 'Seats Left',
value: formatInventoryNumber(summary.leftSeats)
}
]
})
const filteredBookings = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
if (!keyword) {
return bookings.value
}
return bookings.value.filter((booking) => {
return [
booking.customerName,
booking.customerPhone,
booking.personInChargeName,
booking.personInChargePhoneNumber,
booking.ticketType,
booking.ticketLabel,
booking.remark || '',
booking.status
].some((value) => value.toLowerCase().includes(keyword))
})
})
const pendingCount = computed(() => {
return bookings.value.filter((booking) => booking.status === 'pending').length
})
const confirmedCount = computed(() => {
return bookings.value.filter((booking) => booking.status === 'confirmed').length
})
const transferPersonInChargeItems = computed(() => {
const currentPicId = transferringBooking.value?.personInChargeId
return contacts.value
.filter((contact) => contact.id !== currentPicId)
.map((contact) => ({
label: contact.fullName,
value: contact.id
}))
})
await Promise.all([
refreshBookingConfig(),
refreshBookings(),
refreshContacts()
])
function ticketLabel(booking: PublicBooking) {
return booking.ticketLabel || booking.ticketType.toUpperCase()
}
function remarkPreview(remark: string) {
const normalized = remark.trim()
return normalized.length > 120 ? `${normalized.slice(0, 120)}...` : normalized
}
function confirmationPath(booking: PublicBooking) {
return `/confirmation/${booking.confirmationToken}`
}
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)
}
function normalizeCapacityValue(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? String(value) : null
}
const normalized = String(value || '').trim()
return normalized || null
}
function syncCapacityForm(nextSettings: BookingCapacitySettings) {
capacityForm.totalSeats = nextSettings.totalSeats === null ? '' : String(nextSettings.totalSeats)
}
function applySettings(nextSettings: BookingCapacitySettings) {
settings.totalSeats = nextSettings.totalSeats
settings.updatedAt = nextSettings.updatedAt
syncCapacityForm(nextSettings)
}
function applySummary(nextSummary: BookingInventorySummary) {
summary.totalSeats = nextSummary.totalSeats
summary.soldSeats = nextSummary.soldSeats
summary.pendingSeats = nextSummary.pendingSeats
summary.leftSeats = nextSummary.leftSeats
}
function replaceBooking(updatedBooking: PublicBooking) {
const index = bookings.value.findIndex((booking) => booking.id === updatedBooking.id)
if (index === -1) {
return
}
bookings.value.splice(index, 1, updatedBooking)
}
function removeBooking(bookingId: string) {
bookings.value = bookings.value.filter((booking) => booking.id !== bookingId)
}
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 || ''
remarkModalOpen.value = true
}
function closeRemarkEditor() {
if (savingRemark.value) {
return
}
remarkModalOpen.value = false
editingBooking.value = null
remarkForm.remark = ''
}
function hasTransferTargets(booking: PublicBooking) {
return contacts.value.some((contact) => contact.id !== booking.personInChargeId)
}
function openTransferEditor(booking: PublicBooking) {
const nextTarget = contacts.value.find((contact) => contact.id !== booking.personInChargeId)
transferringBooking.value = booking
transferForm.personInChargeId = nextTarget?.id ?? ''
transferModalOpen.value = true
}
function closeTransferEditor() {
if (savingTransfer.value) {
return
}
transferModalOpen.value = false
transferringBooking.value = null
transferForm.personInChargeId = ''
}
async function refreshBookings() {
if (loadingBookings.value) {
return
}
loadingBookings.value = true
try {
const response = await apiClient<{
bookings: PublicBooking[]
settings: BookingCapacitySettings
summary: BookingInventorySummary
}>('/api/bookings')
bookings.value = response.bookings
applySettings(response.settings)
applySummary(response.summary)
} catch (error: any) {
toast.add({
title: 'Unable to load bookings',
description: getErrorMessage(error, 'The booking list could not be loaded.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
loadingBookings.value = false
}
}
async function refreshContacts() {
if (loadingContacts.value) {
return
}
loadingContacts.value = true
try {
const response = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
contacts.value = response.contacts
} catch (error: any) {
toast.add({
title: 'Unable to load PIC list',
description: getErrorMessage(error, 'The PIC transfer list could not be loaded.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
loadingContacts.value = false
}
}
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()
if (!auth.isSuperAdmin.value || savingCapacity.value) {
return
}
savingCapacity.value = true
try {
const response = await apiClient<{ settings: BookingCapacitySettings }>('/api/bookings/capacity', {
method: 'PATCH',
body: {
totalSeats: normalizeCapacityValue(capacityForm.totalSeats)
}
})
applySettings(response.settings)
await refreshBookings()
toast.add({
title: 'Capacity updated',
description: 'The booking inventory limits have been saved.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error: any) {
toast.add({
title: 'Capacity update failed',
description: getErrorMessage(error, 'Unable to save the booking inventory limits.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
savingCapacity.value = false
}
}
async function saveRemark() {
const booking = editingBooking.value
if (!booking || savingRemark.value) {
return
}
savingRemark.value = true
try {
const response = await apiClient<{ booking: PublicBooking }>(`/api/bookings/${booking.id}/remark`, {
method: 'PATCH',
body: {
remark: remarkForm.remark
}
})
replaceBooking(response.booking)
remarkModalOpen.value = false
editingBooking.value = null
remarkForm.remark = ''
toast.add({
title: 'Remark saved',
description: 'The booking remark has been updated.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error: any) {
toast.add({
title: 'Remark update failed',
description: getErrorMessage(error, 'Unable to save the booking remark.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
savingRemark.value = false
}
}
async function saveTransfer() {
const booking = transferringBooking.value
const nextPersonInChargeId = transferForm.personInChargeId
if (!booking || !nextPersonInChargeId || savingTransfer.value) {
return
}
savingTransfer.value = true
try {
const response = await apiClient<TransferBookingPicResponse>(`/api/bookings/${booking.id}/pic`, {
method: 'PATCH',
body: {
personInChargeId: nextPersonInChargeId
}
})
const nextPicName = response.booking.personInChargeName || 'the selected PIC'
if (auth.isSuperAdmin.value || response.booking.personInChargeId === auth.user.value?.id) {
replaceBooking(response.booking)
} else {
removeBooking(response.booking.id)
}
transferModalOpen.value = false
transferringBooking.value = null
transferForm.personInChargeId = ''
toast.add({
title: 'Booking transferred',
description: `${booking.customerName} is now assigned to ${nextPicName}.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error: any) {
toast.add({
title: 'Transfer failed',
description: getErrorMessage(error, 'Unable to transfer this booking.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
savingTransfer.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>