feat(bookings): implement booking system and confirmation flow

Add database tables and repository for managing bookings
Create API endpoints for booking submission and capacity management
Update landing page to persist bookings before WhatsApp redirection
This commit is contained in:
2026-04-12 21:43:30 +08:00
parent 07e5d42005
commit 8541c4a2d1
17 changed files with 1585 additions and 92 deletions

View File

@@ -326,6 +326,12 @@ const mobileMenuOpen = ref(false)
await auth.fetchSession()
const allSystemMenuItems: SystemMenuItem[] = [
{
label: 'Bookings',
to: '/bookings',
icon: 'i-lucide-clipboard-list',
matches: (path) => path.startsWith('/bookings')
},
{
label: 'Security',
to: '/security',

View File

@@ -0,0 +1,514 @@
<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="totalTables" label="Total Tables" class="flex-1">
<UInput
v-model="capacityForm.totalTables"
type="number"
inputmode="numeric"
min="0"
size="md"
class="w-full"
placeholder="Leave blank for no table 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 tables
</p>
<p class="mt-1 text-xl font-semibold text-highlighted">
{{ summary.pendingTables }}
</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-5">
<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>
<p v-if="item.meta" class="text-xs text-muted">
{{ item.meta }}
</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 Tables
</p>
<p class="text-2xl font-semibold leading-none text-highlighted">
{{ formatInventoryNumber(summary.totalTables) }}
</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, or ticket"
/>
<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-[980px]"
>
<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 #bookingMode-cell="{ row }">
<div class="space-y-0.5 py-0.5">
<div class="text-sm font-medium text-default">
{{ getBookingModeLabel(row.original.bookingMode) }}
</div>
<div class="text-xs text-muted">
{{ row.original.quantity }} x {{ ticketLabel(row.original.ticketType) }}
</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 #status-cell="{ row }">
<div class="space-y-1 py-0.5">
<UBadge
:label="getBookingStatusLabel(row.original.status)"
: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"
/>
</div>
</template>
</UTable>
</div>
</UCard>
</div>
</UContainer>
</template>
<script lang="ts" setup>
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking, TicketType } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingModeLabel,
getBookingStatusLabel,
getTicketCatalogItem
} 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 loadingBookings = ref(false)
const savingCapacity = ref(false)
const searchQuery = ref('')
const settings = reactive<BookingCapacitySettings>({
totalTables: null,
updatedAt: null
})
const summary = reactive<BookingInventorySummary>({
totalTables: null,
totalCapacitySeats: null,
soldTables: 0,
pendingTables: 0,
soldSeats: 0,
pendingSeats: 0,
soldCapacitySeats: 0,
pendingCapacitySeats: 0,
leftTables: null,
leftSeats: null,
leftCapacitySeats: null
})
const capacityForm = reactive({
totalTables: ''
})
const columns = [
{ accessorKey: 'customerName', header: 'Guest' },
{ accessorKey: 'bookingMode', header: 'Booking' },
{ accessorKey: 'seatCount', header: 'Seats / Total' },
{ accessorKey: 'personInChargeName', header: 'PIC' },
{ id: 'status', header: 'Status' },
{ accessorKey: 'createdAt', header: 'Submitted' },
{ id: 'actions', header: 'Actions' }
]
const inventoryDescription = computed(() => {
return 'Set only the total number of tables. The system treats each table as 10 seats, then auto-calculates sold and remaining inventory.'
})
const inventoryCards = computed(() => {
return [
{
label: 'Tables Sold',
value: String(summary.soldTables)
},
{
label: 'Seats Sold',
value: String(summary.soldSeats)
},
{
label: 'Capacity Used',
value: String(summary.soldCapacitySeats),
meta: 'in seats'
},
{
label: 'Tables Left',
value: formatInventoryNumber(summary.leftTables)
},
{
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.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
})
await refreshBookings()
function ticketLabel(ticketType: TicketType) {
return getTicketCatalogItem(ticketType)?.label || ticketType.toUpperCase()
}
function confirmationPath(booking: PublicBooking) {
return `/confirmation/${booking.confirmationToken}`
}
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.totalTables = nextSettings.totalTables === null ? '' : String(nextSettings.totalTables)
}
function applySettings(nextSettings: BookingCapacitySettings) {
settings.totalTables = nextSettings.totalTables
settings.updatedAt = nextSettings.updatedAt
syncCapacityForm(nextSettings)
}
function applySummary(nextSummary: BookingInventorySummary) {
summary.totalTables = nextSummary.totalTables
summary.totalCapacitySeats = nextSummary.totalCapacitySeats
summary.soldTables = nextSummary.soldTables
summary.pendingTables = nextSummary.pendingTables
summary.soldSeats = nextSummary.soldSeats
summary.pendingSeats = nextSummary.pendingSeats
summary.soldCapacitySeats = nextSummary.soldCapacitySeats
summary.pendingCapacitySeats = nextSummary.pendingCapacitySeats
summary.leftTables = nextSummary.leftTables
summary.leftSeats = nextSummary.leftSeats
summary.leftCapacitySeats = nextSummary.leftCapacitySeats
}
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 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: {
totalTables: normalizeCapacityValue(capacityForm.totalTables)
}
})
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
}
}
</script>

View File

@@ -0,0 +1,209 @@
<script lang="ts" setup>
import type { PublicBooking } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingModeLabel,
getBookingStatusLabel,
getTicketCatalogItem
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
import { formatDateTime } from '../../utils/formatters'
const route = useRoute()
const toast = useToast()
const apiClient = useApiClient()
const token = String(route.params.token || '')
const confirming = ref(false)
let initialBooking: PublicBooking
try {
const response = await apiClient<{ booking: PublicBooking }>(`/api/public/bookings/${token}`)
initialBooking = response.booking
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.data?.statusCode || 404,
statusMessage: error?.data?.statusMessage || error?.message || 'Booking not found'
})
}
const booking = ref(initialBooking)
const statusColor = computed(() => booking.value.status === 'confirmed' ? 'success' : 'warning')
const ticketLabel = computed(() => getTicketCatalogItem(booking.value.ticketType)?.label || booking.value.ticketType.toUpperCase())
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice))
const detailRows = computed(() => {
const rows = [
{
label: 'Guest / Organizer',
value: booking.value.customerName
},
{
label: 'Contact Number',
value: booking.value.customerPhone
},
{
label: 'Person In Charge',
value: booking.value.personInChargeName
},
{
label: 'PIC Phone',
value: booking.value.personInChargePhoneNumber
},
{
label: 'Booking Mode',
value: getBookingModeLabel(booking.value.bookingMode)
},
{
label: 'Ticket Category',
value: ticketLabel.value
},
{
label: 'Quantity',
value: String(booking.value.quantity)
},
{
label: 'Seats Covered',
value: String(booking.value.seatCount)
},
{
label: 'Submitted',
value: formatDateTime(booking.value.createdAt)
}
]
if (booking.value.confirmedAt) {
rows.push({
label: 'Confirmed At',
value: formatDateTime(booking.value.confirmedAt)
})
}
return rows
})
async function confirmBooking() {
if (booking.value.status === 'confirmed') {
return
}
confirming.value = true
try {
const response = await apiClient<{ booking: PublicBooking, alreadyConfirmed: boolean }>(
`/api/public/bookings/${token}/confirm`,
{
method: 'POST'
}
)
booking.value = response.booking
toast.add({
title: response.alreadyConfirmed ? 'Booking already confirmed' : 'Booking confirmed',
description: response.alreadyConfirmed
? 'This booking had already been confirmed earlier.'
: 'The booking details have been confirmed successfully.',
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error) {
toast.add({
title: 'Confirmation failed',
description: getErrorMessage(error, 'Please try again in a moment.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
confirming.value = false
}
}
</script>
<template>
<UContainer class="py-8">
<div class="mx-auto max-w-3xl space-y-5">
<div class="space-y-2 text-center">
<UBadge label="PIC Confirmation" color="primary" variant="soft" class="rounded-full" />
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
Review Booking Details
</h1>
<p class="text-sm text-muted">
Confirm the booking after verifying the details below.
</p>
</div>
<UCard
class="border border-default bg-default shadow-sm"
:ui="{ body: 'space-y-4 p-4 sm:p-5' }"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<p class="text-xs font-medium uppercase tracking-wide text-muted">
Booking status
</p>
<UBadge :label="getBookingStatusLabel(booking.status)" :color="statusColor" variant="soft" />
</div>
<div class="text-sm text-muted">
Submitted {{ formatDateTime(booking.createdAt) }}
</div>
</div>
<UAlert
v-if="booking.status === 'confirmed'"
title="Booking already confirmed"
:description="`Confirmed on ${formatDateTime(booking.confirmedAt)}.`"
color="success"
icon="i-lucide-badge-check"
/>
<div class="overflow-hidden rounded-xl border border-default">
<div
v-for="row in detailRows"
:key="row.label"
class="grid grid-cols-[8.5rem_minmax(0,1fr)] gap-3 border-b border-default px-4 py-3 text-sm last:border-b-0 sm:grid-cols-[11rem_minmax(0,1fr)]"
>
<div class="text-xs font-medium uppercase tracking-wide text-muted sm:text-sm sm:normal-case sm:tracking-normal">
{{ row.label }}
</div>
<div class="min-w-0 font-medium text-highlighted break-words">
{{ row.value }}
</div>
</div>
<div class="grid grid-cols-[8.5rem_minmax(0,1fr)] gap-3 bg-primary/5 px-4 py-3 sm:grid-cols-[11rem_minmax(0,1fr)]">
<div class="text-xs font-semibold uppercase tracking-wide text-primary sm:text-sm sm:normal-case sm:tracking-normal">
Total Price
</div>
<div class="min-w-0 text-lg font-bold text-highlighted sm:text-xl">
{{ totalFormatted }}
</div>
</div>
</div>
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<UButton
to="/"
label="Back To Booking Form"
color="neutral"
variant="ghost"
class="justify-center"
/>
<UButton
label="Confirm This Booking"
icon="i-lucide-check-check"
class="justify-center"
:disabled="booking.status === 'confirmed'"
:loading="confirming"
@click="confirmBooking"
/>
</div>
</UCard>
</div>
</UContainer>
</template>

View File

@@ -2,9 +2,19 @@
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import { isValidPhoneNumber, type PublicContact } from '~~/shared/auth'
import type { CreateBookingResponse } from '~~/shared/booking'
import {
BOOKING_MODE_OPTIONS,
BOOKING_TICKET_CATALOG,
formatBookingCurrency,
getSeatCount,
getTicketCatalogItem,
type BookingMode,
type TicketType
} from '~~/shared/booking'
import { getErrorMessage } from '../utils/errors'
type BookingMode = 'table' | 'pax'
type TicketType = 'vip' | 'supporter'
const toast = useToast()
const apiClient = useApiClient()
@@ -27,32 +37,6 @@ const eventDetails = [
}
] as const
const bookingModeOptions = [
{
value: 'table',
label: 'Table (10 pax)'
},
{
value: 'pax',
label: 'Person'
}
] satisfies Array<{ value: BookingMode, label: string }>
const ticketCatalog = [
{
value: 'vip',
label: 'VIP',
description: 'RM150 / pax',
price: 150
},
{
value: 'supporter',
label: 'Supporter',
description: 'RM60 / pax',
price: 60
}
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
const contactsResponse = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
const personInCharge = computed(() => {
return contactsResponse.contacts.map((contact) => ({
@@ -61,13 +45,6 @@ const personInCharge = computed(() => {
}))
})
const priceFormatter = new Intl.NumberFormat('en-MY', {
style: 'currency',
currency: 'MYR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
const form = reactive({
name: '',
phone: '',
@@ -82,19 +59,16 @@ const selectedPersonInChargeRecord = computed(() => {
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
})
const selectedTicket = computed(() => {
return ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? ticketCatalog[0]
})
const seatMultiplier = computed(() => form.bookingMode === 'table' ? 10 : 1)
const selectedTicket = computed(() => getTicketCatalogItem(form.ticketType) ?? BOOKING_TICKET_CATALOG[0])
const submittingBooking = ref(false)
const quantityLabel = computed(() => {
return form.bookingMode === 'table' ? 'Number of tables' : 'Number of people'
})
const totalPrice = computed(() => form.quantity * seatMultiplier.value * selectedTicket.value.price)
const totalPrice = computed(() => getSeatCount(form.bookingMode, form.quantity) * selectedTicket.value.price)
const totalFormatted = computed(() => priceFormatter.format(totalPrice.value))
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value))
function validateBooking(state: typeof form): FormError[] {
const errors: FormError[] = []
@@ -116,23 +90,7 @@ function validateBooking(state: typeof form): FormError[] {
return errors
}
function buildBookingMessage() {
const bookingModeLabel = form.bookingMode === 'table' ? 'Table (10 pax each)' : 'Per person'
return [
"I'd like to book tickets for the DAP Johor 60th Anniversary Celebration.",
'',
`Name: ${form.name.trim()}`,
`Phone Number: ${form.phone.trim()}`,
`Booking Mode: ${bookingModeLabel}`,
`Quantity: ${form.quantity}`,
`Ticket Category: ${selectedTicket.value.label}`,
`Seats Covered: ${form.quantity * seatMultiplier.value}`,
`Total Price: ${totalFormatted.value}`
].join('\n')
}
function bookTicket(event: FormSubmitEvent<typeof form>) {
async function bookTicket(event: FormSubmitEvent<typeof form>) {
const selectedPic = selectedPersonInChargeRecord.value
if (!selectedPic) {
@@ -145,28 +103,46 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
return
}
const encodedMessage = encodeURIComponent(buildBookingMessage())
const whatsappUrl = `https://wa.me/${selectedPic.phoneNumber}?text=${encodedMessage}`
const bookingWindow = window.open(whatsappUrl, '_blank', 'noopener,noreferrer')
event.preventDefault()
submittingBooking.value = true
try {
const response = await apiClient<CreateBookingResponse>('/api/public/bookings', {
method: 'POST',
body: {
customerName: form.name.trim(),
customerPhone: form.phone.trim(),
bookingMode: form.bookingMode,
quantity: form.quantity,
ticketType: form.ticketType,
personInChargeId: selectedPic.id
}
})
const bookingWindow = window.open(response.whatsappUrl, '_blank', 'noopener,noreferrer')
if (!bookingWindow) {
window.location.assign(response.whatsappUrl)
return
}
if (!bookingWindow) {
toast.add({
title: 'WhatsApp could not be opened',
description: 'Allow pop-ups for this site, then submit the booking again.',
title: 'WhatsApp booking draft opened',
description: `Booking details and the confirmation link were sent to ${selectedPic.fullName}.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
} catch (error) {
toast.add({
title: 'Booking could not be created',
description: getErrorMessage(error, 'Please try again in a moment.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
return
} finally {
submittingBooking.value = false
}
toast.add({
title: 'WhatsApp booking draft opened',
description: `Your reservation details were sent to ${selectedPic.fullName}.`,
color: 'success',
icon: 'i-lucide-check-circle-2'
})
event.preventDefault()
}
</script>
@@ -206,7 +182,7 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
<UFormField label="Booking Mode" name="bookingMode">
<URadioGroup v-model="form.bookingMode" orientation="horizontal" variant="card" indicator="hidden"
:items="bookingModeOptions" :ui="{
:items="BOOKING_MODE_OPTIONS" :ui="{
fieldset: 'grid grid-cols-2 gap-3',
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}" />
@@ -218,7 +194,7 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
<UFormField label="Ticket Category" name="ticketType">
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
:items="ticketCatalog" :ui="{
:items="BOOKING_TICKET_CATALOG" :ui="{
fieldset: 'grid grid-cols-2 gap-3',
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}" />
@@ -242,7 +218,7 @@ function bookTicket(event: FormSubmitEvent<typeof form>) {
</UFormField>
<UButton id="getTicketBtn" type="submit" label="Book Your Ticket Now" size="xl"
class="w-full justify-center" :disabled="!selectedPersonInCharge" />
class="w-full justify-center" :disabled="!selectedPersonInCharge" :loading="submittingBooking" />
</UForm>
</UCard>
</div>

View File

@@ -0,0 +1,21 @@
import { requireAuth } from '../utils/auth'
import { getBookingCapacitySettings, getBookingInventorySummary, listBookings } from '../utils/booking-repository'
export default defineEventHandler(async (event) => {
const auth = await requireAuth(event)
const [bookings, settings, summary] = await Promise.all([
listBookings(
auth.user.role === 'super_admin'
? undefined
: { personInChargeId: auth.user.id }
),
getBookingCapacitySettings(),
getBookingInventorySummary()
])
return {
bookings,
settings,
summary
}
})

View File

@@ -0,0 +1,26 @@
import { requireRole } from '../../utils/auth'
import { parseBookingCapacityInput } from '../../utils/bookings'
import { getBookingInventorySummary, updateBookingCapacitySettings } from '../../utils/booking-repository'
import { assertBadRequest } from '../../utils/http'
export default defineEventHandler(async (event) => {
await requireRole(event, 'super_admin')
const body = await readBody<{
totalTables?: number | string | null
}>(event)
const input = parseBookingCapacityInput(body)
const summary = await getBookingInventorySummary()
assertBadRequest(
input.totalTables === null || (input.totalTables * 10) >= summary.soldCapacitySeats,
`Total tables cannot be lower than the currently sold capacity of ${summary.soldTables} tables and ${summary.soldSeats} seats`
)
const settings = await updateBookingCapacitySettings(input)
return {
settings
}
})

View File

@@ -0,0 +1,56 @@
import type { BookingMode, CreateBookingResponse, TicketType } from '~~/shared/booking'
import { getTicketCatalogItem, getSeatCount } from '~~/shared/booking'
import { buildAppUrl } from '../../utils/app-url'
import { createBooking } from '../../utils/booking-repository'
import { buildBookingMessage, parseCreateBookingInput } from '../../utils/bookings'
import { assertBadRequest } from '../../utils/http'
import { getPublicContactById } from '../../utils/user-repository'
export default defineEventHandler(async (event): Promise<CreateBookingResponse> => {
const body = await readBody<{
customerName?: string
customerPhone?: string
bookingMode?: BookingMode
quantity?: number
ticketType?: TicketType
personInChargeId?: string
}>(event)
const input = parseCreateBookingInput(body)
const personInCharge = await getPublicContactById(input.personInChargeId)
assertBadRequest(personInCharge, 'Selected person in charge is not available')
const ticket = getTicketCatalogItem(input.ticketType)
assertBadRequest(ticket, 'Ticket category is invalid')
const seatCount = getSeatCount(input.bookingMode, input.quantity)
const totalPrice = seatCount * ticket.price
const { booking, confirmationToken } = await createBooking({
customerName: input.customerName,
customerPhone: input.customerPhone,
bookingMode: input.bookingMode,
quantity: input.quantity,
seatCount,
ticketType: input.ticketType,
unitPrice: ticket.price,
totalPrice,
personInChargeId: personInCharge.id,
personInChargeName: personInCharge.fullName,
personInChargePhoneNumber: personInCharge.phoneNumber
})
const confirmationUrl = buildAppUrl(event, `/confirmation/${confirmationToken}`)
const whatsappMessage = buildBookingMessage(booking, confirmationUrl)
const whatsappUrl = `https://wa.me/${booking.personInChargePhoneNumber}?text=${encodeURIComponent(whatsappMessage)}`
return {
booking,
confirmationUrl,
whatsappUrl
}
})

View File

@@ -0,0 +1,15 @@
import { getRequiredRouteParam, httpError } from '../../../utils/http'
import { getBookingByConfirmationToken } from '../../../utils/booking-repository'
export default defineEventHandler(async (event) => {
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
const booking = await getBookingByConfirmationToken(token)
if (!booking) {
httpError(404, 'Booking not found')
}
return {
booking
}
})

View File

@@ -0,0 +1,35 @@
import { confirmBookingByConfirmationToken, getBookingByConfirmationToken, getBookingInventorySummary } from '../../../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
export default defineEventHandler(async (event) => {
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
const existingBooking = await getBookingByConfirmationToken(token)
if (!existingBooking) {
httpError(404, 'Booking not found')
}
if (existingBooking.status === 'confirmed') {
return {
booking: existingBooking,
alreadyConfirmed: true
}
}
const summary = await getBookingInventorySummary()
if (summary.leftCapacitySeats !== null && existingBooking.seatCount > summary.leftCapacitySeats) {
httpError(409, 'Not enough capacity left to confirm this booking')
}
const booking = await confirmBookingByConfirmationToken(token)
if (!booking) {
httpError(404, 'Booking not found')
}
return {
booking,
alreadyConfirmed: false
}
})

18
server/utils/app-url.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { H3Event } from 'h3'
import { getRequestURL } from 'h3'
export function getAppOrigin(event: H3Event) {
const config = useRuntimeConfig()
if (config.public.appUrl) {
return new URL(config.public.appUrl).origin
}
const url = getRequestURL(event)
return `${url.protocol}//${url.host}`
}
export function buildAppUrl(event: H3Event, path: string) {
return new URL(path, getAppOrigin(event)).toString()
}

View File

@@ -0,0 +1,322 @@
import { randomUUID } from 'node:crypto'
import type {
BookingCapacitySettings,
BookingInventorySummary,
BookingMode,
BookingStatus,
PublicBooking,
TicketType
} from '~~/shared/booking'
import { calculateBookingInventorySummary, isBookingStatus } from '~~/shared/booking'
import { randomToken, toIsoString } from './base64url'
import { ensureDatabaseReady } from './db-init'
import { getSqlClient } from './postgres'
type DbBookingRow = {
id: string
confirmation_token: string
customer_name: string
customer_phone: string
booking_mode: BookingMode
quantity: number | string
seat_count: number | string
ticket_type: TicketType
unit_price: number | string
total_price: number | string
person_in_charge_id: string
person_in_charge_name: string
person_in_charge_phone_number: string
status: BookingStatus | string
created_at: Date | string
confirmed_at: Date | string | null
}
type DbBookingSettingsRow = {
total_tables: number | string | null
updated_at: Date | string
}
function parseInteger(value: number | string) {
return typeof value === 'number' ? value : Number.parseInt(value, 10)
}
function mapBooking(row: DbBookingRow): PublicBooking {
return {
id: row.id,
confirmationToken: row.confirmation_token,
customerName: row.customer_name,
customerPhone: row.customer_phone,
bookingMode: row.booking_mode,
quantity: parseInteger(row.quantity),
seatCount: parseInteger(row.seat_count),
ticketType: row.ticket_type,
unitPrice: parseInteger(row.unit_price),
totalPrice: parseInteger(row.total_price),
personInChargeId: row.person_in_charge_id,
personInChargeName: row.person_in_charge_name,
personInChargePhoneNumber: row.person_in_charge_phone_number,
status: isBookingStatus(row.status) ? row.status : 'pending',
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
confirmedAt: toIsoString(row.confirmed_at)
}
}
function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): BookingCapacitySettings {
if (!row) {
return {
totalTables: null,
totalSeats: null,
updatedAt: null
}
}
return {
totalTables: row.total_tables === null ? null : parseInteger(row.total_tables),
updatedAt: toIsoString(row.updated_at)
}
}
export async function createBooking(input: {
customerName: string
customerPhone: string
bookingMode: BookingMode
quantity: number
seatCount: number
ticketType: TicketType
unitPrice: number
totalPrice: number
personInChargeId: string
personInChargeName: string
personInChargePhoneNumber: string
}) {
await ensureDatabaseReady()
const sql = getSqlClient()
const confirmationToken = randomToken(24)
const [row] = await sql<DbBookingRow[]>`
insert into bookings (
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status
)
values (
${randomUUID()},
${confirmationToken},
${input.customerName},
${input.customerPhone},
${input.bookingMode},
${input.quantity},
${input.seatCount},
${input.ticketType},
${input.unitPrice},
${input.totalPrice},
${input.personInChargeId},
${input.personInChargeName},
${input.personInChargePhoneNumber},
'pending'
)
returning
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status,
created_at,
confirmed_at
`
return {
booking: mapBooking(row),
confirmationToken
}
}
export async function getBookingByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbBookingRow[]>`
select
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status,
created_at,
confirmed_at
from bookings
where confirmation_token = ${confirmationToken}
limit 1
`
return row ? mapBooking(row) : null
}
export async function listBookings(options?: {
personInChargeId?: string
}): Promise<PublicBooking[]> {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = options?.personInChargeId
? await sql<DbBookingRow[]>`
select
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status,
created_at,
confirmed_at
from bookings
where person_in_charge_id = ${options.personInChargeId}
order by created_at desc
`
: await sql<DbBookingRow[]>`
select
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status,
created_at,
confirmed_at
from bookings
order by created_at desc
`
return rows.map(mapBooking)
}
export async function getBookingCapacitySettings(): Promise<BookingCapacitySettings> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbBookingSettingsRow[]>`
select
total_tables,
updated_at
from booking_settings
where id = 'default'
limit 1
`
return mapBookingCapacitySettings(row)
}
export async function updateBookingCapacitySettings(input: {
totalTables: number | null
}): Promise<BookingCapacitySettings> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbBookingSettingsRow[]>`
update booking_settings
set
total_tables = ${input.totalTables},
updated_at = now()
where id = 'default'
returning
total_tables,
updated_at
`
return mapBookingCapacitySettings(row)
}
export async function getBookingInventorySummary(): Promise<BookingInventorySummary> {
const [bookings, settings] = await Promise.all([
listBookings(),
getBookingCapacitySettings()
])
return calculateBookingInventorySummary(bookings, settings)
}
export async function confirmBookingByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<DbBookingRow[]>`
update bookings
set
status = 'confirmed',
confirmed_at = now(),
updated_at = now()
where confirmation_token = ${confirmationToken}
and status = 'pending'
returning
id,
confirmation_token,
customer_name,
customer_phone,
booking_mode,
quantity,
seat_count,
ticket_type,
unit_price,
total_price,
person_in_charge_id,
person_in_charge_name,
person_in_charge_phone_number,
status,
created_at,
confirmed_at
`
if (row) {
return mapBooking(row)
}
return await getBookingByConfirmationToken(confirmationToken)
}

90
server/utils/bookings.ts Normal file
View File

@@ -0,0 +1,90 @@
import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingModeLabel,
getTicketCatalogItem,
isBookingMode,
isTicketType
} from '~~/shared/booking'
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
import { assertBadRequest } from './http'
export function parseCreateBookingInput(body: {
customerName?: string
customerPhone?: string
bookingMode?: BookingMode
quantity?: number
ticketType?: TicketType
personInChargeId?: string
}) {
const customerName = normalizeFullName(body.customerName || '')
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
const bookingMode = body.bookingMode
const ticketType = body.ticketType
const quantity = Number(body.quantity)
const personInChargeId = (body.personInChargeId || '').trim()
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must contain 8 to 15 digits')
assertBadRequest(isBookingMode(bookingMode), 'Booking mode is invalid')
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
assertBadRequest(isTicketType(ticketType), 'Ticket category is invalid')
assertBadRequest(personInChargeId, 'Person in charge is required')
return {
customerName,
customerPhone,
bookingMode,
quantity,
ticketType,
personInChargeId
}
}
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
const ticket = getTicketCatalogItem(booking.ticketType)
const ticketLabel = ticket?.label || booking.ticketType.toUpperCase()
return [
"I'd like to book tickets for the DAP Johor 60th Anniversary Celebration.",
'',
`Name: ${booking.customerName}`,
`Phone Number: ${booking.customerPhone}`,
`Booking Mode: ${getBookingModeLabel(booking.bookingMode)}`,
`Quantity: ${booking.quantity}`,
`Ticket Category: ${ticketLabel}`,
`Seats Covered: ${booking.seatCount}`,
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
'',
'PIC confirmation link:',
confirmationUrl
].join('\n')
}
export function parseBookingCapacityInput(body: {
totalTables?: number | string | null
}): Pick<BookingCapacitySettings, 'totalTables'> {
const totalTables = parseOptionalInteger(body.totalTables)
assertBadRequest(totalTables === null || totalTables >= 0, 'Total tables must be 0 or greater')
return {
totalTables
}
}
function parseOptionalInteger(value: number | string | null | undefined) {
if (value === null || value === undefined || value === '') {
return null
}
const parsed = typeof value === 'number'
? value
: Number.parseInt(String(value), 10)
assertBadRequest(Number.isInteger(parsed), 'Capacity values must be whole numbers')
return parsed
}

View File

@@ -61,6 +61,43 @@ async function initializeDatabase() {
on user_passkeys (user_id)
`
await sql`
create table if not exists bookings (
id text primary key,
confirmation_token text not null unique,
customer_name text not null,
customer_phone text not null,
booking_mode text not null check (booking_mode in ('table', 'pax')),
quantity integer not null check (quantity >= 1),
seat_count integer not null check (seat_count >= 1),
ticket_type text not null check (ticket_type in ('vip', 'supporter')),
unit_price integer not null check (unit_price >= 0),
total_price integer not null check (total_price >= 0),
person_in_charge_id text not null references users(id) on delete restrict,
person_in_charge_name text not null,
person_in_charge_phone_number text not null,
status text not null check (status in ('pending', 'confirmed')) default 'pending',
confirmed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
)
`
await sql`
create table if not exists booking_settings (
id text primary key,
total_tables integer,
total_seats integer,
updated_at timestamptz not null default now()
)
`
await sql`
insert into booking_settings (id)
values ('default')
on conflict (id) do nothing
`
const [existingSuperAdmin] = await sql<{ id: string }[]>`
select id
from users

View File

@@ -256,6 +256,36 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
}))
}
export async function getPublicContactById(contactId: string): Promise<PublicContact | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
select
users.id,
users.full_name,
users.phone_number,
users.role
from users
where users.id = ${contactId}
and users.is_active = true
and users.phone_number is not null
and users.phone_number <> ''
limit 1
`
if (!row) {
return null
}
return {
id: row.id,
fullName: row.full_name,
phoneNumber: row.phone_number || '',
role: row.role
}
}
export async function createUser(input: {
username: string
fullName: string

View File

@@ -2,25 +2,13 @@ import type { H3Event } from 'h3'
import type { AuthenticatorTransportFuture, WebAuthnCredential } from '@simplewebauthn/server'
import { getRequestURL } from 'h3'
import { decodeBase64Url, randomToken } from './base64url'
import { getAppOrigin } from './app-url'
import { getRedisClient } from './redis'
import type { PasskeyRecord } from './user-repository'
const CHALLENGE_TTL_SECONDS = 60 * 5
function getAppOrigin(event: H3Event) {
const config = useRuntimeConfig()
if (config.public.appUrl) {
return new URL(config.public.appUrl).origin
}
const url = getRequestURL(event)
return `${url.protocol}//${url.host}`
}
export function getWebAuthnConfig(event: H3Event) {
const config = useRuntimeConfig()
const origin = getAppOrigin(event)

View File

@@ -90,5 +90,5 @@ export function getDefaultAuthenticatedPath(
return '/security'
}
return user.role === 'super_admin' ? '/management/users' : '/security'
return user.role === 'super_admin' ? '/management/users' : '/bookings'
}

150
shared/booking.ts Normal file
View File

@@ -0,0 +1,150 @@
export type BookingMode = 'table' | 'pax'
export type TicketType = 'vip' | 'supporter'
export type BookingStatus = 'pending' | 'confirmed'
export const BOOKING_MODE_OPTIONS = [
{
value: 'table',
label: 'Table (10 pax)'
},
{
value: 'pax',
label: 'Person'
}
] satisfies Array<{ value: BookingMode, label: string }>
export const BOOKING_TICKET_CATALOG = [
{
value: 'vip',
label: 'VIP',
description: 'RM150 / pax',
price: 150
},
{
value: 'supporter',
label: 'Supporter',
description: 'RM60 / pax',
price: 60
}
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
export interface PublicBooking {
id: string
confirmationToken: string
customerName: string
customerPhone: string
bookingMode: BookingMode
quantity: number
seatCount: number
ticketType: TicketType
unitPrice: number
totalPrice: number
personInChargeId: string
personInChargeName: string
personInChargePhoneNumber: string
status: BookingStatus
createdAt: string
confirmedAt: string | null
}
export interface BookingCapacitySettings {
totalTables: number | null
updatedAt: string | null
}
export interface BookingInventorySummary {
totalTables: number | null
totalCapacitySeats: number | null
soldTables: number
pendingTables: number
soldSeats: number
pendingSeats: number
soldCapacitySeats: number
pendingCapacitySeats: number
leftTables: number | null
leftSeats: number | null
leftCapacitySeats: number | null
}
export interface CreateBookingResponse {
booking: PublicBooking
confirmationUrl: string
whatsappUrl: string
}
export function isBookingMode(value: string | null | undefined): value is BookingMode {
return value === 'table' || value === 'pax'
}
export function isTicketType(value: string | null | undefined): value is TicketType {
return value === 'vip' || value === 'supporter'
}
export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
return value === 'pending' || value === 'confirmed'
}
export function getBookingModeLabel(value: BookingMode) {
return value === 'table' ? 'Table (10 pax each)' : 'Per person'
}
export function getBookingStatusLabel(value: BookingStatus) {
return value === 'confirmed' ? 'Confirmed' : 'Pending PIC confirmation'
}
export function getSeatCount(bookingMode: BookingMode, quantity: number) {
return bookingMode === 'table' ? quantity * 10 : quantity
}
export function getTicketCatalogItem(ticketType: TicketType) {
return BOOKING_TICKET_CATALOG.find((ticket) => ticket.value === ticketType) ?? null
}
export function formatBookingCurrency(value: number) {
return new Intl.NumberFormat('en-MY', {
style: 'currency',
currency: 'MYR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
}
export function calculateBookingInventorySummary(
bookings: Pick<PublicBooking, 'bookingMode' | 'quantity' | 'seatCount' | 'status'>[],
settings: BookingCapacitySettings
): BookingInventorySummary {
const soldTables = bookings
.filter((booking) => booking.status === 'confirmed' && booking.bookingMode === 'table')
.reduce((total, booking) => total + booking.quantity, 0)
const pendingTables = bookings
.filter((booking) => booking.status === 'pending' && booking.bookingMode === 'table')
.reduce((total, booking) => total + booking.quantity, 0)
const soldSeats = bookings
.filter((booking) => booking.status === 'confirmed' && booking.bookingMode === 'pax')
.reduce((total, booking) => total + booking.seatCount, 0)
const pendingSeats = bookings
.filter((booking) => booking.status === 'pending' && booking.bookingMode === 'pax')
.reduce((total, booking) => total + booking.seatCount, 0)
const totalCapacitySeats = settings.totalTables === null ? null : settings.totalTables * 10
const soldCapacitySeats = (soldTables * 10) + soldSeats
const pendingCapacitySeats = (pendingTables * 10) + pendingSeats
const leftCapacitySeats = totalCapacitySeats === null ? null : Math.max(totalCapacitySeats - soldCapacitySeats, 0)
return {
totalTables: settings.totalTables,
totalCapacitySeats,
soldTables,
pendingTables,
soldSeats,
pendingSeats,
soldCapacitySeats,
pendingCapacitySeats,
leftTables: leftCapacitySeats === null ? null : Math.floor(leftCapacitySeats / 10),
leftSeats: leftCapacitySeats === null ? null : leftCapacitySeats % 10,
leftCapacitySeats
}
}