refactor(bookings): simplify capacity tracking to use seats
Replace 'pax' booking mode with 'seat' Consolidate inventory summary to track only seat counts Update database schema and UI for seat-centric capacity
This commit is contained in:
@@ -45,15 +45,15 @@
|
|||||||
|
|
||||||
<UForm :state="capacityForm" class="space-y-3" @submit="saveCapacity">
|
<UForm :state="capacityForm" class="space-y-3" @submit="saveCapacity">
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
<UFormField name="totalTables" label="Total Tables" class="flex-1">
|
<UFormField name="totalSeats" label="Total Seats" class="flex-1">
|
||||||
<UInput
|
<UInput
|
||||||
v-model="capacityForm.totalTables"
|
v-model="capacityForm.totalSeats"
|
||||||
type="number"
|
type="number"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
min="0"
|
min="0"
|
||||||
size="md"
|
size="md"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
placeholder="Leave blank for no table limit"
|
placeholder="Leave blank for no seat limit"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
@@ -90,10 +90,10 @@
|
|||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div class="rounded-lg border border-default bg-muted/20 p-3">
|
<div class="rounded-lg border border-default bg-muted/20 p-3">
|
||||||
<p class="text-xs uppercase tracking-wide text-muted">
|
<p class="text-xs uppercase tracking-wide text-muted">
|
||||||
Pending tables
|
Pending bookings
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xl font-semibold text-highlighted">
|
<p class="mt-1 text-xl font-semibold text-highlighted">
|
||||||
{{ summary.pendingTables }}
|
{{ pendingCount }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<UCard
|
<UCard
|
||||||
v-for="item in inventoryCards"
|
v-for="item in inventoryCards"
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
@@ -123,9 +123,6 @@
|
|||||||
<p class="text-2xl font-semibold leading-none text-highlighted">
|
<p class="text-2xl font-semibold leading-none text-highlighted">
|
||||||
{{ item.value }}
|
{{ item.value }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="item.meta" class="text-xs text-muted">
|
|
||||||
{{ item.meta }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,10 +131,10 @@
|
|||||||
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'px-4 py-3' }">
|
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'px-4 py-3' }">
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||||
Total Tables
|
Total Seats
|
||||||
</p>
|
</p>
|
||||||
<p class="text-2xl font-semibold leading-none text-highlighted">
|
<p class="text-2xl font-semibold leading-none text-highlighted">
|
||||||
{{ formatInventoryNumber(summary.totalTables) }}
|
{{ formatInventoryNumber(summary.totalSeats) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
@@ -213,13 +210,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #bookingMode-cell="{ row }">
|
<template #quantity-cell="{ row }">
|
||||||
<div class="space-y-0.5 py-0.5">
|
<div class="space-y-0.5 py-0.5">
|
||||||
<div class="text-sm font-medium text-default">
|
<div class="text-sm font-medium text-default">
|
||||||
{{ getBookingModeLabel(row.original.bookingMode) }}
|
{{ ticketLabel(row.original.ticketType) }}
|
||||||
</div>
|
|
||||||
<div class="text-xs text-muted">
|
|
||||||
{{ row.original.quantity }} x {{ ticketLabel(row.original.ticketType) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -298,7 +292,6 @@ import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking, T
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
getBookingModeLabel,
|
|
||||||
getBookingStatusLabel,
|
getBookingStatusLabel,
|
||||||
getTicketCatalogItem
|
getTicketCatalogItem
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
@@ -319,29 +312,22 @@ const loadingBookings = ref(false)
|
|||||||
const savingCapacity = ref(false)
|
const savingCapacity = ref(false)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const settings = reactive<BookingCapacitySettings>({
|
const settings = reactive<BookingCapacitySettings>({
|
||||||
totalTables: null,
|
totalSeats: null,
|
||||||
updatedAt: null
|
updatedAt: null
|
||||||
})
|
})
|
||||||
const summary = reactive<BookingInventorySummary>({
|
const summary = reactive<BookingInventorySummary>({
|
||||||
totalTables: null,
|
totalSeats: null,
|
||||||
totalCapacitySeats: null,
|
|
||||||
soldTables: 0,
|
|
||||||
pendingTables: 0,
|
|
||||||
soldSeats: 0,
|
soldSeats: 0,
|
||||||
pendingSeats: 0,
|
pendingSeats: 0,
|
||||||
soldCapacitySeats: 0,
|
leftSeats: null
|
||||||
pendingCapacitySeats: 0,
|
|
||||||
leftTables: null,
|
|
||||||
leftSeats: null,
|
|
||||||
leftCapacitySeats: null
|
|
||||||
})
|
})
|
||||||
const capacityForm = reactive({
|
const capacityForm = reactive({
|
||||||
totalTables: ''
|
totalSeats: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ accessorKey: 'customerName', header: 'Guest' },
|
{ accessorKey: 'customerName', header: 'Guest' },
|
||||||
{ accessorKey: 'bookingMode', header: 'Booking' },
|
{ accessorKey: 'quantity', header: 'Booking' },
|
||||||
{ accessorKey: 'seatCount', header: 'Seats / Total' },
|
{ accessorKey: 'seatCount', header: 'Seats / Total' },
|
||||||
{ accessorKey: 'personInChargeName', header: 'PIC' },
|
{ accessorKey: 'personInChargeName', header: 'PIC' },
|
||||||
{ id: 'status', header: 'Status' },
|
{ id: 'status', header: 'Status' },
|
||||||
@@ -350,27 +336,18 @@ const columns = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const inventoryDescription = computed(() => {
|
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.'
|
return 'Every booking is converted into seats immediately, so sold and remaining capacity are tracked only in seats.'
|
||||||
})
|
})
|
||||||
|
|
||||||
const inventoryCards = computed(() => {
|
const inventoryCards = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
label: 'Tables Sold',
|
|
||||||
value: String(summary.soldTables)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Seats Sold',
|
label: 'Seats Sold',
|
||||||
value: String(summary.soldSeats)
|
value: String(summary.soldSeats)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Capacity Used',
|
label: 'Pending Seats',
|
||||||
value: String(summary.soldCapacitySeats),
|
value: String(summary.pendingSeats)
|
||||||
meta: 'in seats'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Tables Left',
|
|
||||||
value: formatInventoryNumber(summary.leftTables)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Seats Left',
|
label: 'Seats Left',
|
||||||
@@ -434,27 +411,20 @@ function normalizeCapacityValue(value: string | number | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncCapacityForm(nextSettings: BookingCapacitySettings) {
|
function syncCapacityForm(nextSettings: BookingCapacitySettings) {
|
||||||
capacityForm.totalTables = nextSettings.totalTables === null ? '' : String(nextSettings.totalTables)
|
capacityForm.totalSeats = nextSettings.totalSeats === null ? '' : String(nextSettings.totalSeats)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySettings(nextSettings: BookingCapacitySettings) {
|
function applySettings(nextSettings: BookingCapacitySettings) {
|
||||||
settings.totalTables = nextSettings.totalTables
|
settings.totalSeats = nextSettings.totalSeats
|
||||||
settings.updatedAt = nextSettings.updatedAt
|
settings.updatedAt = nextSettings.updatedAt
|
||||||
syncCapacityForm(nextSettings)
|
syncCapacityForm(nextSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySummary(nextSummary: BookingInventorySummary) {
|
function applySummary(nextSummary: BookingInventorySummary) {
|
||||||
summary.totalTables = nextSummary.totalTables
|
summary.totalSeats = nextSummary.totalSeats
|
||||||
summary.totalCapacitySeats = nextSummary.totalCapacitySeats
|
|
||||||
summary.soldTables = nextSummary.soldTables
|
|
||||||
summary.pendingTables = nextSummary.pendingTables
|
|
||||||
summary.soldSeats = nextSummary.soldSeats
|
summary.soldSeats = nextSummary.soldSeats
|
||||||
summary.pendingSeats = nextSummary.pendingSeats
|
summary.pendingSeats = nextSummary.pendingSeats
|
||||||
summary.soldCapacitySeats = nextSummary.soldCapacitySeats
|
|
||||||
summary.pendingCapacitySeats = nextSummary.pendingCapacitySeats
|
|
||||||
summary.leftTables = nextSummary.leftTables
|
|
||||||
summary.leftSeats = nextSummary.leftSeats
|
summary.leftSeats = nextSummary.leftSeats
|
||||||
summary.leftCapacitySeats = nextSummary.leftCapacitySeats
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshBookings() {
|
async function refreshBookings() {
|
||||||
@@ -499,7 +469,7 @@ async function saveCapacity(event: Event) {
|
|||||||
const response = await apiClient<{ settings: BookingCapacitySettings }>('/api/bookings/capacity', {
|
const response = await apiClient<{ settings: BookingCapacitySettings }>('/api/bookings/capacity', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: {
|
body: {
|
||||||
totalTables: normalizeCapacityValue(capacityForm.totalTables)
|
totalSeats: normalizeCapacityValue(capacityForm.totalSeats)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { PublicBooking } from '~~/shared/booking'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
getBookingModeLabel,
|
|
||||||
getBookingStatusLabel,
|
getBookingStatusLabel,
|
||||||
getTicketCatalogItem
|
getTicketCatalogItem
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
@@ -54,18 +53,10 @@ const detailRows = computed(() => {
|
|||||||
label: 'PIC Phone',
|
label: 'PIC Phone',
|
||||||
value: booking.value.personInChargePhoneNumber
|
value: booking.value.personInChargePhoneNumber
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Booking Mode',
|
|
||||||
value: getBookingModeLabel(booking.value.bookingMode)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Ticket Category',
|
label: 'Ticket Category',
|
||||||
value: ticketLabel.value
|
value: ticketLabel.value
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Quantity',
|
|
||||||
value: String(booking.value.quantity)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Seats Covered',
|
label: 'Seats Covered',
|
||||||
value: String(booking.value.seatCount)
|
value: String(booking.value.seatCount)
|
||||||
|
|||||||
@@ -67,10 +67,11 @@ const selectedTicket = computed(() => getTicketCatalogItem(form.ticketType) ?? B
|
|||||||
const submittingBooking = ref(false)
|
const submittingBooking = ref(false)
|
||||||
|
|
||||||
const quantityLabel = computed(() => {
|
const quantityLabel = computed(() => {
|
||||||
return form.bookingMode === 'table' ? 'Number of tables' : 'Number of people'
|
return form.bookingMode === 'table' ? 'Number of Tables' : 'Number of Seats'
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalPrice = computed(() => getSeatCount(form.bookingMode, form.quantity) * selectedTicket.value.price)
|
const seatCount = computed(() => getSeatCount(form.bookingMode, form.quantity))
|
||||||
|
const totalPrice = computed(() => seatCount.value * selectedTicket.value.price)
|
||||||
|
|
||||||
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value))
|
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value))
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ function validateBooking(state: typeof form): FormError[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.quantity < 1) {
|
if (state.quantity < 1) {
|
||||||
errors.push({ name: 'quantity', message: 'Quantity must be at least 1.' })
|
errors.push({ name: 'quantity', message: `${quantityLabel.value} must be at least 1.` })
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
@@ -194,6 +195,9 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
|||||||
|
|
||||||
<UFormField :label="quantityLabel" name="quantity">
|
<UFormField :label="quantityLabel" name="quantity">
|
||||||
<UInputNumber v-model="form.quantity" size="xl" class="w-full" :min="1" :step="1" />
|
<UInputNumber v-model="form.quantity" size="xl" class="w-full" :min="1" :step="1" />
|
||||||
|
<template #help>
|
||||||
|
This booking will generate {{ seatCount }} seat{{ seatCount === 1 ? '' : 's' }}.
|
||||||
|
</template>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Ticket Category" name="ticketType">
|
<UFormField label="Ticket Category" name="ticketType">
|
||||||
@@ -221,7 +225,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
|||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UButton id="getTicketBtn" type="submit" label="Book Your Ticket Now" size="xl"
|
<UButton id="getTicketBtn" type="submit" label="Book Now" size="xl"
|
||||||
class="w-full justify-center" :disabled="!selectedPersonInCharge" :loading="submittingBooking" />
|
class="w-full justify-center" :disabled="!selectedPersonInCharge" :loading="submittingBooking" />
|
||||||
</UForm>
|
</UForm>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ async function openBatchShare() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
label="Share Seat"
|
label="Share Seats"
|
||||||
icon="i-lucide-share-2"
|
icon="i-lucide-share-2"
|
||||||
class="w-full justify-center sm:w-auto"
|
class="w-full justify-center sm:w-auto"
|
||||||
:disabled="!availableSeats.length || shareSeatsLoading || Boolean(seatActionId)"
|
:disabled="!availableSeats.length || shareSeatsLoading || Boolean(seatActionId)"
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
DINNER_EVENT_TITLE,
|
DINNER_EVENT_TITLE,
|
||||||
DINNER_EVENT_VENUE,
|
DINNER_EVENT_VENUE,
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
getBookingModeLabel,
|
|
||||||
getSeatLabel,
|
getSeatLabel,
|
||||||
getTicketCatalogItem
|
getTicketCatalogItem
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
@@ -106,10 +105,10 @@ const totalFormatted = computed(() => formatBookingCurrency(receipt.value.bookin
|
|||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
<div class="rounded-2xl border border-default bg-elevated p-4">
|
||||||
<p class="text-xs uppercase tracking-wide text-muted">
|
<p class="text-xs uppercase tracking-wide text-muted">
|
||||||
Booking Mode
|
Total Seats
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 font-semibold text-highlighted">
|
<p class="mt-2 font-semibold text-highlighted">
|
||||||
{{ getBookingModeLabel(receipt.booking.bookingMode) }}
|
{{ receipt.booking.seatCount }} seats
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-default bg-elevated p-4">
|
<div class="rounded-2xl border border-default bg-elevated p-4">
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
await requireRole(event, 'super_admin')
|
await requireRole(event, 'super_admin')
|
||||||
|
|
||||||
const body = await readBody<{
|
const body = await readBody<{
|
||||||
totalTables?: number | string | null
|
totalSeats?: number | string | null
|
||||||
}>(event)
|
}>(event)
|
||||||
|
|
||||||
const input = parseBookingCapacityInput(body)
|
const input = parseBookingCapacityInput(body)
|
||||||
const summary = await getBookingInventorySummary()
|
const summary = await getBookingInventorySummary()
|
||||||
|
|
||||||
assertBadRequest(
|
assertBadRequest(
|
||||||
input.totalTables === null || (input.totalTables * 10) >= summary.soldCapacitySeats,
|
input.totalSeats === null || input.totalSeats >= summary.soldSeats,
|
||||||
`Total tables cannot be lower than the currently sold capacity of ${summary.soldTables} tables and ${summary.soldSeats} seats`
|
`Total seats cannot be lower than the currently sold count of ${summary.soldSeats} seats`
|
||||||
)
|
)
|
||||||
|
|
||||||
const settings = await updateBookingCapacitySettings(input)
|
const settings = await updateBookingCapacitySettings(input)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event): Promise<CreateBookingResponse>
|
|||||||
const body = await readBody<{
|
const body = await readBody<{
|
||||||
customerName?: string
|
customerName?: string
|
||||||
customerPhone?: string
|
customerPhone?: string
|
||||||
bookingMode?: BookingMode
|
bookingMode?: BookingMode | string | null
|
||||||
quantity?: number
|
quantity?: number
|
||||||
ticketType?: TicketType
|
ticketType?: TicketType
|
||||||
personInChargeId?: string
|
personInChargeId?: string
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const summary = await getBookingInventorySummary()
|
const summary = await getBookingInventorySummary()
|
||||||
|
|
||||||
if (summary.leftCapacitySeats !== null && existingBooking.seatCount > summary.leftCapacitySeats) {
|
if (summary.leftSeats !== null && existingBooking.seatCount > summary.leftSeats) {
|
||||||
httpError(409, 'Not enough capacity left to confirm this booking')
|
httpError(409, 'Not enough capacity left to confirm this booking')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
TicketType
|
TicketType
|
||||||
} from '~~/shared/booking'
|
} from '~~/shared/booking'
|
||||||
|
|
||||||
import { calculateBookingInventorySummary, isBookingStatus } from '~~/shared/booking'
|
import { calculateBookingInventorySummary, isBookingMode, isBookingStatus } from '~~/shared/booking'
|
||||||
|
|
||||||
import { randomToken, toIsoString } from './base64url'
|
import { randomToken, toIsoString } from './base64url'
|
||||||
import { ensureDatabaseReady } from './db-init'
|
import { ensureDatabaseReady } from './db-init'
|
||||||
@@ -23,7 +23,7 @@ type DbBookingRow = {
|
|||||||
receipt_token: string
|
receipt_token: string
|
||||||
customer_name: string
|
customer_name: string
|
||||||
customer_phone: string
|
customer_phone: string
|
||||||
booking_mode: BookingMode
|
booking_mode: string
|
||||||
quantity: number | string
|
quantity: number | string
|
||||||
seat_count: number | string
|
seat_count: number | string
|
||||||
ticket_type: TicketType
|
ticket_type: TicketType
|
||||||
@@ -54,7 +54,7 @@ type DbBookingSeatWithBookingRow = DbBookingSeatRow & {
|
|||||||
receipt_token: string
|
receipt_token: string
|
||||||
customer_name: string
|
customer_name: string
|
||||||
customer_phone: string
|
customer_phone: string
|
||||||
booking_mode: BookingMode
|
booking_mode: string
|
||||||
quantity: number | string
|
quantity: number | string
|
||||||
seat_count: number | string
|
seat_count: number | string
|
||||||
ticket_type: TicketType
|
ticket_type: TicketType
|
||||||
@@ -67,6 +67,7 @@ type DbBookingSeatWithBookingRow = DbBookingSeatRow & {
|
|||||||
|
|
||||||
type DbBookingSettingsRow = {
|
type DbBookingSettingsRow = {
|
||||||
total_tables: number | string | null
|
total_tables: number | string | null
|
||||||
|
total_seats: number | string | null
|
||||||
updated_at: Date | string
|
updated_at: Date | string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,16 +75,22 @@ function parseInteger(value: number | string) {
|
|||||||
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBookingMode(value: string): BookingMode {
|
||||||
|
return isBookingMode(value) ? value : 'seat'
|
||||||
|
}
|
||||||
|
|
||||||
function mapBooking(row: DbBookingRow): PublicBooking {
|
function mapBooking(row: DbBookingRow): PublicBooking {
|
||||||
|
const seatCount = parseInteger(row.seat_count)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
confirmationToken: row.confirmation_token,
|
confirmationToken: row.confirmation_token,
|
||||||
receiptToken: row.receipt_token,
|
receiptToken: row.receipt_token,
|
||||||
customerName: row.customer_name,
|
customerName: row.customer_name,
|
||||||
customerPhone: row.customer_phone,
|
customerPhone: row.customer_phone,
|
||||||
bookingMode: row.booking_mode,
|
bookingMode: normalizeBookingMode(row.booking_mode),
|
||||||
quantity: parseInteger(row.quantity),
|
quantity: parseInteger(row.quantity),
|
||||||
seatCount: parseInteger(row.seat_count),
|
seatCount,
|
||||||
ticketType: row.ticket_type,
|
ticketType: row.ticket_type,
|
||||||
unitPrice: parseInteger(row.unit_price),
|
unitPrice: parseInteger(row.unit_price),
|
||||||
totalPrice: parseInteger(row.total_price),
|
totalPrice: parseInteger(row.total_price),
|
||||||
@@ -97,14 +104,16 @@ function mapBooking(row: DbBookingRow): PublicBooking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapReceiptBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): ReceiptBooking {
|
function mapReceiptBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): ReceiptBooking {
|
||||||
|
const seatCount = parseInteger(row.seat_count)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
receiptToken: row.receipt_token,
|
receiptToken: row.receipt_token,
|
||||||
customerName: row.customer_name,
|
customerName: row.customer_name,
|
||||||
customerPhone: row.customer_phone,
|
customerPhone: row.customer_phone,
|
||||||
bookingMode: row.booking_mode,
|
bookingMode: normalizeBookingMode(row.booking_mode),
|
||||||
quantity: parseInteger(row.quantity),
|
quantity: parseInteger(row.quantity),
|
||||||
seatCount: parseInteger(row.seat_count),
|
seatCount,
|
||||||
ticketType: row.ticket_type,
|
ticketType: row.ticket_type,
|
||||||
unitPrice: parseInteger(row.unit_price),
|
unitPrice: parseInteger(row.unit_price),
|
||||||
totalPrice: parseInteger(row.total_price),
|
totalPrice: parseInteger(row.total_price),
|
||||||
@@ -130,13 +139,17 @@ function mapBookingSeat(row: DbBookingSeatRow): PublicBookingSeat {
|
|||||||
function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): BookingCapacitySettings {
|
function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): BookingCapacitySettings {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return {
|
return {
|
||||||
totalTables: null,
|
totalSeats: null,
|
||||||
updatedAt: null
|
updatedAt: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalSeats = row.total_seats === null
|
||||||
|
? (row.total_tables === null ? null : parseInteger(row.total_tables) * 10)
|
||||||
|
: parseInteger(row.total_seats)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalTables: row.total_tables === null ? null : parseInteger(row.total_tables),
|
totalSeats,
|
||||||
updatedAt: toIsoString(row.updated_at)
|
updatedAt: toIsoString(row.updated_at)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,6 +534,7 @@ export async function getBookingCapacitySettings(): Promise<BookingCapacitySetti
|
|||||||
const [row] = await sql<DbBookingSettingsRow[]>`
|
const [row] = await sql<DbBookingSettingsRow[]>`
|
||||||
select
|
select
|
||||||
total_tables,
|
total_tables,
|
||||||
|
total_seats,
|
||||||
updated_at
|
updated_at
|
||||||
from booking_settings
|
from booking_settings
|
||||||
where id = 'default'
|
where id = 'default'
|
||||||
@@ -531,7 +545,7 @@ export async function getBookingCapacitySettings(): Promise<BookingCapacitySetti
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBookingCapacitySettings(input: {
|
export async function updateBookingCapacitySettings(input: {
|
||||||
totalTables: number | null
|
totalSeats: number | null
|
||||||
}): Promise<BookingCapacitySettings> {
|
}): Promise<BookingCapacitySettings> {
|
||||||
await ensureDatabaseReady()
|
await ensureDatabaseReady()
|
||||||
const sql = getSqlClient()
|
const sql = getSqlClient()
|
||||||
@@ -539,11 +553,12 @@ export async function updateBookingCapacitySettings(input: {
|
|||||||
const [row] = await sql<DbBookingSettingsRow[]>`
|
const [row] = await sql<DbBookingSettingsRow[]>`
|
||||||
update booking_settings
|
update booking_settings
|
||||||
set
|
set
|
||||||
total_tables = ${input.totalTables},
|
total_seats = ${input.totalSeats},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where id = 'default'
|
where id = 'default'
|
||||||
returning
|
returning
|
||||||
total_tables,
|
total_tables,
|
||||||
|
total_seats,
|
||||||
updated_at
|
updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType }
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
formatBookingCurrency,
|
formatBookingCurrency,
|
||||||
getBookingModeLabel,
|
|
||||||
getTicketCatalogItem,
|
getTicketCatalogItem,
|
||||||
isBookingMode,
|
isBookingMode,
|
||||||
isTicketType
|
isTicketType
|
||||||
@@ -14,14 +13,14 @@ import { assertBadRequest } from './http'
|
|||||||
export function parseCreateBookingInput(body: {
|
export function parseCreateBookingInput(body: {
|
||||||
customerName?: string
|
customerName?: string
|
||||||
customerPhone?: string
|
customerPhone?: string
|
||||||
bookingMode?: BookingMode
|
bookingMode?: BookingMode | string | null
|
||||||
quantity?: number
|
quantity?: number
|
||||||
ticketType?: TicketType
|
ticketType?: TicketType
|
||||||
personInChargeId?: string
|
personInChargeId?: string
|
||||||
}) {
|
}) {
|
||||||
const customerName = normalizeFullName(body.customerName || '')
|
const customerName = normalizeFullName(body.customerName || '')
|
||||||
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
|
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
|
||||||
const bookingMode = body.bookingMode
|
const bookingMode = typeof body.bookingMode === 'string' ? body.bookingMode.trim().toLowerCase() : body.bookingMode
|
||||||
const ticketType = body.ticketType
|
const ticketType = body.ticketType
|
||||||
const quantity = Number(body.quantity)
|
const quantity = Number(body.quantity)
|
||||||
const personInChargeId = (body.personInChargeId || '').trim()
|
const personInChargeId = (body.personInChargeId || '').trim()
|
||||||
@@ -52,10 +51,8 @@ export function buildBookingMessage(booking: PublicBooking, confirmationUrl: str
|
|||||||
'',
|
'',
|
||||||
`Name: ${booking.customerName}`,
|
`Name: ${booking.customerName}`,
|
||||||
`Phone Number: ${booking.customerPhone}`,
|
`Phone Number: ${booking.customerPhone}`,
|
||||||
`Booking Mode: ${getBookingModeLabel(booking.bookingMode)}`,
|
`Seats: ${booking.seatCount}`,
|
||||||
`Quantity: ${booking.quantity}`,
|
|
||||||
`Ticket Category: ${ticketLabel}`,
|
`Ticket Category: ${ticketLabel}`,
|
||||||
`Seats Covered: ${booking.seatCount}`,
|
|
||||||
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
|
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
|
||||||
'',
|
'',
|
||||||
'PIC confirmation link:',
|
'PIC confirmation link:',
|
||||||
@@ -87,14 +84,14 @@ export function parseSeatShareInput(body: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseBookingCapacityInput(body: {
|
export function parseBookingCapacityInput(body: {
|
||||||
totalTables?: number | string | null
|
totalSeats?: number | string | null
|
||||||
}): Pick<BookingCapacitySettings, 'totalTables'> {
|
}): Pick<BookingCapacitySettings, 'totalSeats'> {
|
||||||
const totalTables = parseOptionalInteger(body.totalTables)
|
const totalSeats = parseOptionalInteger(body.totalSeats)
|
||||||
|
|
||||||
assertBadRequest(totalTables === null || totalTables >= 0, 'Total tables must be 0 or greater')
|
assertBadRequest(totalSeats === null || totalSeats >= 0, 'Total seats must be 0 or greater')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalTables
|
totalSeats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ async function initializeDatabase() {
|
|||||||
receipt_token text not null unique,
|
receipt_token text not null unique,
|
||||||
customer_name text not null,
|
customer_name text not null,
|
||||||
customer_phone text not null,
|
customer_phone text not null,
|
||||||
booking_mode text not null check (booking_mode in ('table', 'pax')),
|
booking_mode text not null check (booking_mode in ('table', 'seat')),
|
||||||
quantity integer not null check (quantity >= 1),
|
quantity integer not null check (quantity >= 1),
|
||||||
seat_count integer not null check (seat_count >= 1),
|
seat_count integer not null check (seat_count >= 1),
|
||||||
ticket_type text not null check (ticket_type in ('vip', 'supporter')),
|
ticket_type text not null check (ticket_type in ('vip', 'supporter')),
|
||||||
@@ -124,6 +124,11 @@ async function initializeDatabase() {
|
|||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table booking_settings
|
||||||
|
add column if not exists total_seats integer
|
||||||
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
insert into booking_settings (id)
|
insert into booking_settings (id)
|
||||||
values ('default')
|
values ('default')
|
||||||
@@ -146,6 +151,45 @@ async function initializeDatabase() {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
drop constraint if exists bookings_booking_mode_check
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add constraint bookings_booking_mode_check
|
||||||
|
check (booking_mode in ('table', 'pax', 'seat'))
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
update bookings
|
||||||
|
set
|
||||||
|
booking_mode = 'seat',
|
||||||
|
updated_at = now()
|
||||||
|
where booking_mode = 'pax'
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
drop constraint if exists bookings_booking_mode_check
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add constraint bookings_booking_mode_check
|
||||||
|
check (booking_mode in ('table', 'seat'))
|
||||||
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
update booking_settings
|
||||||
|
set
|
||||||
|
total_seats = total_tables * 10,
|
||||||
|
updated_at = now()
|
||||||
|
where total_seats is null
|
||||||
|
and total_tables is not null
|
||||||
|
`
|
||||||
|
|
||||||
const existingBookings = await sql<{ id: string, seat_count: number | string }[]>`
|
const existingBookings = await sql<{ id: string, seat_count: number | string }[]>`
|
||||||
select
|
select
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type BookingMode = 'table' | 'pax'
|
export type BookingMode = 'table' | 'seat'
|
||||||
export type TicketType = 'vip' | 'supporter'
|
export type TicketType = 'vip' | 'supporter'
|
||||||
export type BookingStatus = 'pending' | 'confirmed'
|
export type BookingStatus = 'pending' | 'confirmed'
|
||||||
|
|
||||||
@@ -7,14 +7,16 @@ export const DINNER_EVENT_DATE_LABEL = 'Saturday, 30 May 2026'
|
|||||||
export const DINNER_EVENT_TIME_LABEL = '6:30 PM'
|
export const DINNER_EVENT_TIME_LABEL = '6:30 PM'
|
||||||
export const DINNER_EVENT_VENUE = "Yong Peng's Chee Ann Kor"
|
export const DINNER_EVENT_VENUE = "Yong Peng's Chee Ann Kor"
|
||||||
|
|
||||||
|
export const TABLE_SEAT_COUNT = 10
|
||||||
|
|
||||||
export const BOOKING_MODE_OPTIONS = [
|
export const BOOKING_MODE_OPTIONS = [
|
||||||
{
|
{
|
||||||
value: 'table',
|
value: 'table',
|
||||||
label: 'Table (10 pax)'
|
label: `Table (${TABLE_SEAT_COUNT} seats)`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'pax',
|
value: 'seat',
|
||||||
label: 'Person'
|
label: 'Seat'
|
||||||
}
|
}
|
||||||
] satisfies Array<{ value: BookingMode, label: string }>
|
] satisfies Array<{ value: BookingMode, label: string }>
|
||||||
|
|
||||||
@@ -22,13 +24,13 @@ export const BOOKING_TICKET_CATALOG = [
|
|||||||
{
|
{
|
||||||
value: 'vip',
|
value: 'vip',
|
||||||
label: 'VIP',
|
label: 'VIP',
|
||||||
description: 'RM150 / pax',
|
description: 'RM150 / seat',
|
||||||
price: 150
|
price: 150
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'supporter',
|
value: 'supporter',
|
||||||
label: 'Supporter',
|
label: 'Supporter',
|
||||||
description: 'RM60 / pax',
|
description: 'RM60 / seat',
|
||||||
price: 60
|
price: 60
|
||||||
}
|
}
|
||||||
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
|
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
|
||||||
@@ -97,22 +99,15 @@ export interface PublicSeatReceipt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingCapacitySettings {
|
export interface BookingCapacitySettings {
|
||||||
totalTables: number | null
|
totalSeats: number | null
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingInventorySummary {
|
export interface BookingInventorySummary {
|
||||||
totalTables: number | null
|
totalSeats: number | null
|
||||||
totalCapacitySeats: number | null
|
|
||||||
soldTables: number
|
|
||||||
pendingTables: number
|
|
||||||
soldSeats: number
|
soldSeats: number
|
||||||
pendingSeats: number
|
pendingSeats: number
|
||||||
soldCapacitySeats: number
|
|
||||||
pendingCapacitySeats: number
|
|
||||||
leftTables: number | null
|
|
||||||
leftSeats: number | null
|
leftSeats: number | null
|
||||||
leftCapacitySeats: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBookingResponse {
|
export interface CreateBookingResponse {
|
||||||
@@ -122,7 +117,7 @@ export interface CreateBookingResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isBookingMode(value: string | null | undefined): value is BookingMode {
|
export function isBookingMode(value: string | null | undefined): value is BookingMode {
|
||||||
return value === 'table' || value === 'pax'
|
return value === 'table' || value === 'seat'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTicketType(value: string | null | undefined): value is TicketType {
|
export function isTicketType(value: string | null | undefined): value is TicketType {
|
||||||
@@ -134,7 +129,7 @@ export function isBookingStatus(value: string | null | undefined): value is Book
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getBookingModeLabel(value: BookingMode) {
|
export function getBookingModeLabel(value: BookingMode) {
|
||||||
return value === 'table' ? 'Table (10 pax each)' : 'Per person'
|
return value === 'table' ? `Table (${TABLE_SEAT_COUNT} seats each)` : 'Per seat'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBookingStatusLabel(value: BookingStatus) {
|
export function getBookingStatusLabel(value: BookingStatus) {
|
||||||
@@ -142,7 +137,7 @@ export function getBookingStatusLabel(value: BookingStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSeatCount(bookingMode: BookingMode, quantity: number) {
|
export function getSeatCount(bookingMode: BookingMode, quantity: number) {
|
||||||
return bookingMode === 'table' ? quantity * 10 : quantity
|
return bookingMode === 'table' ? quantity * TABLE_SEAT_COUNT : quantity
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTicketCatalogItem(ticketType: TicketType) {
|
export function getTicketCatalogItem(ticketType: TicketType) {
|
||||||
@@ -163,41 +158,23 @@ export function getSeatLabel(seatNumber: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function calculateBookingInventorySummary(
|
export function calculateBookingInventorySummary(
|
||||||
bookings: Pick<PublicBooking, 'bookingMode' | 'quantity' | 'seatCount' | 'status'>[],
|
bookings: Pick<PublicBooking, 'seatCount' | 'status'>[],
|
||||||
settings: BookingCapacitySettings
|
settings: BookingCapacitySettings
|
||||||
): BookingInventorySummary {
|
): 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
|
const soldSeats = bookings
|
||||||
.filter((booking) => booking.status === 'confirmed' && booking.bookingMode === 'pax')
|
.filter((booking) => booking.status === 'confirmed')
|
||||||
.reduce((total, booking) => total + booking.seatCount, 0)
|
.reduce((total, booking) => total + booking.seatCount, 0)
|
||||||
|
|
||||||
const pendingSeats = bookings
|
const pendingSeats = bookings
|
||||||
.filter((booking) => booking.status === 'pending' && booking.bookingMode === 'pax')
|
.filter((booking) => booking.status === 'pending')
|
||||||
.reduce((total, booking) => total + booking.seatCount, 0)
|
.reduce((total, booking) => total + booking.seatCount, 0)
|
||||||
|
|
||||||
const totalCapacitySeats = settings.totalTables === null ? null : settings.totalTables * 10
|
const leftSeats = settings.totalSeats === null ? null : Math.max(settings.totalSeats - soldSeats, 0)
|
||||||
const soldCapacitySeats = (soldTables * 10) + soldSeats
|
|
||||||
const pendingCapacitySeats = (pendingTables * 10) + pendingSeats
|
|
||||||
const leftCapacitySeats = totalCapacitySeats === null ? null : Math.max(totalCapacitySeats - soldCapacitySeats, 0)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalTables: settings.totalTables,
|
totalSeats: settings.totalSeats,
|
||||||
totalCapacitySeats,
|
|
||||||
soldTables,
|
|
||||||
pendingTables,
|
|
||||||
soldSeats,
|
soldSeats,
|
||||||
pendingSeats,
|
pendingSeats,
|
||||||
soldCapacitySeats,
|
leftSeats
|
||||||
pendingCapacitySeats,
|
|
||||||
leftTables: leftCapacitySeats === null ? null : Math.floor(leftCapacitySeats / 10),
|
|
||||||
leftSeats: leftCapacitySeats === null ? null : leftCapacitySeats % 10,
|
|
||||||
leftCapacitySeats
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user