Files
dticket.tootaio.com/app/pages/bookings/index.vue
xiaomai 6ba7faa696 fix(bookings): correct table overflow and width styling
Remove overflow-x-auto from the table wrapper
Apply minimum width using the UTable ui prop instead of standard classes
2026-05-09 14:39:26 +08:00

1510 lines
47 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>
<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"
:ui="{ base: 'min-w-[1280px]' }"
>
<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 #paymentMethod-cell="{ row }">
<div class="space-y-1 py-0.5">
<UBadge
:label="getPaymentMethodLabel(row.original.paymentMethod)"
:color="row.original.paymentMethod === 'bank' ? 'info' : 'neutral'"
variant="soft"
size="sm"
/>
<UButton
v-if="row.original.transactionDocument"
:to="row.original.transactionDocument.url"
external
:label="row.original.transactionDocument.originalName"
color="neutral"
variant="link"
icon="i-lucide-file-down"
size="xs"
class="-ms-2 max-w-44 justify-start truncate"
/>
<div v-else-if="row.original.paymentMethod === 'bank'" class="text-xs text-muted">
No document
</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-nowrap items-center justify-end gap-1 py-0.5 whitespace-nowrap">
<UTooltip text="Open booking">
<UButton
:to="confirmationPath(row.original)"
color="neutral"
variant="ghost"
icon="i-lucide-external-link"
size="sm"
class="min-w-9 justify-center"
aria-label="Open booking"
/>
</UTooltip>
<UTooltip text="Open receipt">
<UButton
:to="receiptPath(row.original)"
color="neutral"
variant="ghost"
icon="i-lucide-receipt"
size="sm"
class="min-w-9 justify-center"
aria-label="Open receipt"
/>
</UTooltip>
<UDropdownMenu :items="bookingActionMenuItems(row.original)">
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-ellipsis-vertical"
size="sm"
class="min-w-9 justify-center"
:loading="isBookingRowActionBusy(row.original)"
:disabled="isBookingRowActionBusy(row.original)"
aria-label="More actions"
/>
</UDropdownMenu>
</div>
</template>
</UTable>
</div>
</UCard>
<UModal
v-model:open="detailsModalOpen"
title="Edit Booking"
description="Update guest details, payment, ticket selection, quantity, and handling note."
:dismissible="!savingDetails && !deletingTransactionDocument"
:close="!savingDetails && !deletingTransactionDocument"
: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 || deletingTransactionDocument"
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>
<UFormField name="paymentMethod" label="Payment Method" required>
<URadioGroup
v-model="detailsForm.paymentMethod"
orientation="horizontal"
variant="card"
indicator="hidden"
:items="paymentMethodItems"
:disabled="savingDetails"
: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 v-if="detailsForm.paymentMethod === 'bank'" class="space-y-3">
<div
v-if="detailsBooking?.transactionDocument"
class="surface-panel flex flex-col gap-2 rounded-lg px-3 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-highlighted">
{{ detailsBooking.transactionDocument.originalName }}
</p>
<p class="text-xs text-muted">
{{ formatFileSize(detailsBooking.transactionDocument.size) }} - {{ formatDateTime(detailsBooking.transactionDocument.uploadedAt) }}
</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<UButton
:to="detailsBooking.transactionDocument.url"
external
label="Download"
color="neutral"
variant="outline"
icon="i-lucide-file-down"
size="sm"
class="justify-center"
:disabled="deletingTransactionDocument"
/>
<UButton
label="Delete"
color="error"
variant="outline"
icon="i-lucide-trash-2"
size="sm"
class="justify-center"
:loading="deletingTransactionDocument"
:disabled="savingDetails"
@click="deleteTransactionDocumentForDetails"
/>
</div>
</div>
<UFormField name="transactionDocument" label="Transaction Document">
<UInput
:key="transactionDocumentInputKey"
type="file"
:accept="transactionDocumentAccept"
:disabled="savingDetails || deletingTransactionDocument"
size="lg"
class="w-full"
@change="onTransactionDocumentChange"
/>
<template #help>
{{ selectedTransactionDocumentName || 'PDF, JPG, PNG, WEBP, HEIC, or HEIF - max 10MB.' }}
</template>
</UFormField>
</div>
<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="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 || deletingTransactionDocument"
@click="closeBookingEditor"
/>
<UButton
type="submit"
form="bookingDetailsForm"
label="Save Booking"
icon="i-lucide-save"
class="justify-center"
:loading="savingDetails"
:disabled="deletingTransactionDocument"
/>
</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="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,
PaymentMethod,
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'
})
useSeoMeta({
title: 'Bookings',
description: 'Manage dinner ticket bookings, confirmations, receipts, and seat allocation.',
robots: 'noindex,nofollow'
})
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 deletingTransactionDocument = 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,
paymentMethod: 'cash' as PaymentMethod,
remark: ''
})
const detailsTransactionDocumentFile = ref<File | null>(null)
const transactionDocumentInputKey = ref(0)
const remarkForm = reactive({
remark: ''
})
const transferForm = reactive({
personInChargeId: ''
})
const remarkLimit = 1000
const transactionDocumentLimit = 10 * 1024 * 1024
const transactionDocumentAccept = '.pdf,.jpg,.jpeg,.png,.webp,.heic,.heif,application/pdf,image/jpeg,image/png,image/webp,image/heic,image/heif'
const paymentMethodItems: { label: string, value: PaymentMethod }[] = [
{
label: 'Cash',
value: 'cash'
},
{
label: 'Bank',
value: 'bank'
}
]
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 selectedTransactionDocumentName = computed(() => detailsTransactionDocumentFile.value?.name || '')
const columns = [
{ accessorKey: 'customerName', header: 'Guest' },
{ accessorKey: 'quantity', header: 'Booking' },
{ accessorKey: 'seatCount', header: 'Seats / Total' },
{ accessorKey: 'personInChargeName', header: 'PIC' },
{ accessorKey: 'paymentMethod', header: 'Payment' },
{ 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,
getPaymentMethodLabel(booking.paymentMethod),
booking.transactionDocument?.originalName || '',
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 getPaymentMethodLabel(paymentMethod: PaymentMethod) {
return paymentMethod === 'bank' ? 'Bank' : 'Cash'
}
function formatFileSize(size: number) {
if (size >= 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
return `${Math.max(Math.round(size / 1024), 1)} KB`
}
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 isBookingRowActionBusy(booking: PublicBooking) {
return Boolean(
(deletingBookingId.value && deletingBookingId.value !== booking.id)
|| (cancellingBookingId.value && cancellingBookingId.value !== booking.id)
|| deletingBookingId.value === booking.id
|| cancellingBookingId.value === booking.id
)
}
function bookingActionMenuItems(booking: PublicBooking) {
const busy = isBookingRowActionBusy(booking)
const items = [
[
{
label: 'Edit booking',
icon: 'i-lucide-pencil-line',
disabled: busy || !bookingConfig.value,
onSelect: () => openBookingEditor(booking)
},
{
label: 'Transfer booking',
icon: 'i-lucide-send',
disabled: busy || !hasTransferTargets(booking),
onSelect: () => openTransferEditor(booking)
}
]
]
if (booking.status === 'confirmed') {
items.push([
{
label: 'Unconfirm booking',
icon: 'i-lucide-x-circle',
color: 'error',
disabled: busy,
onSelect: () => cancelBookingConfirmation(booking)
}
])
}
items.push([
{
label: 'Delete booking',
icon: 'i-lucide-trash-2',
color: 'error',
disabled: busy,
onSelect: () => deleteBooking(booking)
}
])
return items
}
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.paymentMethod = booking.paymentMethod
detailsForm.remark = booking.remark || ''
detailsTransactionDocumentFile.value = null
transactionDocumentInputKey.value += 1
detailsModalOpen.value = true
}
function closeBookingEditor() {
if (savingDetails.value || deletingTransactionDocument.value) {
return
}
detailsModalOpen.value = false
detailsBooking.value = null
detailsForm.customerName = ''
detailsForm.customerPhone = DEFAULT_PHONE_COUNTRY_CODE
detailsForm.bookingMode = ''
detailsForm.quantity = 1
detailsForm.ticketType = ''
detailsForm.paymentMethod = 'cash'
detailsForm.remark = ''
detailsTransactionDocumentFile.value = null
transactionDocumentInputKey.value += 1
}
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.' })
}
if (state.paymentMethod !== 'cash' && state.paymentMethod !== 'bank') {
errors.push({ name: 'paymentMethod', message: 'Please select Cash or Bank.' })
}
if (detailsTransactionDocumentFile.value && detailsTransactionDocumentFile.value.size > transactionDocumentLimit) {
errors.push({ name: 'transactionDocument', message: 'Transaction document must be 10MB or smaller.' })
}
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 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 = ''
}
function onTransactionDocumentChange(event: Event) {
const input = event.target as HTMLInputElement
detailsTransactionDocumentFile.value = input.files?.[0] ?? null
}
async function deleteTransactionDocumentForDetails() {
const booking = detailsBooking.value
if (!booking?.transactionDocument || deletingTransactionDocument.value || savingDetails.value) {
return
}
if (import.meta.client && !window.confirm(`Delete transaction document for ${booking.customerName}?`)) {
return
}
deletingTransactionDocument.value = true
try {
const response = await apiClient<UpdateBookingDetailsResponse>(`/api/bookings/${booking.id}/transaction-document`, {
method: 'DELETE'
})
replaceBooking(response.booking)
detailsBooking.value = response.booking
detailsTransactionDocumentFile.value = null
transactionDocumentInputKey.value += 1
await refreshBookings()
toast.add({
title: 'Document deleted',
description: 'The transaction document has been removed.',
color: 'success',
icon: 'i-lucide-trash-2'
})
} catch (error: any) {
toast.add({
title: 'Delete failed',
description: getErrorMessage(error, 'Unable to delete the transaction document.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
deletingTransactionDocument.value = false
}
}
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 || deletingTransactionDocument.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,
paymentMethod: detailsForm.paymentMethod,
remark: detailsForm.remark
}
})
let updatedBooking = response.booking
if (detailsForm.paymentMethod === 'bank' && detailsTransactionDocumentFile.value) {
const formData = new FormData()
formData.append('document', detailsTransactionDocumentFile.value)
const uploadResponse = await apiClient<UpdateBookingDetailsResponse>(`/api/bookings/${booking.id}/transaction-document`, {
method: 'POST',
body: formData
})
updatedBooking = uploadResponse.booking
}
replaceBooking(updatedBooking)
await refreshBookings()
detailsModalOpen.value = false
detailsBooking.value = null
detailsTransactionDocumentFile.value = null
transactionDocumentInputKey.value += 1
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)
await refreshBookings()
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>