Compare commits

...

3 Commits

Author SHA1 Message Date
4e40bfd804 feat(users): add drag-and-drop reordering for PICs
Introduce pic_sort_order to persist custom user ordering
Replace data table with a custom draggable grid layout
Add API endpoint to handle bulk order updates
2026-05-04 14:07:43 +08:00
30753fdc61 feat(bookings): add internal remark field to bookings
Add a remark column to the bookings table for management-only notes.
Include UI to view and edit remarks directly from the bookings list.
Create API endpoint and database queries to support remark updates.
2026-05-04 11:59:41 +08:00
3f7025c8e4 feat(booking): move event and ticket configuration to database
Replace hardcoded event details and ticket types with dynamic DB records
Add booking-config API endpoint to serve active event settings
2026-05-04 10:09:08 +08:00
19 changed files with 1581 additions and 435 deletions

View File

@@ -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"
@@ -283,17 +307,69 @@
</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, PublicBooking } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingStatusLabel,
getTicketCatalogItem
getBookingStatusLabel
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
@@ -310,6 +386,9 @@ const auth = useAuth()
const bookings = ref<PublicBooking[]>([])
const loadingBookings = ref(false)
const savingCapacity = ref(false)
const savingRemark = ref(false)
const remarkModalOpen = ref(false)
const editingBooking = ref<PublicBooking | null>(null)
const searchQuery = ref('')
const settings = reactive<BookingCapacitySettings>({
totalSeats: null,
@@ -324,12 +403,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 +454,8 @@ const filteredBookings = computed(() => {
booking.personInChargeName,
booking.personInChargePhoneNumber,
booking.ticketType,
booking.ticketLabel,
booking.remark || '',
booking.status
].some((value) => value.toLowerCase().includes(keyword))
})
@@ -385,8 +471,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 +518,32 @@ 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 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 +610,44 @@ 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
}
}
</script>

View File

@@ -3,8 +3,7 @@ import type { ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
import {
formatBookingCurrency,
getBookingStatusLabel,
getTicketCatalogItem
getBookingStatusLabel
} from '~~/shared/booking'
import { getErrorMessage } from '../../utils/errors'
@@ -32,7 +31,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(() => {
@@ -139,7 +138,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">

View File

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

View File

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

View File

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

View File

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

View 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()
}
})

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

View File

@@ -0,0 +1,5 @@
import { getPublicBookingConfig } from '../../utils/booking-repository'
export default defineEventHandler(async () => {
return await getPublicBookingConfig()
})

View File

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

View File

@@ -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,11 @@ 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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 const TABLE_SEAT_COUNT = 10
export const BOOKING_MODE_OPTIONS = [
{
value: 'table',
label: `Table (${TABLE_SEAT_COUNT} seats)`
},
{
value: 'seat',
label: 'Seat'
export interface DinnerEvent {
id: string
title: string
dateLabel: string
timeLabel: string
venue: string
}
] satisfies Array<{ value: BookingMode, label: string }>
export const BOOKING_TICKET_CATALOG = [
{
value: 'vip',
label: 'VIP',
description: 'RM150 / seat',
price: 150
},
{
value: 'supporter',
label: 'Supporter',
description: 'RM60 / seat',
price: 60
export interface BookingModeOption {
id: string
value: BookingMode
label: string
quantityLabel: string
seatsPerUnit: number
sortOrder: number
}
export interface TicketCatalogItem {
id: string
value: TicketType
label: string
description: string
price: number
sortOrder: number
}
export interface PublicBookingConfig {
event: DinnerEvent
bookingModes: BookingModeOption[]
ticketCatalog: TicketCatalogItem[]
}
] satisfies Array<{ value: TicketType, label: string, description: string, price: number }>
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,28 @@ 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 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) {