Add edit modal to update guest details, ticket selection, and quantity Implement soft delete functionality to archive bookings
1268 lines
39 KiB
Vue
1268 lines
39 KiB
Vue
<template>
|
|
<UContainer class="py-6">
|
|
<div class="mx-auto max-w-7xl space-y-4">
|
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
|
<div class="space-y-1">
|
|
<UBadge label="Bookings" color="primary" variant="soft" size="sm" class="rounded-full" />
|
|
<h1 class="text-2xl font-bold tracking-tight text-highlighted">
|
|
Booking list
|
|
</h1>
|
|
<p class="text-sm text-muted">
|
|
{{ 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="border border-default bg-default shadow-sm" :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="border border-default bg-default shadow-sm" :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="rounded-lg border border-default bg-muted/20 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="rounded-lg border border-default bg-muted/20 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="border border-default bg-default shadow-sm"
|
|
: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="border border-default bg-default shadow-sm" :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="border border-default bg-default shadow-sm" :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="border border-default bg-default shadow-sm" :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="border border-default bg-default shadow-sm"
|
|
: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="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="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"
|
|
description="Management-only note for this booking."
|
|
>
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<div v-if="editingBooking" class="rounded-lg border border-default bg-muted/20 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="rounded-lg border border-default bg-muted/20 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>
|