Files
dticket.tootaio.com/app/pages/bookings/index.vue
xiaomai 3f7025c8e4 feat(booking): move event and ticket configuration to database
Replace hardcoded event details and ticket types with dynamic DB records
Add booking-config API endpoint to serve active event settings
2026-05-04 10:09:08 +08:00

497 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="totalSeats" label="Total Seats" class="flex-1">
<UInput
v-model="capacityForm.totalSeats"
type="number"
inputmode="numeric"
min="0"
size="md"
class="w-full"
placeholder="Leave blank for no seat 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 bookings
</p>
<p class="mt-1 text-xl font-semibold text-highlighted">
{{ pendingCount }}
</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-3">
<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>
</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 Seats
</p>
<p class="text-2xl font-semibold leading-none text-highlighted">
{{ formatInventoryNumber(summary.totalSeats) }}
</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 #quantity-cell="{ row }">
<div class="space-y-0.5 py-0.5">
<div class="text-sm font-medium text-default">
{{ ticketLabel(row.original) }}
</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, row.original.statusLabel)"
: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"
/>
<UButton
:to="receiptPath(row.original)"
label="Receipt"
color="neutral"
variant="outline"
icon="i-lucide-receipt"
size="sm"
/>
</div>
</template>
</UTable>
</div>
</UCard>
</div>
</UContainer>
</template>
<script lang="ts" setup>
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingStatusLabel
} 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>({
totalSeats: null,
updatedAt: null
})
const summary = reactive<BookingInventorySummary>({
totalSeats: null,
soldSeats: 0,
pendingSeats: 0,
leftSeats: null
})
const capacityForm = reactive({
totalSeats: ''
})
const columns = [
{ accessorKey: 'customerName', header: 'Guest' },
{ accessorKey: 'quantity', 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 'Every booking is converted into seats immediately, so sold and remaining capacity are tracked only in seats.'
})
const inventoryCards = computed(() => {
return [
{
label: 'Seats Sold',
value: String(summary.soldSeats)
},
{
label: 'Pending Seats',
value: String(summary.pendingSeats)
},
{
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.ticketLabel,
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(booking: PublicBooking) {
return booking.ticketLabel || booking.ticketType.toUpperCase()
}
function confirmationPath(booking: PublicBooking) {
return `/confirmation/${booking.confirmationToken}`
}
function receiptPath(booking: PublicBooking) {
return `/receipt/${booking.receiptToken}`
}
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.totalSeats = nextSettings.totalSeats === null ? '' : String(nextSettings.totalSeats)
}
function applySettings(nextSettings: BookingCapacitySettings) {
settings.totalSeats = nextSettings.totalSeats
settings.updatedAt = nextSettings.updatedAt
syncCapacityForm(nextSettings)
}
function applySummary(nextSummary: BookingInventorySummary) {
summary.totalSeats = nextSummary.totalSeats
summary.soldSeats = nextSummary.soldSeats
summary.pendingSeats = nextSummary.pendingSeats
summary.leftSeats = nextSummary.leftSeats
}
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: {
totalSeats: normalizeCapacityValue(capacityForm.totalSeats)
}
})
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>