Compare commits
4 Commits
06165f80db
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 13e85cfcd0 | |||
| 4e40bfd804 | |||
| 30753fdc61 | |||
| 3f7025c8e4 |
@@ -173,7 +173,7 @@
|
||||
v-model="searchQuery"
|
||||
size="md"
|
||||
class="w-full sm:w-72"
|
||||
placeholder="Search guest, phone, PIC, or ticket"
|
||||
placeholder="Search guest, phone, PIC, ticket, or remark"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
@@ -197,7 +197,7 @@
|
||||
:empty="searchQuery.trim() ? 'No matching bookings found.' : 'No bookings available yet.'"
|
||||
sticky="header"
|
||||
caption="Bookings"
|
||||
class="min-w-[980px]"
|
||||
class="min-w-[1120px]"
|
||||
>
|
||||
<template #customerName-cell="{ row }">
|
||||
<div class="min-w-0 space-y-0.5 py-0.5">
|
||||
@@ -213,7 +213,7 @@
|
||||
<template #quantity-cell="{ row }">
|
||||
<div class="space-y-0.5 py-0.5">
|
||||
<div class="text-sm font-medium text-default">
|
||||
{{ ticketLabel(row.original.ticketType) }}
|
||||
{{ ticketLabel(row.original) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -240,10 +240,34 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #remark-cell="{ row }">
|
||||
<div class="max-w-64 space-y-1 py-0.5">
|
||||
<p
|
||||
v-if="row.original.remark"
|
||||
class="whitespace-pre-wrap break-words text-sm leading-snug text-default"
|
||||
>
|
||||
{{ remarkPreview(row.original.remark) }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted">
|
||||
No remark
|
||||
</p>
|
||||
|
||||
<UButton
|
||||
:label="row.original.remark ? 'Edit remark' : 'Add remark'"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-message-square-text"
|
||||
size="xs"
|
||||
class="-ms-2"
|
||||
@click="openRemarkEditor(row.original)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #status-cell="{ row }">
|
||||
<div class="space-y-1 py-0.5">
|
||||
<UBadge
|
||||
:label="getBookingStatusLabel(row.original.status)"
|
||||
:label="getBookingStatusLabel(row.original.status, row.original.statusLabel)"
|
||||
:color="row.original.status === 'confirmed' ? 'success' : 'warning'"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
@@ -278,22 +302,85 @@
|
||||
icon="i-lucide-receipt"
|
||||
size="sm"
|
||||
/>
|
||||
<UButton
|
||||
v-if="row.original.status === 'confirmed'"
|
||||
label="Unconfirm"
|
||||
color="error"
|
||||
variant="outline"
|
||||
icon="i-lucide-x-circle"
|
||||
size="sm"
|
||||
:loading="cancellingBookingId === row.original.id"
|
||||
:disabled="cancellingBookingId !== null && cancellingBookingId !== row.original.id"
|
||||
@click="cancelBookingConfirmation(row.original)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UModal
|
||||
v-model:open="remarkModalOpen"
|
||||
title="Booking Remark"
|
||||
description="Management-only note for this booking."
|
||||
>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div v-if="editingBooking" class="rounded-lg border border-default bg-muted/20 px-3 py-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ editingBooking.customerName }}
|
||||
</p>
|
||||
<p class="text-xs text-muted">
|
||||
{{ ticketLabel(editingBooking) }} - {{ editingBooking.seatCount }} seats
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UFormField name="remark" label="Remark">
|
||||
<UTextarea
|
||||
v-model="remarkForm.remark"
|
||||
:rows="5"
|
||||
:maxlength="remarkLimit"
|
||||
autoresize
|
||||
class="w-full"
|
||||
placeholder="Internal handling note"
|
||||
/>
|
||||
<template #help>
|
||||
{{ remarkForm.remark.length }}/{{ remarkLimit }}
|
||||
</template>
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
label="Cancel"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="justify-center"
|
||||
:disabled="savingRemark"
|
||||
@click="closeRemarkEditor"
|
||||
/>
|
||||
<UButton
|
||||
label="Save Remark"
|
||||
icon="i-lucide-save"
|
||||
class="justify-center"
|
||||
:loading="savingRemark"
|
||||
@click="saveRemark"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</UContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { BookingCapacitySettings, BookingInventorySummary, PublicBooking, TicketType } from '~~/shared/booking'
|
||||
import type { BookingCapacitySettings, BookingInventorySummary, CancelBookingConfirmationResponse, PublicBooking } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
formatBookingCurrency,
|
||||
getBookingStatusLabel,
|
||||
getTicketCatalogItem
|
||||
getBookingStatusLabel
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors'
|
||||
@@ -310,6 +397,10 @@ const auth = useAuth()
|
||||
const bookings = ref<PublicBooking[]>([])
|
||||
const loadingBookings = ref(false)
|
||||
const savingCapacity = ref(false)
|
||||
const savingRemark = ref(false)
|
||||
const cancellingBookingId = ref<string | null>(null)
|
||||
const remarkModalOpen = ref(false)
|
||||
const editingBooking = ref<PublicBooking | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const settings = reactive<BookingCapacitySettings>({
|
||||
totalSeats: null,
|
||||
@@ -324,12 +415,17 @@ const summary = reactive<BookingInventorySummary>({
|
||||
const capacityForm = reactive({
|
||||
totalSeats: ''
|
||||
})
|
||||
const remarkForm = reactive({
|
||||
remark: ''
|
||||
})
|
||||
const remarkLimit = 1000
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: 'customerName', header: 'Guest' },
|
||||
{ accessorKey: 'quantity', header: 'Booking' },
|
||||
{ accessorKey: 'seatCount', header: 'Seats / Total' },
|
||||
{ accessorKey: 'personInChargeName', header: 'PIC' },
|
||||
{ accessorKey: 'remark', header: 'Remark' },
|
||||
{ id: 'status', header: 'Status' },
|
||||
{ accessorKey: 'createdAt', header: 'Submitted' },
|
||||
{ id: 'actions', header: 'Actions' }
|
||||
@@ -370,6 +466,8 @@ const filteredBookings = computed(() => {
|
||||
booking.personInChargeName,
|
||||
booking.personInChargePhoneNumber,
|
||||
booking.ticketType,
|
||||
booking.ticketLabel,
|
||||
booking.remark || '',
|
||||
booking.status
|
||||
].some((value) => value.toLowerCase().includes(keyword))
|
||||
})
|
||||
@@ -385,8 +483,13 @@ const confirmedCount = computed(() => {
|
||||
|
||||
await refreshBookings()
|
||||
|
||||
function ticketLabel(ticketType: TicketType) {
|
||||
return getTicketCatalogItem(ticketType)?.label || ticketType.toUpperCase()
|
||||
function ticketLabel(booking: PublicBooking) {
|
||||
return booking.ticketLabel || booking.ticketType.toUpperCase()
|
||||
}
|
||||
|
||||
function remarkPreview(remark: string) {
|
||||
const normalized = remark.trim()
|
||||
return normalized.length > 120 ? `${normalized.slice(0, 120)}...` : normalized
|
||||
}
|
||||
|
||||
function confirmationPath(booking: PublicBooking) {
|
||||
@@ -427,6 +530,38 @@ function applySummary(nextSummary: BookingInventorySummary) {
|
||||
summary.leftSeats = nextSummary.leftSeats
|
||||
}
|
||||
|
||||
function replaceBooking(updatedBooking: PublicBooking) {
|
||||
const index = bookings.value.findIndex((booking) => booking.id === updatedBooking.id)
|
||||
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
bookings.value.splice(index, 1, updatedBooking)
|
||||
}
|
||||
|
||||
function applyCancelledConfirmationToSummary(booking: PublicBooking) {
|
||||
summary.soldSeats = Math.max(summary.soldSeats - booking.seatCount, 0)
|
||||
summary.pendingSeats += booking.seatCount
|
||||
summary.leftSeats = summary.totalSeats === null ? null : Math.max(summary.totalSeats - summary.soldSeats, 0)
|
||||
}
|
||||
|
||||
function openRemarkEditor(booking: PublicBooking) {
|
||||
editingBooking.value = booking
|
||||
remarkForm.remark = booking.remark || ''
|
||||
remarkModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeRemarkEditor() {
|
||||
if (savingRemark.value) {
|
||||
return
|
||||
}
|
||||
|
||||
remarkModalOpen.value = false
|
||||
editingBooking.value = null
|
||||
remarkForm.remark = ''
|
||||
}
|
||||
|
||||
async function refreshBookings() {
|
||||
if (loadingBookings.value) {
|
||||
return
|
||||
@@ -493,4 +628,86 @@ async function saveCapacity(event: Event) {
|
||||
savingCapacity.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRemark() {
|
||||
const booking = editingBooking.value
|
||||
|
||||
if (!booking || savingRemark.value) {
|
||||
return
|
||||
}
|
||||
|
||||
savingRemark.value = true
|
||||
|
||||
try {
|
||||
const response = await apiClient<{ booking: PublicBooking }>(`/api/bookings/${booking.id}/remark`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
remark: remarkForm.remark
|
||||
}
|
||||
})
|
||||
|
||||
replaceBooking(response.booking)
|
||||
remarkModalOpen.value = false
|
||||
editingBooking.value = null
|
||||
remarkForm.remark = ''
|
||||
|
||||
toast.add({
|
||||
title: 'Remark saved',
|
||||
description: 'The booking remark has been updated.',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-check-circle-2'
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Remark update failed',
|
||||
description: getErrorMessage(error, 'Unable to save the booking remark.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
savingRemark.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelBookingConfirmation(booking: PublicBooking) {
|
||||
if (booking.status !== 'confirmed' || cancellingBookingId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (import.meta.client && !window.confirm(`Cancel confirmation for ${booking.customerName}? The booking will return to pending and the seats will be released.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
cancellingBookingId.value = booking.id
|
||||
|
||||
try {
|
||||
const response = await apiClient<CancelBookingConfirmationResponse>(`/api/public/bookings/${booking.confirmationToken}/cancel`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
replaceBooking(response.booking)
|
||||
|
||||
if (!response.alreadyPending) {
|
||||
applyCancelledConfirmationToSummary(booking)
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: response.alreadyPending ? 'Booking already pending' : 'Confirmation cancelled',
|
||||
description: response.alreadyPending
|
||||
? `${booking.customerName} was already pending confirmation.`
|
||||
: `${booking.customerName} has been returned to pending status.`,
|
||||
color: response.alreadyPending ? 'warning' : 'success',
|
||||
icon: 'i-lucide-x-circle'
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: 'Cancellation failed',
|
||||
description: getErrorMessage(error, 'Unable to cancel the booking confirmation.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
cancellingBookingId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
|
||||
import type { CancelBookingConfirmationResponse, ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
formatBookingCurrency,
|
||||
getBookingStatusLabel,
|
||||
getTicketCatalogItem
|
||||
getBookingStatusLabel
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors'
|
||||
@@ -16,6 +15,7 @@ const apiClient = useApiClient()
|
||||
|
||||
const token = String(route.params.token || '')
|
||||
const confirming = ref(false)
|
||||
const cancelling = ref(false)
|
||||
|
||||
let initialBooking: PublicBooking
|
||||
|
||||
@@ -32,7 +32,7 @@ try {
|
||||
const booking = ref(initialBooking)
|
||||
|
||||
const statusColor = computed(() => booking.value.status === 'confirmed' ? 'success' : 'warning')
|
||||
const ticketLabel = computed(() => getTicketCatalogItem(booking.value.ticketType)?.label || booking.value.ticketType.toUpperCase())
|
||||
const ticketLabel = computed(() => booking.value.ticketLabel || booking.value.ticketType.toUpperCase())
|
||||
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice))
|
||||
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
|
||||
const detailRows = computed(() => {
|
||||
@@ -115,6 +115,47 @@ async function confirmBooking() {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelBookingConfirmation() {
|
||||
if (booking.value.status !== 'confirmed') {
|
||||
return
|
||||
}
|
||||
|
||||
if (import.meta.client && !window.confirm('Cancel this confirmation? The booking will return to pending and the seats will be released.')) {
|
||||
return
|
||||
}
|
||||
|
||||
cancelling.value = true
|
||||
|
||||
try {
|
||||
const response = await apiClient<CancelBookingConfirmationResponse>(
|
||||
`/api/public/bookings/${token}/cancel`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
|
||||
booking.value = response.booking
|
||||
|
||||
toast.add({
|
||||
title: response.alreadyPending ? 'Booking already pending' : 'Confirmation cancelled',
|
||||
description: response.alreadyPending
|
||||
? 'This booking was already pending confirmation.'
|
||||
: 'The booking has been returned to pending status.',
|
||||
color: response.alreadyPending ? 'warning' : 'success',
|
||||
icon: 'i-lucide-x-circle'
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
title: 'Cancellation failed',
|
||||
description: getErrorMessage(error, 'Please try again in a moment.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -139,7 +180,7 @@ async function confirmBooking() {
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-muted">
|
||||
Booking status
|
||||
</p>
|
||||
<UBadge :label="getBookingStatusLabel(booking.status)" :color="statusColor" variant="soft" />
|
||||
<UBadge :label="getBookingStatusLabel(booking.status, booking.statusLabel)" :color="statusColor" variant="soft" />
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted">
|
||||
@@ -199,13 +240,24 @@ async function confirmBooking() {
|
||||
/>
|
||||
|
||||
<UButton
|
||||
v-if="booking.status === 'pending'"
|
||||
label="Confirm This Booking"
|
||||
icon="i-lucide-check-check"
|
||||
class="justify-center"
|
||||
:disabled="booking.status === 'confirmed'"
|
||||
:loading="confirming"
|
||||
@click="confirmBooking"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
v-else
|
||||
label="Cancel Confirmation"
|
||||
color="error"
|
||||
variant="outline"
|
||||
icon="i-lucide-x-circle"
|
||||
class="justify-center"
|
||||
:loading="cancelling"
|
||||
@click="cancelBookingConfirmation"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
@@ -7,17 +7,10 @@ import {
|
||||
normalizePhoneNumber,
|
||||
type PublicContact
|
||||
} from '~~/shared/auth'
|
||||
import type { CreateBookingResponse } from '~~/shared/booking'
|
||||
import type { BookingModeOption, CreateBookingResponse, PublicBookingConfig, TicketCatalogItem } from '~~/shared/booking'
|
||||
import {
|
||||
BOOKING_MODE_OPTIONS,
|
||||
BOOKING_TICKET_CATALOG,
|
||||
DINNER_EVENT_DATE_LABEL,
|
||||
DINNER_EVENT_TIME_LABEL,
|
||||
DINNER_EVENT_TITLE,
|
||||
DINNER_EVENT_VENUE,
|
||||
formatBookingCurrency,
|
||||
getSeatCount,
|
||||
getTicketCatalogItem,
|
||||
type BookingMode,
|
||||
type TicketType
|
||||
} from '~~/shared/booking'
|
||||
@@ -28,25 +21,44 @@ import { getErrorMessage } from '../utils/errors'
|
||||
const toast = useToast()
|
||||
const apiClient = useApiClient()
|
||||
|
||||
const eventDetails = [
|
||||
const [bookingConfig, contactsResponse] = await Promise.all([
|
||||
apiClient<PublicBookingConfig>('/api/public/booking-config'),
|
||||
apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
|
||||
])
|
||||
|
||||
const eventDetails = computed(() => [
|
||||
{
|
||||
label: 'Date',
|
||||
value: DINNER_EVENT_DATE_LABEL,
|
||||
value: bookingConfig.event.dateLabel,
|
||||
icon: 'lucide:calendar-days'
|
||||
},
|
||||
{
|
||||
label: 'Time',
|
||||
value: DINNER_EVENT_TIME_LABEL,
|
||||
value: bookingConfig.event.timeLabel,
|
||||
icon: 'lucide:clock-6'
|
||||
},
|
||||
{
|
||||
label: 'Venue',
|
||||
value: DINNER_EVENT_VENUE,
|
||||
value: bookingConfig.event.venue,
|
||||
icon: 'lucide:map-pin'
|
||||
}
|
||||
] as const
|
||||
])
|
||||
|
||||
const bookingModeOptions = computed(() => {
|
||||
return bookingConfig.bookingModes.map((mode) => ({
|
||||
value: mode.value,
|
||||
label: mode.label
|
||||
}))
|
||||
})
|
||||
|
||||
const ticketCatalogOptions = computed(() => {
|
||||
return bookingConfig.ticketCatalog.map((ticket) => ({
|
||||
value: ticket.value,
|
||||
label: ticket.label,
|
||||
description: ticket.description
|
||||
}))
|
||||
})
|
||||
|
||||
const contactsResponse = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
|
||||
const personInCharge = computed(() => {
|
||||
return contactsResponse.contacts.map((contact) => ({
|
||||
label: contact.fullName,
|
||||
@@ -57,9 +69,9 @@ const personInCharge = computed(() => {
|
||||
const form = reactive({
|
||||
name: '',
|
||||
phone: DEFAULT_PHONE_COUNTRY_CODE,
|
||||
bookingMode: 'table' as BookingMode,
|
||||
bookingMode: (bookingConfig.bookingModes[0]?.value ?? '') as BookingMode,
|
||||
quantity: 1,
|
||||
ticketType: 'vip' as TicketType
|
||||
ticketType: (bookingConfig.ticketCatalog[0]?.value ?? '') as TicketType
|
||||
})
|
||||
|
||||
const selectedPersonInCharge = ref(contactsResponse.contacts[0]?.id ?? '')
|
||||
@@ -68,15 +80,22 @@ const selectedPersonInChargeRecord = computed(() => {
|
||||
return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null
|
||||
})
|
||||
|
||||
const selectedTicket = computed(() => getTicketCatalogItem(form.ticketType) ?? BOOKING_TICKET_CATALOG[0])
|
||||
const selectedBookingMode = computed<BookingModeOption | null>(() => {
|
||||
return bookingConfig.bookingModes.find((mode) => mode.value === form.bookingMode) ?? bookingConfig.bookingModes[0] ?? null
|
||||
})
|
||||
|
||||
const selectedTicket = computed<TicketCatalogItem | null>(() => {
|
||||
return bookingConfig.ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? bookingConfig.ticketCatalog[0] ?? null
|
||||
})
|
||||
|
||||
const submittingBooking = ref(false)
|
||||
|
||||
const quantityLabel = computed(() => {
|
||||
return form.bookingMode === 'table' ? 'Number of Tables' : 'Number of Seats'
|
||||
return selectedBookingMode.value?.quantityLabel || 'Quantity'
|
||||
})
|
||||
|
||||
const seatCount = computed(() => getSeatCount(form.bookingMode, form.quantity))
|
||||
const totalPrice = computed(() => seatCount.value * selectedTicket.value.price)
|
||||
const seatCount = computed(() => getSeatCount(selectedBookingMode.value, form.quantity))
|
||||
const totalPrice = computed(() => seatCount.value * (selectedTicket.value?.price ?? 0))
|
||||
|
||||
const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value))
|
||||
|
||||
@@ -97,6 +116,14 @@ function validateBooking(state: typeof form): FormError[] {
|
||||
errors.push({ name: 'quantity', message: `${quantityLabel.value} must be at least 1.` })
|
||||
}
|
||||
|
||||
if (!selectedBookingMode.value) {
|
||||
errors.push({ name: 'bookingMode', message: 'Please select a booking mode.' })
|
||||
}
|
||||
|
||||
if (!selectedTicket.value) {
|
||||
errors.push({ name: 'ticketType', message: 'Please select a ticket category.' })
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
@@ -161,7 +188,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-extrabold tracking-tight text-highlighted sm:text-4xl">
|
||||
{{ DINNER_EVENT_TITLE }}
|
||||
{{ bookingConfig.event.title }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +219,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
|
||||
<UFormField label="Booking Mode" name="bookingMode">
|
||||
<URadioGroup v-model="form.bookingMode" orientation="horizontal" variant="card" indicator="hidden"
|
||||
:items="BOOKING_MODE_OPTIONS" :ui="{
|
||||
:items="bookingModeOptions" :ui="{
|
||||
fieldset: 'grid grid-cols-2 gap-3',
|
||||
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
|
||||
}" />
|
||||
@@ -207,7 +234,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
|
||||
<UFormField label="Ticket Category" name="ticketType">
|
||||
<URadioGroup v-model="form.ticketType" orientation="horizontal" variant="card" indicator="hidden"
|
||||
:items="BOOKING_TICKET_CATALOG" :ui="{
|
||||
:items="ticketCatalogOptions" :ui="{
|
||||
fieldset: 'grid grid-cols-2 gap-3',
|
||||
item: 'rounded-xl border border-default bg-default p-3 data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
|
||||
}" />
|
||||
@@ -231,7 +258,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
</UFormField>
|
||||
|
||||
<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 || !selectedBookingMode || !selectedTicket" :loading="submittingBooking" />
|
||||
</UForm>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
@@ -49,70 +49,94 @@
|
||||
</template>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<UTable
|
||||
:data="filteredUsers"
|
||||
:columns="columns"
|
||||
:loading="loadingUsers"
|
||||
:empty="searchQuery.trim() ? 'No matching users found.' : 'No users available.'"
|
||||
sticky="header"
|
||||
caption="Users"
|
||||
class="min-w-[820px]"
|
||||
<div class="min-w-[900px]" aria-label="Users">
|
||||
<div class="grid grid-cols-[56px_minmax(190px,1.4fr)_minmax(150px,1fr)_120px_minmax(190px,1.2fr)_150px_minmax(230px,auto)] items-center gap-3 border-b border-default bg-muted px-4 py-2 text-xs font-semibold uppercase text-muted">
|
||||
<div>Order</div>
|
||||
<div>Display Name</div>
|
||||
<div>PIC Phone</div>
|
||||
<div>Role</div>
|
||||
<div>Status</div>
|
||||
<div>Last Login</div>
|
||||
<div class="text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
<TransitionGroup tag="div" name="user-list" class="divide-y divide-default">
|
||||
<div
|
||||
v-for="user in filteredUsers"
|
||||
:key="user.id"
|
||||
:draggable="canReorderUsers"
|
||||
class="grid grid-cols-[56px_minmax(190px,1.4fr)_minmax(150px,1fr)_120px_minmax(190px,1.2fr)_150px_minmax(230px,auto)] items-center gap-3 bg-default px-4 py-3 transition-[background,box-shadow,transform,opacity] duration-200"
|
||||
:class="{
|
||||
'cursor-grab hover:bg-muted/60': canReorderUsers,
|
||||
'cursor-not-allowed opacity-70': !canReorderUsers,
|
||||
'scale-[0.99] opacity-60 shadow-lg': draggedUserId === user.id,
|
||||
'bg-muted': dragOverUserId === user.id
|
||||
}"
|
||||
@dragstart="startUserDrag($event, user)"
|
||||
@dragover.prevent="overUserDrag($event, user)"
|
||||
@drop.prevent="dropUserDrag"
|
||||
@dragend="endUserDrag"
|
||||
>
|
||||
<template #fullName-cell="{ row }">
|
||||
<div class="flex items-center">
|
||||
<UTooltip :text="canReorderUsers ? 'Drag to reorder' : 'Clear search to reorder'">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-9 items-center justify-center rounded-md border border-default bg-default text-muted transition hover:text-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="canReorderUsers ? 'cursor-grab active:cursor-grabbing' : 'cursor-not-allowed opacity-50'"
|
||||
:aria-label="`Drag ${user.fullName} to reorder PIC list`"
|
||||
:disabled="!canReorderUsers"
|
||||
>
|
||||
<UIcon name="i-lucide-grip-vertical" class="size-4" />
|
||||
</button>
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 space-y-0.5 py-1">
|
||||
<div class="font-semibold leading-tight text-highlighted">
|
||||
{{ row.original.fullName }}
|
||||
{{ user.fullName }}
|
||||
</div>
|
||||
<div class="text-xs text-muted">
|
||||
@{{ row.original.username }}
|
||||
@{{ user.username }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #phoneNumber-cell="{ row }">
|
||||
<span class="text-sm" :class="row.original.phoneNumber ? 'text-default' : 'text-muted'">
|
||||
{{ row.original.phoneNumber || 'Not set' }}
|
||||
<span class="text-sm" :class="user.phoneNumber ? 'text-default' : 'text-muted'">
|
||||
{{ user.phoneNumber || 'Not set' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #role-cell="{ row }">
|
||||
<div>
|
||||
<UBadge
|
||||
:label="row.original.role === 'super_admin' ? 'Super Admin' : 'Staff'"
|
||||
:color="row.original.role === 'super_admin' ? 'primary' : 'neutral'"
|
||||
:label="user.role === 'super_admin' ? 'Super Admin' : 'Staff'"
|
||||
:color="user.role === 'super_admin' ? 'primary' : 'neutral'"
|
||||
variant="soft"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #status-cell="{ row }">
|
||||
<div class="space-y-1.5 py-1">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<UBadge
|
||||
:label="row.original.mustChangePassword ? 'Password reset' : 'Password ready'"
|
||||
:color="row.original.mustChangePassword ? 'warning' : 'success'"
|
||||
:label="user.mustChangePassword ? 'Password reset' : 'Password ready'"
|
||||
:color="user.mustChangePassword ? 'warning' : 'success'"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
/>
|
||||
<UBadge
|
||||
:label="row.original.passkeyCount > 0 ? 'Passkey ready' : 'No passkey'"
|
||||
:color="row.original.passkeyCount > 0 ? 'success' : 'neutral'"
|
||||
:label="user.passkeyCount > 0 ? 'Passkey ready' : 'No passkey'"
|
||||
:color="user.passkeyCount > 0 ? 'success' : 'neutral'"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted">
|
||||
{{ row.original.passkeyCount }} passkey{{ row.original.passkeyCount === 1 ? '' : 's' }}
|
||||
{{ user.passkeyCount }} passkey{{ user.passkeyCount === 1 ? '' : 's' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #lastLoginAt-cell="{ row }">
|
||||
<span class="text-xs text-muted sm:text-sm">
|
||||
{{ formatDateTime(row.original.lastLoginAt, 'Never') }}
|
||||
{{ formatDateTime(user.lastLoginAt, 'Never') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #actions-cell="{ row }">
|
||||
<div class="flex flex-wrap justify-end gap-1.5 py-1">
|
||||
<UButton
|
||||
label="Edit"
|
||||
@@ -120,7 +144,7 @@
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil-line"
|
||||
size="sm"
|
||||
@click="openEditModal(row.original)"
|
||||
@click="openEditModal(user)"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
@@ -129,12 +153,17 @@
|
||||
variant="outline"
|
||||
icon="i-lucide-key-round"
|
||||
size="sm"
|
||||
:loading="resettingUserId === row.original.id"
|
||||
@click="resetPassword(row.original)"
|
||||
:loading="resettingUserId === user.id"
|
||||
@click="resetPassword(user)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<div v-if="!filteredUsers.length" class="px-4 py-10 text-center text-sm text-muted">
|
||||
{{ searchQuery.trim() ? 'No matching users found.' : 'No users available.' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
@@ -233,12 +262,17 @@ const auth = useAuth()
|
||||
const users = ref<ManagedUser[]>([])
|
||||
const loadingUsers = ref(false)
|
||||
const savingUser = ref(false)
|
||||
const savingPicOrder = ref(false)
|
||||
const resettingUserId = ref<string | null>(null)
|
||||
const issuedPasswordMessage = ref('')
|
||||
const searchQuery = ref('')
|
||||
const editorOpen = ref(false)
|
||||
const editorMode = ref<'create' | 'edit'>('create')
|
||||
const editingUserId = ref<string | null>(null)
|
||||
const draggedUserId = ref<string | null>(null)
|
||||
const dragOverUserId = ref<string | null>(null)
|
||||
const dragSaveStarted = ref(false)
|
||||
let usersBeforeDrag: ManagedUser[] = []
|
||||
|
||||
const userForm = reactive({
|
||||
fullName: '',
|
||||
@@ -252,19 +286,13 @@ const roleOptions = [
|
||||
{ label: 'Super Admin', value: 'super_admin' }
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: 'fullName', header: 'Display Name' },
|
||||
{ accessorKey: 'phoneNumber', header: 'PIC Phone' },
|
||||
{ accessorKey: 'role', header: 'Role' },
|
||||
{ id: 'status', header: 'Status' },
|
||||
{ accessorKey: 'lastLoginAt', header: 'Last Login' },
|
||||
{ id: 'actions', header: 'Actions' }
|
||||
]
|
||||
|
||||
const isEditMode = computed(() => editorMode.value === 'edit')
|
||||
const isEditingCurrentUser = computed(() => {
|
||||
return isEditMode.value && editingUserId.value === auth.user.value?.id
|
||||
})
|
||||
const canReorderUsers = computed(() => {
|
||||
return !searchQuery.value.trim() && !loadingUsers.value && !savingPicOrder.value
|
||||
})
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
const keyword = searchQuery.value.trim().toLowerCase()
|
||||
@@ -358,6 +386,131 @@ async function refreshUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
function moveUserNearTarget(sourceUserId: string, targetUserId: string, insertAfterTarget: boolean) {
|
||||
if (sourceUserId === targetUserId) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceIndex = users.value.findIndex((user) => user.id === sourceUserId)
|
||||
const currentUserIds = users.value.map((user) => user.id)
|
||||
|
||||
if (sourceIndex === -1 || !currentUserIds.includes(targetUserId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const reorderedUsers = [...users.value]
|
||||
const [movedUser] = reorderedUsers.splice(sourceIndex, 1)
|
||||
const targetIndex = reorderedUsers.findIndex((user) => user.id === targetUserId)
|
||||
|
||||
if (targetIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
reorderedUsers.splice(insertAfterTarget ? targetIndex + 1 : targetIndex, 0, movedUser)
|
||||
|
||||
const nextUserIds = reorderedUsers.map((user) => user.id)
|
||||
|
||||
if (nextUserIds.join('|') === currentUserIds.join('|')) {
|
||||
return
|
||||
}
|
||||
|
||||
users.value = reorderedUsers
|
||||
}
|
||||
|
||||
function resetDragState() {
|
||||
draggedUserId.value = null
|
||||
dragOverUserId.value = null
|
||||
dragSaveStarted.value = false
|
||||
usersBeforeDrag = []
|
||||
}
|
||||
|
||||
function startUserDrag(event: DragEvent, user: ManagedUser) {
|
||||
if (!canReorderUsers.value) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
draggedUserId.value = user.id
|
||||
dragOverUserId.value = user.id
|
||||
dragSaveStarted.value = false
|
||||
usersBeforeDrag = [...users.value]
|
||||
event.dataTransfer?.setData('text/plain', user.id)
|
||||
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function overUserDrag(event: DragEvent, user: ManagedUser) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
if (!canReorderUsers.value || !draggedUserId.value || draggedUserId.value === user.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.currentTarget
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect()
|
||||
const insertAfterTarget = event.clientY > rect.top + rect.height / 2
|
||||
|
||||
dragOverUserId.value = user.id
|
||||
moveUserNearTarget(draggedUserId.value, user.id, insertAfterTarget)
|
||||
}
|
||||
|
||||
async function dropUserDrag() {
|
||||
if (!draggedUserId.value || savingPicOrder.value) {
|
||||
return
|
||||
}
|
||||
|
||||
dragSaveStarted.value = true
|
||||
savingPicOrder.value = true
|
||||
|
||||
try {
|
||||
const response = await apiClient<{ users: ManagedUser[] }>('/api/admin/users/order', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
userIds: users.value.map((user) => user.id)
|
||||
}
|
||||
})
|
||||
|
||||
users.value = response.users
|
||||
toast.add({
|
||||
title: 'PIC order saved',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-check-circle-2'
|
||||
})
|
||||
} catch (error: any) {
|
||||
users.value = usersBeforeDrag
|
||||
toast.add({
|
||||
title: 'PIC order could not be saved',
|
||||
description: getErrorMessage(error, 'Please try again in a moment.'),
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-alert'
|
||||
})
|
||||
} finally {
|
||||
savingPicOrder.value = false
|
||||
resetDragState()
|
||||
}
|
||||
}
|
||||
|
||||
function endUserDrag() {
|
||||
if (dragSaveStarted.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (draggedUserId.value && usersBeforeDrag.length) {
|
||||
users.value = usersBeforeDrag
|
||||
}
|
||||
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
async function saveUser(event: FormSubmitEvent<typeof userForm>) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -466,3 +619,22 @@ async function resetPassword(user: ManagedUser) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-list-move,
|
||||
.user-list-enter-active,
|
||||
.user-list-leave-active {
|
||||
transition: transform 180ms ease, opacity 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
.user-list-enter-from,
|
||||
.user-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.user-list-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,14 +2,9 @@
|
||||
import type { PublicBookingReceipt, PublicBookingSeatWithUrl } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
DINNER_EVENT_DATE_LABEL,
|
||||
DINNER_EVENT_TIME_LABEL,
|
||||
DINNER_EVENT_TITLE,
|
||||
DINNER_EVENT_VENUE,
|
||||
formatBookingCurrency,
|
||||
getBookingStatusLabel,
|
||||
getSeatLabel,
|
||||
getTicketCatalogItem
|
||||
getSeatLabel
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors'
|
||||
@@ -51,7 +46,8 @@ try {
|
||||
|
||||
const receipt = ref(initialReceipt)
|
||||
|
||||
const ticketLabel = computed(() => getTicketCatalogItem(receipt.value.booking.ticketType)?.label || receipt.value.booking.ticketType.toUpperCase())
|
||||
const eventDetails = computed(() => receipt.value.booking.event)
|
||||
const ticketLabel = computed(() => receipt.value.booking.ticketLabel || receipt.value.booking.ticketType.toUpperCase())
|
||||
const statusColor = computed(() => receipt.value.booking.status === 'confirmed' ? 'success' : 'warning')
|
||||
const sharedSeats = computed(() => receipt.value.seats.filter((seat) => Boolean(seat.sharedAt)))
|
||||
const availableSeats = computed(() => receipt.value.seats.filter((seat) => !seat.sharedAt))
|
||||
@@ -66,7 +62,7 @@ const statusRows = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Status',
|
||||
value: getBookingStatusLabel(receipt.value.booking.status),
|
||||
value: getBookingStatusLabel(receipt.value.booking.status, receipt.value.booking.statusLabel),
|
||||
isBadge: true
|
||||
},
|
||||
{
|
||||
@@ -126,15 +122,15 @@ function buildSeatBundleText(
|
||||
: null
|
||||
|
||||
return [
|
||||
DINNER_EVENT_TITLE,
|
||||
eventDetails.value.title,
|
||||
`Guest: ${receipt.value.booking.customerName}`,
|
||||
recipientLabel,
|
||||
recipientPhoneLabel,
|
||||
`Seats: ${seats.map((seat) => getSeatLabel(seat.seatNumber)).join(', ')}`,
|
||||
`Category: ${ticketLabel.value}`,
|
||||
`Date: ${DINNER_EVENT_DATE_LABEL}`,
|
||||
`Time: ${DINNER_EVENT_TIME_LABEL}`,
|
||||
`Venue: ${DINNER_EVENT_VENUE}`,
|
||||
`Date: ${eventDetails.value.dateLabel}`,
|
||||
`Time: ${eventDetails.value.timeLabel}`,
|
||||
`Venue: ${eventDetails.value.venue}`,
|
||||
'',
|
||||
...seats.flatMap((seat) => [
|
||||
`${getSeatLabel(seat.seatNumber)}:`,
|
||||
@@ -222,7 +218,7 @@ async function shareSeats() {
|
||||
recipientPhone: shareForm.recipientPhone
|
||||
})
|
||||
const shared = await shareLink({
|
||||
title: `${DINNER_EVENT_TITLE} seats`,
|
||||
title: `${eventDetails.value.title} seats`,
|
||||
text: shareText,
|
||||
clipboardText: shareText,
|
||||
successTitle: 'Seats ready',
|
||||
@@ -289,7 +285,7 @@ async function shareSeat(seat: PublicBookingSeatWithUrl) {
|
||||
|
||||
try {
|
||||
const shared = await shareLink({
|
||||
title: `${DINNER_EVENT_TITLE} ${getSeatLabel(seat.seatNumber)}`,
|
||||
title: `${eventDetails.value.title} ${getSeatLabel(seat.seatNumber)}`,
|
||||
text: buildSeatBundleText([seat]),
|
||||
clipboardText: buildSeatBundleText([seat]),
|
||||
successTitle: 'Seat ready',
|
||||
@@ -365,7 +361,7 @@ async function openBatchShare() {
|
||||
<div class="space-y-1 text-center">
|
||||
<UBadge label="Ticket Receipt" color="primary" variant="soft" class="rounded-full" />
|
||||
<h1 class="text-2xl font-bold tracking-tight text-highlighted sm:text-3xl">
|
||||
{{ DINNER_EVENT_TITLE }}
|
||||
{{ eventDetails.title }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
import type { PublicSeatReceipt } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
DINNER_EVENT_DATE_LABEL,
|
||||
DINNER_EVENT_TIME_LABEL,
|
||||
DINNER_EVENT_TITLE,
|
||||
DINNER_EVENT_VENUE,
|
||||
formatBookingCurrency,
|
||||
getSeatLabel,
|
||||
getTicketCatalogItem
|
||||
getSeatLabel
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { formatDateTime } from '../../utils/formatters'
|
||||
@@ -31,7 +26,8 @@ try {
|
||||
|
||||
const receipt = ref(initialReceipt)
|
||||
|
||||
const ticketLabel = computed(() => getTicketCatalogItem(receipt.value.booking.ticketType)?.label || receipt.value.booking.ticketType.toUpperCase())
|
||||
const eventDetails = computed(() => receipt.value.booking.event)
|
||||
const ticketLabel = computed(() => receipt.value.booking.ticketLabel || receipt.value.booking.ticketType.toUpperCase())
|
||||
const totalFormatted = computed(() => formatBookingCurrency(receipt.value.booking.totalPrice))
|
||||
</script>
|
||||
|
||||
@@ -44,7 +40,7 @@ const totalFormatted = computed(() => formatBookingCurrency(receipt.value.bookin
|
||||
{{ getSeatLabel(receipt.seat.seatNumber) }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted">
|
||||
{{ DINNER_EVENT_TITLE }}
|
||||
{{ eventDetails.title }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -124,15 +120,15 @@ const totalFormatted = computed(() => formatBookingCurrency(receipt.value.bookin
|
||||
<div class="space-y-3 rounded-2xl border border-default bg-elevated p-4">
|
||||
<div class="flex items-center gap-3 text-sm text-default">
|
||||
<UIcon name="i-lucide-calendar-days" class="size-4 text-muted" />
|
||||
<span>{{ DINNER_EVENT_DATE_LABEL }}</span>
|
||||
<span>{{ eventDetails.dateLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm text-default">
|
||||
<UIcon name="i-lucide-clock-6" class="size-4 text-muted" />
|
||||
<span>{{ DINNER_EVENT_TIME_LABEL }}</span>
|
||||
<span>{{ eventDetails.timeLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm text-default">
|
||||
<UIcon name="i-lucide-map-pin" class="size-4 text-muted" />
|
||||
<span>{{ DINNER_EVENT_VENUE }}</span>
|
||||
<span>{{ eventDetails.venue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
24
server/api/admin/users/order.patch.ts
Normal file
24
server/api/admin/users/order.patch.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { requireRole } from '../../../utils/auth'
|
||||
import { assertBadRequest } from '../../../utils/http'
|
||||
import { listUsers, reorderUsers } from '../../../utils/user-repository'
|
||||
import { parseUserOrderInput } from '../../../utils/users'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireRole(event, 'super_admin')
|
||||
|
||||
const body = await readBody<{
|
||||
userIds?: unknown
|
||||
}>(event)
|
||||
const { userIds } = parseUserOrderInput(body)
|
||||
const users = await listUsers()
|
||||
const existingIds = new Set(users.map((user) => user.id))
|
||||
|
||||
assertBadRequest(userIds.length === users.length, 'User order must include every user')
|
||||
assertBadRequest(userIds.every((userId) => existingIds.has(userId)), 'User order contains an unknown user')
|
||||
|
||||
await reorderUsers(userIds)
|
||||
|
||||
return {
|
||||
users: await listUsers()
|
||||
}
|
||||
})
|
||||
29
server/api/bookings/[id]/remark.patch.ts
Normal file
29
server/api/bookings/[id]/remark.patch.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { PublicBooking } from '~~/shared/booking'
|
||||
|
||||
import { requireAuth } from '../../../utils/auth'
|
||||
import { updateBookingRemark } from '../../../utils/booking-repository'
|
||||
import { parseBookingRemarkInput } from '../../../utils/bookings'
|
||||
import { getRequiredRouteParam, httpError } from '../../../utils/http'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<{ booking: PublicBooking }> => {
|
||||
const auth = await requireAuth(event)
|
||||
const bookingId = getRequiredRouteParam(event, 'id', 'Booking ID')
|
||||
const body = await readBody<{
|
||||
remark?: string | null
|
||||
}>(event)
|
||||
|
||||
const input = parseBookingRemarkInput(body)
|
||||
const booking = await updateBookingRemark({
|
||||
bookingId,
|
||||
remark: input.remark,
|
||||
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
|
||||
})
|
||||
|
||||
if (!booking) {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
return {
|
||||
booking
|
||||
}
|
||||
})
|
||||
5
server/api/public/booking-config.get.ts
Normal file
5
server/api/public/booking-config.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { getPublicBookingConfig } from '../../utils/booking-repository'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return await getPublicBookingConfig()
|
||||
})
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { BookingMode, CreateBookingResponse, TicketType } from '~~/shared/booking'
|
||||
|
||||
import { getTicketCatalogItem, getSeatCount } from '~~/shared/booking'
|
||||
import { getSeatCount } from '~~/shared/booking'
|
||||
|
||||
import { buildAppUrl } from '../../utils/app-url'
|
||||
import { createBooking } from '../../utils/booking-repository'
|
||||
import {
|
||||
createBooking,
|
||||
getActiveBookingModeOptionByCode,
|
||||
getActiveTicketCatalogItemByCode
|
||||
} from '../../utils/booking-repository'
|
||||
import { buildBookingMessage, parseCreateBookingInput } from '../../utils/bookings'
|
||||
import { assertBadRequest } from '../../utils/http'
|
||||
import { getPublicContactById } from '../../utils/user-repository'
|
||||
@@ -20,29 +24,33 @@ export default defineEventHandler(async (event): Promise<CreateBookingResponse>
|
||||
}>(event)
|
||||
|
||||
const input = parseCreateBookingInput(body)
|
||||
const personInCharge = await getPublicContactById(input.personInChargeId)
|
||||
const [personInCharge, bookingMode, ticket] = await Promise.all([
|
||||
getPublicContactById(input.personInChargeId),
|
||||
getActiveBookingModeOptionByCode(input.bookingMode),
|
||||
getActiveTicketCatalogItemByCode(input.ticketType)
|
||||
])
|
||||
|
||||
assertBadRequest(personInCharge, 'Selected person in charge is not available')
|
||||
|
||||
const ticket = getTicketCatalogItem(input.ticketType)
|
||||
|
||||
assertBadRequest(bookingMode, 'Booking mode is invalid')
|
||||
assertBadRequest(ticket, 'Ticket category is invalid')
|
||||
assertBadRequest(bookingMode.eventId === ticket.eventId, 'Booking mode and ticket category must belong to the same event')
|
||||
|
||||
const seatCount = getSeatCount(input.bookingMode, input.quantity)
|
||||
const seatCount = getSeatCount(bookingMode, input.quantity)
|
||||
const totalPrice = seatCount * ticket.price
|
||||
|
||||
const { booking, confirmationToken } = await createBooking({
|
||||
eventId: bookingMode.eventId,
|
||||
customerName: input.customerName,
|
||||
customerPhone: input.customerPhone,
|
||||
bookingMode: input.bookingMode,
|
||||
bookingModeId: bookingMode.id,
|
||||
bookingMode: bookingMode.value,
|
||||
quantity: input.quantity,
|
||||
seatCount,
|
||||
ticketType: input.ticketType,
|
||||
ticketTypeId: ticket.id,
|
||||
ticketType: ticket.value,
|
||||
unitPrice: ticket.price,
|
||||
totalPrice,
|
||||
personInChargeId: personInCharge.id,
|
||||
personInChargeName: personInCharge.fullName,
|
||||
personInChargePhoneNumber: personInCharge.phoneNumber
|
||||
personInChargeId: personInCharge.id
|
||||
})
|
||||
|
||||
const confirmationUrl = buildAppUrl(event, `/confirmation/${confirmationToken}`)
|
||||
|
||||
31
server/api/public/bookings/[token]/cancel.post.ts
Normal file
31
server/api/public/bookings/[token]/cancel.post.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { CancelBookingConfirmationResponse } from '~~/shared/booking'
|
||||
|
||||
import { cancelBookingConfirmationByConfirmationToken, getBookingByConfirmationToken } from '../../../../utils/booking-repository'
|
||||
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<CancelBookingConfirmationResponse> => {
|
||||
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||
const existingBooking = await getBookingByConfirmationToken(token)
|
||||
|
||||
if (!existingBooking) {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
if (existingBooking.status === 'pending') {
|
||||
return {
|
||||
booking: existingBooking,
|
||||
alreadyPending: true
|
||||
}
|
||||
}
|
||||
|
||||
const booking = await cancelBookingConfirmationByConfirmationToken(token)
|
||||
|
||||
if (!booking) {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
return {
|
||||
booking,
|
||||
alreadyPending: false
|
||||
}
|
||||
})
|
||||
@@ -1,17 +1,21 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type {
|
||||
BookingModeOption,
|
||||
BookingCapacitySettings,
|
||||
BookingInventorySummary,
|
||||
DinnerEvent,
|
||||
BookingMode,
|
||||
BookingStatus,
|
||||
PublicBookingConfig,
|
||||
PublicBooking,
|
||||
PublicBookingSeat,
|
||||
ReceiptBooking,
|
||||
TicketCatalogItem,
|
||||
TicketType
|
||||
} from '~~/shared/booking'
|
||||
|
||||
import { calculateBookingInventorySummary, isBookingMode, isBookingStatus } from '~~/shared/booking'
|
||||
import { calculateBookingInventorySummary, getBookingStatusLabel, isBookingStatus } from '~~/shared/booking'
|
||||
|
||||
import { randomToken, toIsoString } from './base64url'
|
||||
import { ensureDatabaseReady } from './db-init'
|
||||
@@ -21,18 +25,31 @@ type DbBookingRow = {
|
||||
id: string
|
||||
confirmation_token: string
|
||||
receipt_token: string
|
||||
event_id: string
|
||||
event_title: string
|
||||
event_date_label: string
|
||||
event_time_label: string
|
||||
event_venue: string
|
||||
customer_name: string
|
||||
customer_phone: string
|
||||
booking_mode_id: string | null
|
||||
booking_mode: string
|
||||
booking_mode_label: string | null
|
||||
booking_mode_seats_per_unit: number | string | null
|
||||
quantity: number | string
|
||||
seat_count: number | string
|
||||
ticket_type: TicketType
|
||||
ticket_type_id: string | null
|
||||
ticket_type: string
|
||||
ticket_label: string | null
|
||||
ticket_description: string | null
|
||||
unit_price: number | string
|
||||
total_price: number | string
|
||||
person_in_charge_id: string
|
||||
person_in_charge_name: string
|
||||
person_in_charge_phone_number: string
|
||||
person_in_charge_name: string | null
|
||||
person_in_charge_phone_number: string | null
|
||||
remark?: string | null
|
||||
status: BookingStatus | string
|
||||
status_label: string | null
|
||||
created_at: Date | string
|
||||
confirmed_at: Date | string | null
|
||||
}
|
||||
@@ -48,56 +65,177 @@ type DbBookingSeatRow = {
|
||||
updated_at: Date | string
|
||||
}
|
||||
|
||||
type DbBookingSeatWithBookingRow = DbBookingSeatRow & {
|
||||
type DbBookingSeatWithBookingRow = DbBookingSeatRow & Omit<DbBookingRow, 'id' | 'created_at'> & {
|
||||
booking_id: string
|
||||
confirmation_token: string
|
||||
receipt_token: string
|
||||
customer_name: string
|
||||
customer_phone: string
|
||||
booking_mode: string
|
||||
quantity: number | string
|
||||
seat_count: number | string
|
||||
ticket_type: TicketType
|
||||
unit_price: number | string
|
||||
total_price: number | string
|
||||
status: BookingStatus | string
|
||||
booking_created_at: Date | string
|
||||
confirmed_at: Date | string | null
|
||||
}
|
||||
|
||||
type DbBookingSettingsRow = {
|
||||
event_id: string
|
||||
total_tables: number | string | null
|
||||
total_seats: number | string | null
|
||||
updated_at: Date | string
|
||||
}
|
||||
|
||||
type DbDinnerEventRow = {
|
||||
id: string
|
||||
title: string
|
||||
date_label: string
|
||||
time_label: string
|
||||
venue: string
|
||||
}
|
||||
|
||||
type DbBookingModeOptionRow = {
|
||||
id: string
|
||||
event_id: string
|
||||
code: string
|
||||
label: string
|
||||
quantity_label: string
|
||||
seats_per_unit: number | string
|
||||
sort_order: number | string
|
||||
}
|
||||
|
||||
type DbTicketCatalogItemRow = {
|
||||
id: string
|
||||
event_id: string
|
||||
code: string
|
||||
label: string
|
||||
description: string
|
||||
price: number | string
|
||||
sort_order: number | string
|
||||
}
|
||||
|
||||
export interface BookingModeOptionRecord extends BookingModeOption {
|
||||
eventId: string
|
||||
}
|
||||
|
||||
export interface TicketCatalogItemRecord extends TicketCatalogItem {
|
||||
eventId: string
|
||||
}
|
||||
|
||||
function bookingSelectColumns(sql: any) {
|
||||
return sql`
|
||||
bookings.id,
|
||||
bookings.confirmation_token,
|
||||
bookings.receipt_token,
|
||||
dinner_events.id as event_id,
|
||||
dinner_events.title as event_title,
|
||||
dinner_events.date_label as event_date_label,
|
||||
dinner_events.time_label as event_time_label,
|
||||
dinner_events.venue as event_venue,
|
||||
bookings.customer_name,
|
||||
bookings.customer_phone,
|
||||
bookings.booking_mode_id,
|
||||
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
|
||||
booking_modes.label as booking_mode_label,
|
||||
booking_modes.seats_per_unit as booking_mode_seats_per_unit,
|
||||
bookings.quantity,
|
||||
bookings.seat_count,
|
||||
bookings.ticket_type_id,
|
||||
coalesce(ticket_types.code, bookings.ticket_type) as ticket_type,
|
||||
ticket_types.label as ticket_label,
|
||||
ticket_types.description as ticket_description,
|
||||
bookings.unit_price,
|
||||
bookings.total_price,
|
||||
bookings.person_in_charge_id,
|
||||
coalesce(users.full_name, bookings.person_in_charge_name) as person_in_charge_name,
|
||||
coalesce(users.phone_number, bookings.person_in_charge_phone_number) as person_in_charge_phone_number,
|
||||
bookings.remark,
|
||||
bookings.status,
|
||||
booking_statuses.label as status_label,
|
||||
bookings.created_at,
|
||||
bookings.confirmed_at
|
||||
`
|
||||
}
|
||||
|
||||
function bookingJoins(sql: any) {
|
||||
return sql`
|
||||
inner join dinner_events on dinner_events.id = bookings.event_id
|
||||
left join booking_modes on booking_modes.id = bookings.booking_mode_id
|
||||
left join ticket_types on ticket_types.id = bookings.ticket_type_id
|
||||
left join users on users.id = bookings.person_in_charge_id
|
||||
left join booking_statuses on booking_statuses.code = bookings.status
|
||||
`
|
||||
}
|
||||
|
||||
function parseInteger(value: number | string) {
|
||||
return typeof value === 'number' ? value : Number.parseInt(value, 10)
|
||||
}
|
||||
|
||||
function normalizeBookingMode(value: string): BookingMode {
|
||||
return isBookingMode(value) ? value : 'seat'
|
||||
function mapDinnerEvent(row: DbDinnerEventRow): DinnerEvent {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
dateLabel: row.date_label,
|
||||
timeLabel: row.time_label,
|
||||
venue: row.venue
|
||||
}
|
||||
}
|
||||
|
||||
function mapDinnerEventFromBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): DinnerEvent {
|
||||
return {
|
||||
id: row.event_id,
|
||||
title: row.event_title,
|
||||
dateLabel: row.event_date_label,
|
||||
timeLabel: row.event_time_label,
|
||||
venue: row.event_venue
|
||||
}
|
||||
}
|
||||
|
||||
function mapBookingModeOption(row: DbBookingModeOptionRow): BookingModeOptionRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
eventId: row.event_id,
|
||||
value: row.code,
|
||||
label: row.label,
|
||||
quantityLabel: row.quantity_label,
|
||||
seatsPerUnit: parseInteger(row.seats_per_unit),
|
||||
sortOrder: parseInteger(row.sort_order)
|
||||
}
|
||||
}
|
||||
|
||||
function mapTicketCatalogItem(row: DbTicketCatalogItemRow): TicketCatalogItemRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
eventId: row.event_id,
|
||||
value: row.code,
|
||||
label: row.label,
|
||||
description: row.description,
|
||||
price: parseInteger(row.price),
|
||||
sortOrder: parseInteger(row.sort_order)
|
||||
}
|
||||
}
|
||||
|
||||
function mapBooking(row: DbBookingRow): PublicBooking {
|
||||
const seatCount = parseInteger(row.seat_count)
|
||||
const status = isBookingStatus(row.status) ? row.status : 'pending'
|
||||
const ticketType = row.ticket_type
|
||||
const bookingMode = row.booking_mode
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
confirmationToken: row.confirmation_token,
|
||||
receiptToken: row.receipt_token,
|
||||
event: mapDinnerEventFromBooking(row),
|
||||
customerName: row.customer_name,
|
||||
customerPhone: row.customer_phone,
|
||||
bookingMode: normalizeBookingMode(row.booking_mode),
|
||||
bookingModeId: row.booking_mode_id,
|
||||
bookingMode,
|
||||
bookingModeLabel: row.booking_mode_label || bookingMode,
|
||||
quantity: parseInteger(row.quantity),
|
||||
seatCount,
|
||||
ticketType: row.ticket_type,
|
||||
ticketTypeId: row.ticket_type_id,
|
||||
ticketType,
|
||||
ticketLabel: row.ticket_label || ticketType.toUpperCase(),
|
||||
ticketDescription: row.ticket_description,
|
||||
unitPrice: parseInteger(row.unit_price),
|
||||
totalPrice: parseInteger(row.total_price),
|
||||
personInChargeId: row.person_in_charge_id,
|
||||
personInChargeName: row.person_in_charge_name,
|
||||
personInChargePhoneNumber: row.person_in_charge_phone_number,
|
||||
status: isBookingStatus(row.status) ? row.status : 'pending',
|
||||
personInChargeName: row.person_in_charge_name || '',
|
||||
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
|
||||
remark: row.remark || null,
|
||||
status,
|
||||
statusLabel: row.status_label || getBookingStatusLabel(status),
|
||||
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
||||
confirmedAt: toIsoString(row.confirmed_at)
|
||||
}
|
||||
@@ -105,24 +243,59 @@ function mapBooking(row: DbBookingRow): PublicBooking {
|
||||
|
||||
function mapReceiptBooking(row: DbBookingRow | DbBookingSeatWithBookingRow): ReceiptBooking {
|
||||
const seatCount = parseInteger(row.seat_count)
|
||||
const status = isBookingStatus(row.status) ? row.status : 'pending'
|
||||
const ticketType = row.ticket_type
|
||||
const bookingMode = row.booking_mode
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
receiptToken: row.receipt_token,
|
||||
event: mapDinnerEventFromBooking(row),
|
||||
customerName: row.customer_name,
|
||||
customerPhone: row.customer_phone,
|
||||
bookingMode: normalizeBookingMode(row.booking_mode),
|
||||
bookingModeId: row.booking_mode_id,
|
||||
bookingMode,
|
||||
bookingModeLabel: row.booking_mode_label || bookingMode,
|
||||
quantity: parseInteger(row.quantity),
|
||||
seatCount,
|
||||
ticketType: row.ticket_type,
|
||||
ticketTypeId: row.ticket_type_id,
|
||||
ticketType,
|
||||
ticketLabel: row.ticket_label || ticketType.toUpperCase(),
|
||||
ticketDescription: row.ticket_description,
|
||||
unitPrice: parseInteger(row.unit_price),
|
||||
totalPrice: parseInteger(row.total_price),
|
||||
status: isBookingStatus(row.status) ? row.status : 'pending',
|
||||
status,
|
||||
statusLabel: row.status_label || getBookingStatusLabel(status),
|
||||
createdAt: toIsoString('booking_created_at' in row ? row.booking_created_at : row.created_at) ?? new Date().toISOString(),
|
||||
confirmedAt: toIsoString(row.confirmed_at)
|
||||
}
|
||||
}
|
||||
|
||||
function mapPublicBookingToReceiptBooking(booking: PublicBooking): ReceiptBooking {
|
||||
return {
|
||||
id: booking.id,
|
||||
receiptToken: booking.receiptToken,
|
||||
event: booking.event,
|
||||
customerName: booking.customerName,
|
||||
customerPhone: booking.customerPhone,
|
||||
bookingModeId: booking.bookingModeId,
|
||||
bookingMode: booking.bookingMode,
|
||||
bookingModeLabel: booking.bookingModeLabel,
|
||||
quantity: booking.quantity,
|
||||
seatCount: booking.seatCount,
|
||||
ticketTypeId: booking.ticketTypeId,
|
||||
ticketType: booking.ticketType,
|
||||
ticketLabel: booking.ticketLabel,
|
||||
ticketDescription: booking.ticketDescription,
|
||||
unitPrice: booking.unitPrice,
|
||||
totalPrice: booking.totalPrice,
|
||||
status: booking.status,
|
||||
statusLabel: booking.statusLabel,
|
||||
createdAt: booking.createdAt,
|
||||
confirmedAt: booking.confirmedAt
|
||||
}
|
||||
}
|
||||
|
||||
function mapBookingSeat(row: DbBookingSeatRow): PublicBookingSeat {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -144,9 +317,7 @@ function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): Book
|
||||
}
|
||||
}
|
||||
|
||||
const totalSeats = row.total_seats === null
|
||||
? (row.total_tables === null ? null : parseInteger(row.total_tables) * 10)
|
||||
: parseInteger(row.total_seats)
|
||||
const totalSeats = row.total_seats === null ? null : parseInteger(row.total_seats)
|
||||
|
||||
return {
|
||||
totalSeats,
|
||||
@@ -154,6 +325,115 @@ function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): Book
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPublicBookingConfig(): Promise<PublicBookingConfig> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [event] = await sql<DbDinnerEventRow[]>`
|
||||
select
|
||||
id,
|
||||
title,
|
||||
date_label,
|
||||
time_label,
|
||||
venue
|
||||
from dinner_events
|
||||
where is_active = true
|
||||
order by sort_order asc, created_at asc
|
||||
limit 1
|
||||
`
|
||||
|
||||
if (!event) {
|
||||
throw new Error('No active dinner event is configured.')
|
||||
}
|
||||
|
||||
const [bookingModes, ticketCatalog] = await Promise.all([
|
||||
sql<DbBookingModeOptionRow[]>`
|
||||
select
|
||||
id,
|
||||
event_id,
|
||||
code,
|
||||
label,
|
||||
quantity_label,
|
||||
seats_per_unit,
|
||||
sort_order
|
||||
from booking_modes
|
||||
where event_id = ${event.id}
|
||||
and is_active = true
|
||||
order by sort_order asc, label asc
|
||||
`,
|
||||
sql<DbTicketCatalogItemRow[]>`
|
||||
select
|
||||
id,
|
||||
event_id,
|
||||
code,
|
||||
label,
|
||||
description,
|
||||
price,
|
||||
sort_order
|
||||
from ticket_types
|
||||
where event_id = ${event.id}
|
||||
and is_active = true
|
||||
order by sort_order asc, label asc
|
||||
`
|
||||
])
|
||||
|
||||
return {
|
||||
event: mapDinnerEvent(event),
|
||||
bookingModes: bookingModes.map(mapBookingModeOption),
|
||||
ticketCatalog: ticketCatalog.map(mapTicketCatalogItem)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActiveBookingModeOptionByCode(code: string): Promise<BookingModeOptionRecord | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingModeOptionRow[]>`
|
||||
select
|
||||
booking_modes.id,
|
||||
booking_modes.event_id,
|
||||
booking_modes.code,
|
||||
booking_modes.label,
|
||||
booking_modes.quantity_label,
|
||||
booking_modes.seats_per_unit,
|
||||
booking_modes.sort_order
|
||||
from booking_modes
|
||||
inner join dinner_events on dinner_events.id = booking_modes.event_id
|
||||
where dinner_events.is_active = true
|
||||
and booking_modes.is_active = true
|
||||
and booking_modes.code = ${code}
|
||||
order by booking_modes.sort_order asc
|
||||
limit 1
|
||||
`
|
||||
|
||||
return row ? mapBookingModeOption(row) : null
|
||||
}
|
||||
|
||||
export async function getActiveTicketCatalogItemByCode(code: string): Promise<TicketCatalogItemRecord | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbTicketCatalogItemRow[]>`
|
||||
select
|
||||
ticket_types.id,
|
||||
ticket_types.event_id,
|
||||
ticket_types.code,
|
||||
ticket_types.label,
|
||||
ticket_types.description,
|
||||
ticket_types.price,
|
||||
ticket_types.sort_order
|
||||
from ticket_types
|
||||
inner join dinner_events on dinner_events.id = ticket_types.event_id
|
||||
where dinner_events.is_active = true
|
||||
and ticket_types.is_active = true
|
||||
and ticket_types.code = ${code}
|
||||
order by ticket_types.sort_order asc
|
||||
limit 1
|
||||
`
|
||||
|
||||
return row ? mapTicketCatalogItem(row) : null
|
||||
}
|
||||
|
||||
async function insertBookingSeats(
|
||||
tx: ReturnType<typeof getSqlClient>,
|
||||
bookingId: string,
|
||||
@@ -178,17 +458,18 @@ async function insertBookingSeats(
|
||||
}
|
||||
|
||||
export async function createBooking(input: {
|
||||
eventId: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
bookingModeId: string
|
||||
bookingMode: BookingMode
|
||||
quantity: number
|
||||
seatCount: number
|
||||
ticketTypeId: string
|
||||
ticketType: TicketType
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
personInChargeId: string
|
||||
personInChargeName: string
|
||||
personInChargePhoneNumber: string
|
||||
}) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
@@ -198,58 +479,50 @@ export async function createBooking(input: {
|
||||
|
||||
const row = await sql.begin(async (tx) => {
|
||||
const [createdBooking] = await tx<DbBookingRow[]>`
|
||||
with inserted_booking as (
|
||||
insert into bookings (
|
||||
id,
|
||||
confirmation_token,
|
||||
receipt_token,
|
||||
event_id,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode_id,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type_id,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
remark,
|
||||
status
|
||||
)
|
||||
values (
|
||||
${bookingId},
|
||||
${confirmationToken},
|
||||
${receiptToken},
|
||||
${input.eventId},
|
||||
${input.customerName},
|
||||
${input.customerPhone},
|
||||
${input.bookingModeId},
|
||||
${input.bookingMode},
|
||||
${input.quantity},
|
||||
${input.seatCount},
|
||||
${input.ticketTypeId},
|
||||
${input.ticketType},
|
||||
${input.unitPrice},
|
||||
${input.totalPrice},
|
||||
${input.personInChargeId},
|
||||
${input.personInChargeName},
|
||||
${input.personInChargePhoneNumber},
|
||||
null,
|
||||
'pending'
|
||||
)
|
||||
returning
|
||||
id,
|
||||
confirmation_token,
|
||||
receipt_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(tx)}
|
||||
from inserted_booking as bookings
|
||||
${bookingJoins(tx)}
|
||||
`
|
||||
|
||||
await insertBookingSeats(tx, bookingId, input.seatCount)
|
||||
@@ -269,26 +542,10 @@ export async function getBookingByConfirmationToken(confirmationToken: string):
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
receipt_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from bookings
|
||||
where confirmation_token = ${confirmationToken}
|
||||
${bookingJoins(sql)}
|
||||
where bookings.confirmation_token = ${confirmationToken}
|
||||
limit 1
|
||||
`
|
||||
|
||||
@@ -300,26 +557,10 @@ export async function getBookingByReceiptToken(receiptToken: string): Promise<Pu
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
receipt_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from bookings
|
||||
where receipt_token = ${receiptToken}
|
||||
${bookingJoins(sql)}
|
||||
where bookings.receipt_token = ${receiptToken}
|
||||
limit 1
|
||||
`
|
||||
|
||||
@@ -334,54 +575,68 @@ export async function listBookings(options?: {
|
||||
|
||||
const rows = options?.personInChargeId
|
||||
? await sql<DbBookingRow[]>`
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
receipt_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from bookings
|
||||
where person_in_charge_id = ${options.personInChargeId}
|
||||
order by created_at desc
|
||||
${bookingJoins(sql)}
|
||||
where dinner_events.is_active = true
|
||||
and bookings.person_in_charge_id = ${options.personInChargeId}
|
||||
order by bookings.created_at desc
|
||||
`
|
||||
: await sql<DbBookingRow[]>`
|
||||
select
|
||||
id,
|
||||
confirmation_token,
|
||||
receipt_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from bookings
|
||||
order by created_at desc
|
||||
${bookingJoins(sql)}
|
||||
where dinner_events.is_active = true
|
||||
order by bookings.created_at desc
|
||||
`
|
||||
|
||||
return rows.map(mapBooking)
|
||||
}
|
||||
|
||||
export async function updateBookingRemark(input: {
|
||||
bookingId: string
|
||||
personInChargeId?: string
|
||||
remark: string | null
|
||||
}): Promise<PublicBooking | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const rows = input.personInChargeId
|
||||
? await sql<DbBookingRow[]>`
|
||||
with updated_booking as (
|
||||
update bookings
|
||||
set
|
||||
remark = ${input.remark},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
and person_in_charge_id = ${input.personInChargeId}
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from updated_booking as bookings
|
||||
${bookingJoins(sql)}
|
||||
where dinner_events.is_active = true
|
||||
limit 1
|
||||
`
|
||||
: await sql<DbBookingRow[]>`
|
||||
with updated_booking as (
|
||||
update bookings
|
||||
set
|
||||
remark = ${input.remark},
|
||||
updated_at = now()
|
||||
where id = ${input.bookingId}
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from updated_booking as bookings
|
||||
${bookingJoins(sql)}
|
||||
where dinner_events.is_active = true
|
||||
limit 1
|
||||
`
|
||||
|
||||
return rows[0] ? mapBooking(rows[0]) : null
|
||||
}
|
||||
|
||||
export async function listBookingSeats(bookingId: string): Promise<PublicBookingSeat[]> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
@@ -417,25 +672,7 @@ export async function getBookingReceiptByReceiptToken(receiptToken: string): Pro
|
||||
const seats = await listBookingSeats(booking.id)
|
||||
|
||||
return {
|
||||
booking: mapReceiptBooking({
|
||||
id: booking.id,
|
||||
confirmation_token: booking.confirmationToken,
|
||||
receipt_token: booking.receiptToken,
|
||||
customer_name: booking.customerName,
|
||||
customer_phone: booking.customerPhone,
|
||||
booking_mode: booking.bookingMode,
|
||||
quantity: booking.quantity,
|
||||
seat_count: booking.seatCount,
|
||||
ticket_type: booking.ticketType,
|
||||
unit_price: booking.unitPrice,
|
||||
total_price: booking.totalPrice,
|
||||
person_in_charge_id: booking.personInChargeId,
|
||||
person_in_charge_name: booking.personInChargeName,
|
||||
person_in_charge_phone_number: booking.personInChargePhoneNumber,
|
||||
status: booking.status,
|
||||
created_at: booking.createdAt,
|
||||
confirmed_at: booking.confirmedAt
|
||||
}),
|
||||
booking: mapPublicBookingToReceiptBooking(booking),
|
||||
seats
|
||||
}
|
||||
}
|
||||
@@ -460,19 +697,35 @@ export async function getSeatReceiptBySeatToken(seatToken: string): Promise<{
|
||||
bookings.id as booking_id,
|
||||
bookings.confirmation_token,
|
||||
bookings.receipt_token,
|
||||
dinner_events.id as event_id,
|
||||
dinner_events.title as event_title,
|
||||
dinner_events.date_label as event_date_label,
|
||||
dinner_events.time_label as event_time_label,
|
||||
dinner_events.venue as event_venue,
|
||||
bookings.customer_name,
|
||||
bookings.customer_phone,
|
||||
bookings.booking_mode,
|
||||
bookings.booking_mode_id,
|
||||
coalesce(booking_modes.code, bookings.booking_mode) as booking_mode,
|
||||
booking_modes.label as booking_mode_label,
|
||||
booking_modes.seats_per_unit as booking_mode_seats_per_unit,
|
||||
bookings.quantity,
|
||||
bookings.seat_count,
|
||||
bookings.ticket_type,
|
||||
bookings.ticket_type_id,
|
||||
coalesce(ticket_types.code, bookings.ticket_type) as ticket_type,
|
||||
ticket_types.label as ticket_label,
|
||||
ticket_types.description as ticket_description,
|
||||
bookings.unit_price,
|
||||
bookings.total_price,
|
||||
bookings.person_in_charge_id,
|
||||
coalesce(users.full_name, bookings.person_in_charge_name) as person_in_charge_name,
|
||||
coalesce(users.phone_number, bookings.person_in_charge_phone_number) as person_in_charge_phone_number,
|
||||
bookings.status,
|
||||
booking_statuses.label as status_label,
|
||||
bookings.created_at as booking_created_at,
|
||||
bookings.confirmed_at
|
||||
from booking_seats
|
||||
inner join bookings on bookings.id = booking_seats.booking_id
|
||||
${bookingJoins(sql)}
|
||||
where booking_seats.seat_token = ${seatToken}
|
||||
limit 1
|
||||
`
|
||||
@@ -533,11 +786,14 @@ export async function getBookingCapacitySettings(): Promise<BookingCapacitySetti
|
||||
|
||||
const [row] = await sql<DbBookingSettingsRow[]>`
|
||||
select
|
||||
booking_settings.event_id,
|
||||
total_tables,
|
||||
total_seats,
|
||||
updated_at
|
||||
booking_settings.updated_at
|
||||
from booking_settings
|
||||
where id = 'default'
|
||||
inner join dinner_events on dinner_events.id = booking_settings.event_id
|
||||
where dinner_events.is_active = true
|
||||
order by dinner_events.sort_order asc
|
||||
limit 1
|
||||
`
|
||||
|
||||
@@ -555,11 +811,14 @@ export async function updateBookingCapacitySettings(input: {
|
||||
set
|
||||
total_seats = ${input.totalSeats},
|
||||
updated_at = now()
|
||||
where id = 'default'
|
||||
from dinner_events
|
||||
where booking_settings.event_id = dinner_events.id
|
||||
and dinner_events.is_active = true
|
||||
returning
|
||||
booking_settings.event_id,
|
||||
total_tables,
|
||||
total_seats,
|
||||
updated_at
|
||||
booking_settings.updated_at
|
||||
`
|
||||
|
||||
return mapBookingCapacitySettings(row)
|
||||
@@ -579,6 +838,7 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
with updated_booking as (
|
||||
update bookings
|
||||
set
|
||||
status = 'confirmed',
|
||||
@@ -586,24 +846,38 @@ export async function confirmBookingByConfirmationToken(confirmationToken: strin
|
||||
updated_at = now()
|
||||
where confirmation_token = ${confirmationToken}
|
||||
and status = 'pending'
|
||||
returning
|
||||
id,
|
||||
confirmation_token,
|
||||
receipt_token,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
booking_mode,
|
||||
quantity,
|
||||
seat_count,
|
||||
ticket_type,
|
||||
unit_price,
|
||||
total_price,
|
||||
person_in_charge_id,
|
||||
person_in_charge_name,
|
||||
person_in_charge_phone_number,
|
||||
status,
|
||||
created_at,
|
||||
confirmed_at
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from updated_booking as bookings
|
||||
${bookingJoins(sql)}
|
||||
`
|
||||
|
||||
if (row) {
|
||||
return mapBooking(row)
|
||||
}
|
||||
|
||||
return await getBookingByConfirmationToken(confirmationToken)
|
||||
}
|
||||
|
||||
export async function cancelBookingConfirmationByConfirmationToken(confirmationToken: string): Promise<PublicBooking | null> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<DbBookingRow[]>`
|
||||
with updated_booking as (
|
||||
update bookings
|
||||
set
|
||||
status = 'pending',
|
||||
confirmed_at = null,
|
||||
updated_at = now()
|
||||
where confirmation_token = ${confirmationToken}
|
||||
and status = 'confirmed'
|
||||
returning *
|
||||
)
|
||||
select ${bookingSelectColumns(sql)}
|
||||
from updated_booking as bookings
|
||||
${bookingJoins(sql)}
|
||||
`
|
||||
|
||||
if (row) {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
formatBookingCurrency,
|
||||
getTicketCatalogItem,
|
||||
isBookingMode,
|
||||
isTicketType
|
||||
formatBookingCurrency
|
||||
} from '~~/shared/booking'
|
||||
import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth'
|
||||
|
||||
@@ -21,15 +18,15 @@ export function parseCreateBookingInput(body: {
|
||||
const customerName = normalizeFullName(body.customerName || '')
|
||||
const customerPhone = normalizePhoneNumber(body.customerPhone || '')
|
||||
const bookingMode = typeof body.bookingMode === 'string' ? body.bookingMode.trim().toLowerCase() : body.bookingMode
|
||||
const ticketType = body.ticketType
|
||||
const ticketType = typeof body.ticketType === 'string' ? body.ticketType.trim().toLowerCase() : body.ticketType
|
||||
const quantity = Number(body.quantity)
|
||||
const personInChargeId = (body.personInChargeId || '').trim()
|
||||
|
||||
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
|
||||
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must include a country code, e.g. +60123456789')
|
||||
assertBadRequest(isBookingMode(bookingMode), 'Booking mode is invalid')
|
||||
assertBadRequest(typeof bookingMode === 'string' && bookingMode.length > 0, 'Booking mode is required')
|
||||
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
|
||||
assertBadRequest(isTicketType(ticketType), 'Ticket category is invalid')
|
||||
assertBadRequest(typeof ticketType === 'string' && ticketType.trim().length > 0, 'Ticket category is required')
|
||||
assertBadRequest(personInChargeId, 'Person in charge is required')
|
||||
|
||||
return {
|
||||
@@ -42,17 +39,26 @@ export function parseCreateBookingInput(body: {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
|
||||
const ticket = getTicketCatalogItem(booking.ticketType)
|
||||
const ticketLabel = ticket?.label || booking.ticketType.toUpperCase()
|
||||
export function parseBookingRemarkInput(body: {
|
||||
remark?: string | null
|
||||
}) {
|
||||
const remark = typeof body.remark === 'string' ? body.remark.trim() : ''
|
||||
|
||||
assertBadRequest(remark.length <= 1000, 'Remark must be 1,000 characters or fewer')
|
||||
|
||||
return {
|
||||
remark: remark || null
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
|
||||
return [
|
||||
"I'd like to book tickets for the DAP Johor 60th Anniversary Celebration.",
|
||||
`I'd like to book tickets for the ${booking.event.title}.`,
|
||||
'',
|
||||
`Name: ${booking.customerName}`,
|
||||
`Phone Number: ${booking.customerPhone}`,
|
||||
`Seats: ${booking.seatCount}`,
|
||||
`Ticket Category: ${ticketLabel}`,
|
||||
`Ticket Category: ${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
|
||||
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
|
||||
'',
|
||||
'PIC confirmation link:',
|
||||
|
||||
@@ -41,6 +41,29 @@ async function initializeDatabase() {
|
||||
add column if not exists phone_number text
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table users
|
||||
add column if not exists pic_sort_order integer not null default 0
|
||||
`
|
||||
|
||||
await sql`
|
||||
update users
|
||||
set pic_sort_order = seed.sort_order
|
||||
from (
|
||||
select
|
||||
id,
|
||||
row_number() over (
|
||||
order by
|
||||
case when role = 'super_admin' then 0 else 1 end,
|
||||
created_at asc,
|
||||
full_name asc
|
||||
) as sort_order
|
||||
from users
|
||||
) as seed
|
||||
where users.id = seed.id
|
||||
and users.pic_sort_order = 0
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists user_passkeys (
|
||||
id text primary key,
|
||||
@@ -62,23 +85,206 @@ async function initializeDatabase() {
|
||||
on user_passkeys (user_id)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists dinner_events (
|
||||
id text primary key,
|
||||
title text not null,
|
||||
date_label text not null,
|
||||
time_label text not null,
|
||||
venue text not null,
|
||||
is_active boolean not null default false,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create unique index if not exists dinner_events_single_active_idx
|
||||
on dinner_events (is_active)
|
||||
where is_active = true
|
||||
`
|
||||
|
||||
await sql`
|
||||
insert into dinner_events (
|
||||
id,
|
||||
title,
|
||||
date_label,
|
||||
time_label,
|
||||
venue,
|
||||
is_active,
|
||||
sort_order
|
||||
)
|
||||
values (
|
||||
'dap-johor-60',
|
||||
'DAP JOHOR 60th Anniversary Celebration',
|
||||
'Saturday, 30 May 2026',
|
||||
'6:30 PM',
|
||||
'Yong Peng''s Chee Ann Kor',
|
||||
true,
|
||||
1
|
||||
)
|
||||
on conflict (id) do nothing
|
||||
`
|
||||
|
||||
await sql`
|
||||
update dinner_events
|
||||
set
|
||||
is_active = true,
|
||||
updated_at = now()
|
||||
where id = 'dap-johor-60'
|
||||
and not exists (
|
||||
select 1
|
||||
from dinner_events
|
||||
where is_active = true
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists booking_modes (
|
||||
id text primary key,
|
||||
event_id text not null references dinner_events(id) on delete cascade,
|
||||
code text not null,
|
||||
label text not null,
|
||||
quantity_label text not null,
|
||||
seats_per_unit integer not null check (seats_per_unit >= 1),
|
||||
is_active boolean not null default true,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (event_id, code)
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
insert into booking_modes (
|
||||
id,
|
||||
event_id,
|
||||
code,
|
||||
label,
|
||||
quantity_label,
|
||||
seats_per_unit,
|
||||
is_active,
|
||||
sort_order
|
||||
)
|
||||
values
|
||||
(
|
||||
'dap-johor-60-table',
|
||||
'dap-johor-60',
|
||||
'table',
|
||||
'Table (10 seats)',
|
||||
'Number of Tables',
|
||||
10,
|
||||
true,
|
||||
1
|
||||
),
|
||||
(
|
||||
'dap-johor-60-seat',
|
||||
'dap-johor-60',
|
||||
'seat',
|
||||
'Seat',
|
||||
'Number of Seats',
|
||||
1,
|
||||
true,
|
||||
2
|
||||
)
|
||||
on conflict (event_id, code) do nothing
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists ticket_types (
|
||||
id text primary key,
|
||||
event_id text not null references dinner_events(id) on delete cascade,
|
||||
code text not null,
|
||||
label text not null,
|
||||
description text not null,
|
||||
price integer not null check (price >= 0),
|
||||
is_active boolean not null default true,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (event_id, code)
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
insert into ticket_types (
|
||||
id,
|
||||
event_id,
|
||||
code,
|
||||
label,
|
||||
description,
|
||||
price,
|
||||
is_active,
|
||||
sort_order
|
||||
)
|
||||
values
|
||||
(
|
||||
'dap-johor-60-vip',
|
||||
'dap-johor-60',
|
||||
'vip',
|
||||
'VIP',
|
||||
'RM150 / seat',
|
||||
150,
|
||||
true,
|
||||
1
|
||||
),
|
||||
(
|
||||
'dap-johor-60-supporter',
|
||||
'dap-johor-60',
|
||||
'supporter',
|
||||
'Supporter',
|
||||
'RM60 / seat',
|
||||
60,
|
||||
true,
|
||||
2
|
||||
)
|
||||
on conflict (event_id, code) do nothing
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists booking_statuses (
|
||||
code text primary key,
|
||||
label text not null,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
insert into booking_statuses (
|
||||
code,
|
||||
label,
|
||||
sort_order
|
||||
)
|
||||
values
|
||||
('pending', 'Pending PIC confirmation', 1),
|
||||
('confirmed', 'Confirmed', 2)
|
||||
on conflict (code) do nothing
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists bookings (
|
||||
id text primary key,
|
||||
confirmation_token text not null unique,
|
||||
receipt_token text not null unique,
|
||||
event_id text references dinner_events(id) on delete restrict,
|
||||
customer_name text not null,
|
||||
customer_phone text not null,
|
||||
booking_mode text not null check (booking_mode in ('table', 'seat')),
|
||||
booking_mode_id text references booking_modes(id) on delete restrict,
|
||||
booking_mode text not null,
|
||||
quantity integer not null check (quantity >= 1),
|
||||
seat_count integer not null check (seat_count >= 1),
|
||||
ticket_type text not null check (ticket_type in ('vip', 'supporter')),
|
||||
ticket_type_id text references ticket_types(id) on delete restrict,
|
||||
ticket_type text not null,
|
||||
unit_price integer not null check (unit_price >= 0),
|
||||
total_price integer not null check (total_price >= 0),
|
||||
person_in_charge_id text not null references users(id) on delete restrict,
|
||||
person_in_charge_name text not null,
|
||||
person_in_charge_phone_number text not null,
|
||||
status text not null check (status in ('pending', 'confirmed')) default 'pending',
|
||||
remark text,
|
||||
status text not null default 'pending',
|
||||
confirmed_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
@@ -90,11 +296,46 @@ async function initializeDatabase() {
|
||||
add column if not exists receipt_token text
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
add column if not exists event_id text
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
add column if not exists booking_mode_id text
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
add column if not exists ticket_type_id text
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
add column if not exists remark text
|
||||
`
|
||||
|
||||
await sql`
|
||||
create unique index if not exists bookings_receipt_token_idx
|
||||
on bookings (receipt_token)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create index if not exists bookings_event_id_idx
|
||||
on bookings (event_id)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create index if not exists bookings_booking_mode_id_idx
|
||||
on bookings (booking_mode_id)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create index if not exists bookings_ticket_type_id_idx
|
||||
on bookings (ticket_type_id)
|
||||
`
|
||||
|
||||
await sql`
|
||||
create table if not exists booking_seats (
|
||||
id text primary key,
|
||||
@@ -118,21 +359,28 @@ async function initializeDatabase() {
|
||||
await sql`
|
||||
create table if not exists booking_settings (
|
||||
id text primary key,
|
||||
event_id text references dinner_events(id) on delete cascade,
|
||||
total_tables integer,
|
||||
total_seats integer,
|
||||
updated_at timestamptz not null default now()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table booking_settings
|
||||
add column if not exists event_id text
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table booking_settings
|
||||
add column if not exists total_seats integer
|
||||
`
|
||||
|
||||
await sql`
|
||||
insert into booking_settings (id)
|
||||
values ('default')
|
||||
on conflict (id) do nothing
|
||||
insert into booking_settings (id, event_id)
|
||||
values ('default', 'dap-johor-60')
|
||||
on conflict (id) do update
|
||||
set event_id = coalesce(booking_settings.event_id, excluded.event_id)
|
||||
`
|
||||
|
||||
const bookingsMissingReceiptTokens = await sql<{ id: string }[]>`
|
||||
@@ -156,12 +404,6 @@ async function initializeDatabase() {
|
||||
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
|
||||
@@ -177,14 +419,203 @@ async function initializeDatabase() {
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
add constraint bookings_booking_mode_check
|
||||
check (booking_mode in ('table', 'seat'))
|
||||
drop constraint if exists bookings_ticket_type_check
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
drop constraint if exists bookings_status_check
|
||||
`
|
||||
|
||||
const [activeEvent] = await sql<{ id: string }[]>`
|
||||
select id
|
||||
from dinner_events
|
||||
where is_active = true
|
||||
order by sort_order asc, created_at asc
|
||||
limit 1
|
||||
`
|
||||
|
||||
if (activeEvent) {
|
||||
await sql`
|
||||
update booking_settings
|
||||
set
|
||||
event_id = ${activeEvent.id},
|
||||
updated_at = now()
|
||||
where event_id is null
|
||||
`
|
||||
|
||||
await sql`
|
||||
update bookings
|
||||
set
|
||||
event_id = ${activeEvent.id},
|
||||
updated_at = now()
|
||||
where event_id is null
|
||||
`
|
||||
|
||||
await sql`
|
||||
update bookings
|
||||
set
|
||||
booking_mode_id = booking_modes.id,
|
||||
updated_at = now()
|
||||
from booking_modes
|
||||
where bookings.booking_mode_id is null
|
||||
and booking_modes.event_id = bookings.event_id
|
||||
and booking_modes.code = bookings.booking_mode
|
||||
`
|
||||
|
||||
await sql`
|
||||
update bookings
|
||||
set
|
||||
ticket_type_id = ticket_types.id,
|
||||
updated_at = now()
|
||||
from ticket_types
|
||||
where bookings.ticket_type_id is null
|
||||
and ticket_types.event_id = bookings.event_id
|
||||
and ticket_types.code = bookings.ticket_type
|
||||
`
|
||||
|
||||
const [fallbackBookingMode] = await sql<{ id: string, code: string }[]>`
|
||||
select id, code
|
||||
from booking_modes
|
||||
where event_id = ${activeEvent.id}
|
||||
and is_active = true
|
||||
order by sort_order asc, created_at asc
|
||||
limit 1
|
||||
`
|
||||
|
||||
if (fallbackBookingMode) {
|
||||
await sql`
|
||||
update bookings
|
||||
set
|
||||
booking_mode_id = ${fallbackBookingMode.id},
|
||||
booking_mode = ${fallbackBookingMode.code},
|
||||
updated_at = now()
|
||||
where booking_mode_id is null
|
||||
`
|
||||
}
|
||||
|
||||
const [fallbackTicketType] = await sql<{ id: string, code: string }[]>`
|
||||
select id, code
|
||||
from ticket_types
|
||||
where event_id = ${activeEvent.id}
|
||||
and is_active = true
|
||||
order by sort_order asc, created_at asc
|
||||
limit 1
|
||||
`
|
||||
|
||||
if (fallbackTicketType) {
|
||||
await sql`
|
||||
update bookings
|
||||
set
|
||||
ticket_type_id = ${fallbackTicketType.id},
|
||||
ticket_type = ${fallbackTicketType.code},
|
||||
updated_at = now()
|
||||
where ticket_type_id is null
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
await sql`
|
||||
create unique index if not exists booking_settings_event_id_idx
|
||||
on booking_settings (event_id)
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
alter column person_in_charge_name drop not null
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
alter column person_in_charge_phone_number drop not null
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
alter column event_id set not null
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
alter column booking_mode_id set not null
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
alter column ticket_type_id set not null
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table booking_settings
|
||||
alter column event_id set not null
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
drop constraint if exists bookings_event_id_fkey
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
add constraint bookings_event_id_fkey
|
||||
foreign key (event_id) references dinner_events(id) on delete restrict
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
drop constraint if exists bookings_booking_mode_id_fkey
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
add constraint bookings_booking_mode_id_fkey
|
||||
foreign key (booking_mode_id) references booking_modes(id) on delete restrict
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
drop constraint if exists bookings_ticket_type_id_fkey
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
add constraint bookings_ticket_type_id_fkey
|
||||
foreign key (ticket_type_id) references ticket_types(id) on delete restrict
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
drop constraint if exists bookings_status_fkey
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table bookings
|
||||
add constraint bookings_status_fkey
|
||||
foreign key (status) references booking_statuses(code) on delete restrict
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table booking_settings
|
||||
drop constraint if exists booking_settings_event_id_fkey
|
||||
`
|
||||
|
||||
await sql`
|
||||
alter table booking_settings
|
||||
add constraint booking_settings_event_id_fkey
|
||||
foreign key (event_id) references dinner_events(id) on delete cascade
|
||||
`
|
||||
|
||||
await sql`
|
||||
update booking_settings
|
||||
set
|
||||
total_seats = total_tables * 10,
|
||||
total_seats = total_tables * coalesce((
|
||||
select booking_modes.seats_per_unit
|
||||
from booking_modes
|
||||
where booking_modes.event_id = booking_settings.event_id
|
||||
and booking_modes.code = 'table'
|
||||
order by booking_modes.sort_order asc
|
||||
limit 1
|
||||
), 1),
|
||||
updated_at = now()
|
||||
where total_seats is null
|
||||
and total_tables is not null
|
||||
|
||||
@@ -9,7 +9,8 @@ export function getSqlClient() {
|
||||
sqlClient = postgres(
|
||||
config.databaseUrl || 'postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system',
|
||||
{
|
||||
max: 10
|
||||
max: 10,
|
||||
onnotice: false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type DbUserRow = {
|
||||
full_name: string
|
||||
phone_number: string | null
|
||||
role: UserRole
|
||||
pic_sort_order: number | string
|
||||
password_hash: string
|
||||
must_change_password: boolean
|
||||
is_active: boolean
|
||||
@@ -83,6 +84,7 @@ function mapAuthUser(row: DbUserRow): AuthUser {
|
||||
fullName: row.full_name,
|
||||
phoneNumber: row.phone_number ? normalizePhoneNumber(row.phone_number) : null,
|
||||
role: row.role,
|
||||
picSortOrder: parseInteger(row.pic_sort_order),
|
||||
isActive: row.is_active,
|
||||
mustChangePassword: row.must_change_password,
|
||||
needsPasskeySetup: passkeyCount === 0,
|
||||
@@ -147,6 +149,7 @@ export async function getUserById(userId: string): Promise<UserAuthRecord | null
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role,
|
||||
users.pic_sort_order,
|
||||
users.password_hash,
|
||||
users.must_change_password,
|
||||
users.is_active,
|
||||
@@ -178,6 +181,7 @@ export async function getUserByUsername(username: string): Promise<UserAuthRecor
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role,
|
||||
users.pic_sort_order,
|
||||
users.password_hash,
|
||||
users.must_change_password,
|
||||
users.is_active,
|
||||
@@ -209,6 +213,7 @@ export async function listUsers(): Promise<ManagedUser[]> {
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role,
|
||||
users.pic_sort_order,
|
||||
users.password_hash,
|
||||
users.must_change_password,
|
||||
users.is_active,
|
||||
@@ -223,7 +228,7 @@ export async function listUsers(): Promise<ManagedUser[]> {
|
||||
group by user_id
|
||||
) as passkey_totals on passkey_totals.user_id = users.id
|
||||
order by
|
||||
case when users.role = 'super_admin' then 0 else 1 end,
|
||||
users.pic_sort_order asc,
|
||||
users.created_at asc
|
||||
`
|
||||
|
||||
@@ -234,18 +239,19 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const rows = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
|
||||
const rows = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role' | 'pic_sort_order'>[]>`
|
||||
select
|
||||
users.id,
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role
|
||||
users.role,
|
||||
users.pic_sort_order
|
||||
from users
|
||||
where users.is_active = true
|
||||
and users.phone_number is not null
|
||||
and users.phone_number <> ''
|
||||
order by
|
||||
case when users.role = 'super_admin' then 0 else 1 end,
|
||||
users.pic_sort_order asc,
|
||||
users.full_name asc
|
||||
`
|
||||
|
||||
@@ -253,7 +259,8 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
|
||||
id: row.id,
|
||||
fullName: row.full_name,
|
||||
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
|
||||
role: row.role
|
||||
role: row.role,
|
||||
picSortOrder: parseInteger(row.pic_sort_order)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -261,12 +268,13 @@ export async function getPublicContactById(contactId: string): Promise<PublicCon
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role'>[]>`
|
||||
const [row] = await sql<Pick<DbUserRow, 'id' | 'full_name' | 'phone_number' | 'role' | 'pic_sort_order'>[]>`
|
||||
select
|
||||
users.id,
|
||||
users.full_name,
|
||||
users.phone_number,
|
||||
users.role
|
||||
users.role,
|
||||
users.pic_sort_order
|
||||
from users
|
||||
where users.id = ${contactId}
|
||||
and users.is_active = true
|
||||
@@ -283,7 +291,8 @@ export async function getPublicContactById(contactId: string): Promise<PublicCon
|
||||
id: row.id,
|
||||
fullName: row.full_name,
|
||||
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
|
||||
role: row.role
|
||||
role: row.role,
|
||||
picSortOrder: parseInteger(row.pic_sort_order)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +314,7 @@ export async function createUser(input: {
|
||||
full_name,
|
||||
phone_number,
|
||||
role,
|
||||
pic_sort_order,
|
||||
password_hash,
|
||||
must_change_password,
|
||||
is_active,
|
||||
@@ -316,6 +326,7 @@ export async function createUser(input: {
|
||||
${input.fullName},
|
||||
${input.phoneNumber},
|
||||
${input.role},
|
||||
(select coalesce(max(pic_sort_order), 0) + 1 from users),
|
||||
${input.passwordHash},
|
||||
true,
|
||||
true,
|
||||
@@ -327,6 +338,7 @@ export async function createUser(input: {
|
||||
full_name,
|
||||
phone_number,
|
||||
role,
|
||||
pic_sort_order,
|
||||
password_hash,
|
||||
must_change_password,
|
||||
is_active,
|
||||
@@ -361,6 +373,21 @@ export async function updateUserProfile(input: {
|
||||
return getUserById(input.userId)
|
||||
}
|
||||
|
||||
export async function reorderUsers(userIds: string[]) {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
for (const [index, userId] of userIds.entries()) {
|
||||
await sql`
|
||||
update users
|
||||
set
|
||||
pic_sort_order = ${index + 1},
|
||||
updated_at = now()
|
||||
where id = ${userId}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserPassword(input: {
|
||||
userId: string
|
||||
passwordHash: string
|
||||
|
||||
@@ -73,3 +73,19 @@ export function parseUserProfileInput(body: {
|
||||
role
|
||||
}
|
||||
}
|
||||
|
||||
export function parseUserOrderInput(body: {
|
||||
userIds?: unknown
|
||||
}) {
|
||||
assertBadRequest(Array.isArray(body.userIds), 'User ids must be an array')
|
||||
assertBadRequest(body.userIds.every((value) => typeof value === 'string' && value.trim()), 'Every user id is required')
|
||||
|
||||
const userIds = body.userIds.map((value) => value.trim())
|
||||
const uniqueUserIds = new Set(userIds)
|
||||
|
||||
assertBadRequest(uniqueUserIds.size === userIds.length, 'User ids must be unique')
|
||||
|
||||
return {
|
||||
userIds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,7 @@ import type { H3Event } from 'h3'
|
||||
import type { PublicBooking, WhatsAppDeliveryResult } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
DINNER_EVENT_DATE_LABEL,
|
||||
DINNER_EVENT_TIME_LABEL,
|
||||
DINNER_EVENT_TITLE,
|
||||
DINNER_EVENT_VENUE,
|
||||
formatBookingCurrency,
|
||||
getTicketCatalogItem
|
||||
formatBookingCurrency
|
||||
} from '~~/shared/booking'
|
||||
import { normalizePhoneNumber } from '~~/shared/auth'
|
||||
|
||||
@@ -29,22 +24,20 @@ export function buildWhatsAppDeepLink(phoneNumber: string, message: string) {
|
||||
}
|
||||
|
||||
export function buildBookingTicketReceiptMessage(event: H3Event, booking: PublicBooking) {
|
||||
const ticket = getTicketCatalogItem(booking.ticketType)
|
||||
const ticketLabel = ticket?.label || booking.ticketType.toUpperCase()
|
||||
const receiptUrl = buildAppUrl(event, `/receipt/${booking.receiptToken}`)
|
||||
|
||||
return [
|
||||
DINNER_EVENT_TITLE,
|
||||
booking.event.title,
|
||||
'',
|
||||
`Hi ${booking.customerName}, your ticket receipt has been confirmed.`,
|
||||
'',
|
||||
`Receipt: ${receiptUrl}`,
|
||||
`Seats: ${booking.seatCount}`,
|
||||
`Ticket Category: ${ticketLabel}`,
|
||||
`Ticket Category: ${booking.ticketLabel || booking.ticketType.toUpperCase()}`,
|
||||
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
|
||||
`Date: ${DINNER_EVENT_DATE_LABEL}`,
|
||||
`Time: ${DINNER_EVENT_TIME_LABEL}`,
|
||||
`Venue: ${DINNER_EVENT_VENUE}`,
|
||||
`Date: ${booking.event.dateLabel}`,
|
||||
`Time: ${booking.event.timeLabel}`,
|
||||
`Venue: ${booking.event.venue}`,
|
||||
'',
|
||||
'Please present the QR code from the receipt at the event.'
|
||||
].join('\n')
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface AuthUser {
|
||||
fullName: string
|
||||
phoneNumber: string | null
|
||||
role: UserRole
|
||||
picSortOrder: number
|
||||
isActive: boolean
|
||||
mustChangePassword: boolean
|
||||
needsPasskeySetup: boolean
|
||||
@@ -81,6 +82,7 @@ export interface PublicContact {
|
||||
fullName: string
|
||||
phoneNumber: string
|
||||
role: UserRole
|
||||
picSortOrder: number
|
||||
}
|
||||
|
||||
export interface PasskeySummary {
|
||||
|
||||
@@ -1,56 +1,63 @@
|
||||
export type BookingMode = 'table' | 'seat'
|
||||
export type TicketType = 'vip' | 'supporter'
|
||||
export type BookingMode = string
|
||||
export type TicketType = string
|
||||
export type BookingStatus = 'pending' | 'confirmed'
|
||||
|
||||
export const DINNER_EVENT_TITLE = 'DAP JOHOR 60th Anniversary Celebration'
|
||||
export const DINNER_EVENT_DATE_LABEL = 'Saturday, 30 May 2026'
|
||||
export const DINNER_EVENT_TIME_LABEL = '6:30 PM'
|
||||
export const DINNER_EVENT_VENUE = "Yong Peng's Chee Ann Kor"
|
||||
export interface DinnerEvent {
|
||||
id: string
|
||||
title: string
|
||||
dateLabel: string
|
||||
timeLabel: string
|
||||
venue: string
|
||||
}
|
||||
|
||||
export const TABLE_SEAT_COUNT = 10
|
||||
export interface BookingModeOption {
|
||||
id: string
|
||||
value: BookingMode
|
||||
label: string
|
||||
quantityLabel: string
|
||||
seatsPerUnit: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export const BOOKING_MODE_OPTIONS = [
|
||||
{
|
||||
value: 'table',
|
||||
label: `Table (${TABLE_SEAT_COUNT} seats)`
|
||||
},
|
||||
{
|
||||
value: 'seat',
|
||||
label: 'Seat'
|
||||
}
|
||||
] satisfies Array<{ value: BookingMode, label: string }>
|
||||
export interface TicketCatalogItem {
|
||||
id: string
|
||||
value: TicketType
|
||||
label: string
|
||||
description: string
|
||||
price: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export const BOOKING_TICKET_CATALOG = [
|
||||
{
|
||||
value: 'vip',
|
||||
label: 'VIP',
|
||||
description: 'RM150 / seat',
|
||||
price: 150
|
||||
},
|
||||
{
|
||||
value: 'supporter',
|
||||
label: 'Supporter',
|
||||
description: 'RM60 / seat',
|
||||
price: 60
|
||||
}
|
||||
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
|
||||
export interface PublicBookingConfig {
|
||||
event: DinnerEvent
|
||||
bookingModes: BookingModeOption[]
|
||||
ticketCatalog: TicketCatalogItem[]
|
||||
}
|
||||
|
||||
export interface PublicBooking {
|
||||
id: string
|
||||
confirmationToken: string
|
||||
receiptToken: string
|
||||
event: DinnerEvent
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
bookingModeId: string | null
|
||||
bookingMode: BookingMode
|
||||
bookingModeLabel: string
|
||||
quantity: number
|
||||
seatCount: number
|
||||
ticketTypeId: string | null
|
||||
ticketType: TicketType
|
||||
ticketLabel: string
|
||||
ticketDescription: string | null
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
personInChargeId: string
|
||||
personInChargeName: string
|
||||
personInChargePhoneNumber: string
|
||||
remark: string | null
|
||||
status: BookingStatus
|
||||
statusLabel: string
|
||||
createdAt: string
|
||||
confirmedAt: string | null
|
||||
}
|
||||
@@ -58,15 +65,22 @@ export interface PublicBooking {
|
||||
export interface ReceiptBooking {
|
||||
id: string
|
||||
receiptToken: string
|
||||
event: DinnerEvent
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
bookingModeId: string | null
|
||||
bookingMode: BookingMode
|
||||
bookingModeLabel: string
|
||||
quantity: number
|
||||
seatCount: number
|
||||
ticketTypeId: string | null
|
||||
ticketType: TicketType
|
||||
ticketLabel: string
|
||||
ticketDescription: string | null
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
status: BookingStatus
|
||||
statusLabel: string
|
||||
createdAt: string
|
||||
confirmedAt: string | null
|
||||
}
|
||||
@@ -131,32 +145,33 @@ export interface ConfirmBookingResponse {
|
||||
ticketReceiptWhatsApp: WhatsAppDeliveryResult
|
||||
}
|
||||
|
||||
export function isBookingMode(value: string | null | undefined): value is BookingMode {
|
||||
return value === 'table' || value === 'seat'
|
||||
}
|
||||
|
||||
export function isTicketType(value: string | null | undefined): value is TicketType {
|
||||
return value === 'vip' || value === 'supporter'
|
||||
export interface CancelBookingConfirmationResponse {
|
||||
booking: PublicBooking
|
||||
alreadyPending: boolean
|
||||
}
|
||||
|
||||
export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
|
||||
return value === 'pending' || value === 'confirmed'
|
||||
}
|
||||
|
||||
export function getBookingModeLabel(value: BookingMode) {
|
||||
return value === 'table' ? `Table (${TABLE_SEAT_COUNT} seats each)` : 'Per seat'
|
||||
}
|
||||
export function getBookingStatusLabel(value: BookingStatus | string, label?: string | null) {
|
||||
if (label) {
|
||||
return label
|
||||
}
|
||||
|
||||
export function getBookingStatusLabel(value: BookingStatus) {
|
||||
return value === 'confirmed' ? 'Confirmed' : 'Pending PIC confirmation'
|
||||
}
|
||||
|
||||
export function getSeatCount(bookingMode: BookingMode, quantity: number) {
|
||||
return bookingMode === 'table' ? quantity * TABLE_SEAT_COUNT : quantity
|
||||
export function getSeatCount(bookingMode: Pick<BookingModeOption, 'seatsPerUnit'> | null | undefined, quantity: number) {
|
||||
return quantity * (bookingMode?.seatsPerUnit ?? 1)
|
||||
}
|
||||
|
||||
export function getTicketCatalogItem(ticketType: TicketType) {
|
||||
return BOOKING_TICKET_CATALOG.find((ticket) => ticket.value === ticketType) ?? null
|
||||
export function getTicketLabel(ticket: Pick<TicketCatalogItem, 'label'> | null | undefined, ticketType: TicketType) {
|
||||
return ticket?.label || ticketType.toUpperCase()
|
||||
}
|
||||
|
||||
export function getBookingTicketLabel(booking: Pick<PublicBooking | ReceiptBooking, 'ticketLabel' | 'ticketType'>) {
|
||||
return booking.ticketLabel || booking.ticketType.toUpperCase()
|
||||
}
|
||||
|
||||
export function formatBookingCurrency(value: number) {
|
||||
|
||||
Reference in New Issue
Block a user