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.
654 lines
20 KiB
Vue
654 lines
20 KiB
Vue
<template>
|
|
<UContainer class="py-6">
|
|
<div class="mx-auto max-w-7xl space-y-4">
|
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
|
<div class="space-y-1">
|
|
<UBadge label="Bookings" color="primary" variant="soft" size="sm" class="rounded-full" />
|
|
<h1 class="text-2xl font-bold tracking-tight text-highlighted">
|
|
Booking list
|
|
</h1>
|
|
<p class="text-sm text-muted">
|
|
{{ auth.isSuperAdmin.value ? 'All submitted bookings across every PIC.' : 'Bookings assigned to you as PIC.' }}
|
|
</p>
|
|
</div>
|
|
|
|
<UBadge
|
|
:label="auth.isSuperAdmin.value ? 'Super Admin View' : 'Staff View'"
|
|
:color="auth.isSuperAdmin.value ? 'primary' : 'neutral'"
|
|
variant="soft"
|
|
size="sm"
|
|
class="rounded-full"
|
|
/>
|
|
</div>
|
|
|
|
<UAlert
|
|
title="Inventory counting rule"
|
|
:description="inventoryDescription"
|
|
color="info"
|
|
icon="i-lucide-info"
|
|
variant="soft"
|
|
:ui="{ root: 'py-3', title: 'text-sm font-medium', description: 'text-xs sm:text-sm' }"
|
|
/>
|
|
|
|
<div v-if="auth.isSuperAdmin.value" class="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_18rem]">
|
|
<UCard class="border border-default bg-default shadow-sm" :ui="{ header: 'px-4 py-3', body: 'px-4 py-3' }">
|
|
<template #header>
|
|
<div class="space-y-1">
|
|
<h2 class="text-base font-semibold text-highlighted">
|
|
Capacity settings
|
|
</h2>
|
|
<p class="text-xs text-muted sm:text-sm">
|
|
Configure the event capacity used to calculate sold and left inventory.
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<UForm :state="capacityForm" class="space-y-3" @submit="saveCapacity">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
<UFormField name="totalSeats" label="Total Seats" class="flex-1">
|
|
<UInput
|
|
v-model="capacityForm.totalSeats"
|
|
type="number"
|
|
inputmode="numeric"
|
|
min="0"
|
|
size="md"
|
|
class="w-full"
|
|
placeholder="Leave blank for no seat limit"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UButton
|
|
type="submit"
|
|
label="Save Capacity"
|
|
icon="i-lucide-save"
|
|
size="md"
|
|
:loading="savingCapacity"
|
|
class="justify-center sm:min-w-32"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between gap-3 border-t border-default pt-3">
|
|
<p class="text-xs text-muted sm:text-sm">
|
|
Last updated {{ settings.updatedAt ? formatDateTime(settings.updatedAt) : 'Not set yet' }}
|
|
</p>
|
|
</div>
|
|
</UForm>
|
|
</UCard>
|
|
|
|
<UCard class="border border-default bg-default shadow-sm" :ui="{ header: 'px-4 py-3', body: 'px-4 py-3' }">
|
|
<template #header>
|
|
<div class="space-y-1">
|
|
<h2 class="text-base font-semibold text-highlighted">
|
|
Pending reservations
|
|
</h2>
|
|
<p class="text-xs text-muted sm:text-sm">
|
|
Pending bookings are not counted as sold until the PIC confirms them.
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div class="rounded-lg border border-default bg-muted/20 p-3">
|
|
<p class="text-xs uppercase tracking-wide text-muted">
|
|
Pending bookings
|
|
</p>
|
|
<p class="mt-1 text-xl font-semibold text-highlighted">
|
|
{{ pendingCount }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-default bg-muted/20 p-3">
|
|
<p class="text-xs uppercase tracking-wide text-muted">
|
|
Pending seats
|
|
</p>
|
|
<p class="mt-1 text-xl font-semibold text-highlighted">
|
|
{{ summary.pendingSeats }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
|
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
<UCard
|
|
v-for="item in inventoryCards"
|
|
:key="item.label"
|
|
class="border border-default bg-default shadow-sm"
|
|
:ui="{ body: 'px-4 py-3' }"
|
|
>
|
|
<div class="space-y-0.5">
|
|
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
|
{{ item.label }}
|
|
</p>
|
|
<p class="text-2xl font-semibold leading-none text-highlighted">
|
|
{{ item.value }}
|
|
</p>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
|
|
<div class="grid gap-3 md:grid-cols-3">
|
|
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'px-4 py-3' }">
|
|
<div class="space-y-0.5">
|
|
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
|
Total Seats
|
|
</p>
|
|
<p class="text-2xl font-semibold leading-none text-highlighted">
|
|
{{ formatInventoryNumber(summary.totalSeats) }}
|
|
</p>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'px-4 py-3' }">
|
|
<div class="space-y-0.5">
|
|
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
|
Total bookings
|
|
</p>
|
|
<p class="text-2xl font-semibold leading-none text-highlighted">
|
|
{{ bookings.length }}
|
|
</p>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UCard class="border border-default bg-default shadow-sm" :ui="{ body: 'px-4 py-3' }">
|
|
<div class="space-y-0.5">
|
|
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
|
Booking status
|
|
</p>
|
|
<p class="text-sm font-medium text-default">
|
|
{{ confirmedCount }} confirmed, {{ pendingCount }} pending
|
|
</p>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
|
|
<UCard
|
|
class="border border-default bg-default shadow-sm"
|
|
:ui="{ header: 'px-4 py-3', body: 'p-0 sm:p-0' }"
|
|
>
|
|
<template #header>
|
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<UInput
|
|
v-model="searchQuery"
|
|
size="md"
|
|
class="w-full sm:w-72"
|
|
placeholder="Search guest, phone, PIC, ticket, or remark"
|
|
/>
|
|
|
|
<UButton
|
|
label="Refresh"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-refresh-cw"
|
|
size="md"
|
|
:loading="loadingBookings"
|
|
@click="refreshBookings"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="overflow-x-auto">
|
|
<UTable
|
|
:data="filteredBookings"
|
|
:columns="columns"
|
|
:loading="loadingBookings"
|
|
:empty="searchQuery.trim() ? 'No matching bookings found.' : 'No bookings available yet.'"
|
|
sticky="header"
|
|
caption="Bookings"
|
|
class="min-w-[1120px]"
|
|
>
|
|
<template #customerName-cell="{ row }">
|
|
<div class="min-w-0 space-y-0.5 py-0.5">
|
|
<div class="text-sm font-semibold leading-tight text-highlighted">
|
|
{{ row.original.customerName }}
|
|
</div>
|
|
<div class="text-xs text-muted">
|
|
{{ row.original.customerPhone }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #quantity-cell="{ row }">
|
|
<div class="space-y-0.5 py-0.5">
|
|
<div class="text-sm font-medium text-default">
|
|
{{ ticketLabel(row.original) }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #seatCount-cell="{ row }">
|
|
<div class="space-y-0.5 py-0.5">
|
|
<div class="text-sm font-medium text-default">
|
|
{{ row.original.seatCount }} seats
|
|
</div>
|
|
<div class="text-xs text-muted">
|
|
{{ formatBookingCurrency(row.original.totalPrice) }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #personInChargeName-cell="{ row }">
|
|
<div class="min-w-0 space-y-0.5 py-0.5">
|
|
<div class="text-sm font-medium text-default">
|
|
{{ row.original.personInChargeName }}
|
|
</div>
|
|
<div class="text-xs text-muted">
|
|
{{ row.original.personInChargePhoneNumber }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #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, row.original.statusLabel)"
|
|
:color="row.original.status === 'confirmed' ? 'success' : 'warning'"
|
|
variant="soft"
|
|
size="sm"
|
|
/>
|
|
<div class="text-xs text-muted">
|
|
{{ row.original.status === 'confirmed' ? `Confirmed ${formatDateTime(row.original.confirmedAt)}` : 'Awaiting PIC confirmation' }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #createdAt-cell="{ row }">
|
|
<span class="text-xs text-muted">
|
|
{{ formatDateTime(row.original.createdAt) }}
|
|
</span>
|
|
</template>
|
|
|
|
<template #actions-cell="{ row }">
|
|
<div class="flex flex-wrap justify-end gap-1.5 py-0.5">
|
|
<UButton
|
|
:to="confirmationPath(row.original)"
|
|
label="Open"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-external-link"
|
|
size="sm"
|
|
/>
|
|
<UButton
|
|
:to="receiptPath(row.original)"
|
|
label="Receipt"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-receipt"
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</UTable>
|
|
</div>
|
|
</UCard>
|
|
|
|
<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 } from '~~/shared/booking'
|
|
|
|
import {
|
|
formatBookingCurrency,
|
|
getBookingStatusLabel
|
|
} from '~~/shared/booking'
|
|
|
|
import { getErrorMessage } from '../../utils/errors'
|
|
import { formatDateTime } from '../../utils/formatters'
|
|
|
|
definePageMeta({
|
|
middleware: 'auth'
|
|
})
|
|
|
|
const toast = useToast()
|
|
const apiClient = useApiClient()
|
|
const auth = useAuth()
|
|
|
|
const bookings = ref<PublicBooking[]>([])
|
|
const loadingBookings = ref(false)
|
|
const savingCapacity = ref(false)
|
|
const savingRemark = ref(false)
|
|
const remarkModalOpen = ref(false)
|
|
const editingBooking = ref<PublicBooking | null>(null)
|
|
const searchQuery = ref('')
|
|
const settings = reactive<BookingCapacitySettings>({
|
|
totalSeats: null,
|
|
updatedAt: null
|
|
})
|
|
const summary = reactive<BookingInventorySummary>({
|
|
totalSeats: null,
|
|
soldSeats: 0,
|
|
pendingSeats: 0,
|
|
leftSeats: null
|
|
})
|
|
const capacityForm = reactive({
|
|
totalSeats: ''
|
|
})
|
|
const 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' }
|
|
]
|
|
|
|
const inventoryDescription = computed(() => {
|
|
return 'Every booking is converted into seats immediately, so sold and remaining capacity are tracked only in seats.'
|
|
})
|
|
|
|
const inventoryCards = computed(() => {
|
|
return [
|
|
{
|
|
label: 'Seats Sold',
|
|
value: String(summary.soldSeats)
|
|
},
|
|
{
|
|
label: 'Pending Seats',
|
|
value: String(summary.pendingSeats)
|
|
},
|
|
{
|
|
label: 'Seats Left',
|
|
value: formatInventoryNumber(summary.leftSeats)
|
|
}
|
|
]
|
|
})
|
|
|
|
const filteredBookings = computed(() => {
|
|
const keyword = searchQuery.value.trim().toLowerCase()
|
|
|
|
if (!keyword) {
|
|
return bookings.value
|
|
}
|
|
|
|
return bookings.value.filter((booking) => {
|
|
return [
|
|
booking.customerName,
|
|
booking.customerPhone,
|
|
booking.personInChargeName,
|
|
booking.personInChargePhoneNumber,
|
|
booking.ticketType,
|
|
booking.ticketLabel,
|
|
booking.remark || '',
|
|
booking.status
|
|
].some((value) => value.toLowerCase().includes(keyword))
|
|
})
|
|
})
|
|
|
|
const pendingCount = computed(() => {
|
|
return bookings.value.filter((booking) => booking.status === 'pending').length
|
|
})
|
|
|
|
const confirmedCount = computed(() => {
|
|
return bookings.value.filter((booking) => booking.status === 'confirmed').length
|
|
})
|
|
|
|
await refreshBookings()
|
|
|
|
function ticketLabel(booking: PublicBooking) {
|
|
return booking.ticketLabel || booking.ticketType.toUpperCase()
|
|
}
|
|
|
|
function remarkPreview(remark: string) {
|
|
const normalized = remark.trim()
|
|
return normalized.length > 120 ? `${normalized.slice(0, 120)}...` : normalized
|
|
}
|
|
|
|
function confirmationPath(booking: PublicBooking) {
|
|
return `/confirmation/${booking.confirmationToken}`
|
|
}
|
|
|
|
function receiptPath(booking: PublicBooking) {
|
|
return `/receipt/${booking.receiptToken}`
|
|
}
|
|
|
|
function formatInventoryNumber(value: number | null) {
|
|
return value === null ? 'Not set' : String(value)
|
|
}
|
|
|
|
function normalizeCapacityValue(value: string | number | null | undefined) {
|
|
if (typeof value === 'number') {
|
|
return Number.isFinite(value) ? String(value) : null
|
|
}
|
|
|
|
const normalized = String(value || '').trim()
|
|
return normalized || null
|
|
}
|
|
|
|
function syncCapacityForm(nextSettings: BookingCapacitySettings) {
|
|
capacityForm.totalSeats = nextSettings.totalSeats === null ? '' : String(nextSettings.totalSeats)
|
|
}
|
|
|
|
function applySettings(nextSettings: BookingCapacitySettings) {
|
|
settings.totalSeats = nextSettings.totalSeats
|
|
settings.updatedAt = nextSettings.updatedAt
|
|
syncCapacityForm(nextSettings)
|
|
}
|
|
|
|
function applySummary(nextSummary: BookingInventorySummary) {
|
|
summary.totalSeats = nextSummary.totalSeats
|
|
summary.soldSeats = nextSummary.soldSeats
|
|
summary.pendingSeats = nextSummary.pendingSeats
|
|
summary.leftSeats = nextSummary.leftSeats
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
loadingBookings.value = true
|
|
|
|
try {
|
|
const response = await apiClient<{
|
|
bookings: PublicBooking[]
|
|
settings: BookingCapacitySettings
|
|
summary: BookingInventorySummary
|
|
}>('/api/bookings')
|
|
|
|
bookings.value = response.bookings
|
|
applySettings(response.settings)
|
|
applySummary(response.summary)
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: 'Unable to load bookings',
|
|
description: getErrorMessage(error, 'The booking list could not be loaded.'),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
loadingBookings.value = false
|
|
}
|
|
}
|
|
|
|
async function saveCapacity(event: Event) {
|
|
event.preventDefault()
|
|
|
|
if (!auth.isSuperAdmin.value || savingCapacity.value) {
|
|
return
|
|
}
|
|
|
|
savingCapacity.value = true
|
|
|
|
try {
|
|
const response = await apiClient<{ settings: BookingCapacitySettings }>('/api/bookings/capacity', {
|
|
method: 'PATCH',
|
|
body: {
|
|
totalSeats: normalizeCapacityValue(capacityForm.totalSeats)
|
|
}
|
|
})
|
|
|
|
applySettings(response.settings)
|
|
await refreshBookings()
|
|
|
|
toast.add({
|
|
title: 'Capacity updated',
|
|
description: 'The booking inventory limits have been saved.',
|
|
color: 'success',
|
|
icon: 'i-lucide-check-circle-2'
|
|
})
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: 'Capacity update failed',
|
|
description: getErrorMessage(error, 'Unable to save the booking inventory limits.'),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
savingCapacity.value = false
|
|
}
|
|
}
|
|
|
|
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>
|