Introduce structural CSS classes for page shells, headers, and surface cards Update primary theme color to red and neutral to zinc across the application
1268 lines
39 KiB
Vue
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>
|