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">
<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
v-model="capacityForm.totalTables"
v-model="capacityForm.totalSeats"
type="number"
inputmode="numeric"
min="0"
size="md"
class="w-full"
placeholder="Leave blank for no table limit"
placeholder="Leave blank for no seat limit"
/>
</UFormField>
@@ -90,10 +90,10 @@
<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
Pending bookings
</p>
<p class="mt-1 text-xl font-semibold text-highlighted">
{{ summary.pendingTables }}
{{ pendingCount }}
</p>
</div>
@@ -109,7 +109,7 @@
</UCard>
</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
v-for="item in inventoryCards"
:key="item.label"
@@ -123,9 +123,6 @@
<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>
@@ -134,10 +131,10 @@
<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
Total Seats
</p>
<p class="text-2xl font-semibold leading-none text-highlighted">
{{ formatInventoryNumber(summary.totalTables) }}
{{ formatInventoryNumber(summary.totalSeats) }}
</p>
</div>
</UCard>
@@ -213,13 +210,10 @@
</div>
</template>
<template #bookingMode-cell="{ row }">
<template #quantity-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) }}
{{ ticketLabel(row.original.ticketType) }}
</div>
</div>
</template>
@@ -298,7 +292,6 @@ import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking, T
import {
formatBookingCurrency,
getBookingModeLabel,
getBookingStatusLabel,
getTicketCatalogItem
} from '~~/shared/booking'
@@ -319,29 +312,22 @@ const loadingBookings = ref(false)
const savingCapacity = ref(false)
const searchQuery = ref('')
const settings = reactive<BookingCapacitySettings>({
totalTables: null,
totalSeats: null,
updatedAt: null
})
const summary = reactive<BookingInventorySummary>({
totalTables: null,
totalCapacitySeats: null,
soldTables: 0,
pendingTables: 0,
totalSeats: null,
soldSeats: 0,
pendingSeats: 0,
soldCapacitySeats: 0,
pendingCapacitySeats: 0,
leftTables: null,
leftSeats: null,
leftCapacitySeats: null
leftSeats: null
})
const capacityForm = reactive({
totalTables: ''
totalSeats: ''
})
const columns = [
{ accessorKey: 'customerName', header: 'Guest' },
{ accessorKey: 'bookingMode', header: 'Booking' },
{ accessorKey: 'quantity', header: 'Booking' },
{ accessorKey: 'seatCount', header: 'Seats / Total' },
{ accessorKey: 'personInChargeName', header: 'PIC' },
{ id: 'status', header: 'Status' },
@@ -350,27 +336,18 @@ const columns = [
]
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(() => {
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: 'Pending Seats',
value: String(summary.pendingSeats)
},
{
label: 'Seats Left',
@@ -434,27 +411,20 @@ function normalizeCapacityValue(value: string | number | null | undefined) {
}
function syncCapacityForm(nextSettings: BookingCapacitySettings) {
capacityForm.totalTables = nextSettings.totalTables === null ? '' : String(nextSettings.totalTables)
capacityForm.totalSeats = nextSettings.totalSeats === null ? '' : String(nextSettings.totalSeats)
}
function applySettings(nextSettings: BookingCapacitySettings) {
settings.totalTables = nextSettings.totalTables
settings.totalSeats = nextSettings.totalSeats
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.totalSeats = nextSummary.totalSeats
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() {
@@ -499,7 +469,7 @@ async function saveCapacity(event: Event) {
const response = await apiClient<{ settings: BookingCapacitySettings }>('/api/bookings/capacity', {
method: 'PATCH',
body: {
totalTables: normalizeCapacityValue(capacityForm.totalTables)
totalSeats: normalizeCapacityValue(capacityForm.totalSeats)
}
})

View File

@@ -3,7 +3,6 @@ import type { PublicBooking } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingModeLabel,
getBookingStatusLabel,
getTicketCatalogItem
} from '~~/shared/booking'
@@ -54,18 +53,10 @@ const detailRows = computed(() => {
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)

View File

@@ -67,10 +67,11 @@ const selectedTicket = computed(() => getTicketCatalogItem(form.ticketType) ?? B
const submittingBooking = ref(false)
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))
@@ -88,7 +89,7 @@ function validateBooking(state: typeof form): FormError[] {
}
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
@@ -194,6 +195,9 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
<UFormField :label="quantityLabel" name="quantity">
<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 label="Ticket Category" name="ticketType">
@@ -221,7 +225,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
/>
</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" />
</UForm>
</UCard>

View File

@@ -427,7 +427,7 @@ async function openBatchShare() {
</div>
<UButton
label="Share Seat"
label="Share Seats"
icon="i-lucide-share-2"
class="w-full justify-center sm:w-auto"
:disabled="!availableSeats.length || shareSeatsLoading || Boolean(seatActionId)"

View File

@@ -7,7 +7,6 @@ import {
DINNER_EVENT_TITLE,
DINNER_EVENT_VENUE,
formatBookingCurrency,
getBookingModeLabel,
getSeatLabel,
getTicketCatalogItem
} from '~~/shared/booking'
@@ -106,10 +105,10 @@ const totalFormatted = computed(() => formatBookingCurrency(receipt.value.bookin
</div>
<div class="rounded-2xl border border-default bg-elevated p-4">
<p class="text-xs uppercase tracking-wide text-muted">
Booking Mode
Total Seats
</p>
<p class="mt-2 font-semibold text-highlighted">
{{ getBookingModeLabel(receipt.booking.bookingMode) }}
{{ receipt.booking.seatCount }} seats
</p>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4">