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
515 lines
16 KiB
Vue
515 lines
16 KiB
Vue
<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>
|