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.
This commit is contained in:
@@ -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">
|
||||
@@ -240,6 +240,30 @@
|
||||
</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
|
||||
@@ -283,6 +307,59 @@
|
||||
</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>
|
||||
@@ -309,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,
|
||||
@@ -323,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 +455,7 @@ const filteredBookings = computed(() => {
|
||||
booking.personInChargePhoneNumber,
|
||||
booking.ticketType,
|
||||
booking.ticketLabel,
|
||||
booking.remark || '',
|
||||
booking.status
|
||||
].some((value) => value.toLowerCase().includes(keyword))
|
||||
})
|
||||
@@ -389,6 +475,11 @@ 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}`
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user