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:
2026-04-13 08:49:54 +08:00
parent c47d0d287e
commit faa998c7e1
12 changed files with 136 additions and 139 deletions

View File

@@ -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)
} }
}) })

View File

@@ -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)

View File

@@ -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>

View File

@@ -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)"

View File

@@ -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">

View File

@@ -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)

View File

@@ -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

View File

@@ -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')
} }

View File

@@ -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
` `

View File

@@ -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
} }
} }

View File

@@ -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,

View File

@@ -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
} }
} }