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"
|
v-model="searchQuery"
|
||||||
size="md"
|
size="md"
|
||||||
class="w-full sm:w-72"
|
class="w-full sm:w-72"
|
||||||
placeholder="Search guest, phone, PIC, or ticket"
|
placeholder="Search guest, phone, PIC, ticket, or remark"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
:empty="searchQuery.trim() ? 'No matching bookings found.' : 'No bookings available yet.'"
|
:empty="searchQuery.trim() ? 'No matching bookings found.' : 'No bookings available yet.'"
|
||||||
sticky="header"
|
sticky="header"
|
||||||
caption="Bookings"
|
caption="Bookings"
|
||||||
class="min-w-[980px]"
|
class="min-w-[1120px]"
|
||||||
>
|
>
|
||||||
<template #customerName-cell="{ row }">
|
<template #customerName-cell="{ row }">
|
||||||
<div class="min-w-0 space-y-0.5 py-0.5">
|
<div class="min-w-0 space-y-0.5 py-0.5">
|
||||||
@@ -240,6 +240,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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 }">
|
<template #status-cell="{ row }">
|
||||||
<div class="space-y-1 py-0.5">
|
<div class="space-y-1 py-0.5">
|
||||||
<UBadge
|
<UBadge
|
||||||
@@ -283,6 +307,59 @@
|
|||||||
</UTable>
|
</UTable>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</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>
|
</div>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -309,6 +386,9 @@ const auth = useAuth()
|
|||||||
const bookings = ref<PublicBooking[]>([])
|
const bookings = ref<PublicBooking[]>([])
|
||||||
const loadingBookings = ref(false)
|
const loadingBookings = ref(false)
|
||||||
const savingCapacity = ref(false)
|
const savingCapacity = ref(false)
|
||||||
|
const savingRemark = ref(false)
|
||||||
|
const remarkModalOpen = ref(false)
|
||||||
|
const editingBooking = ref<PublicBooking | null>(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const settings = reactive<BookingCapacitySettings>({
|
const settings = reactive<BookingCapacitySettings>({
|
||||||
totalSeats: null,
|
totalSeats: null,
|
||||||
@@ -323,12 +403,17 @@ const summary = reactive<BookingInventorySummary>({
|
|||||||
const capacityForm = reactive({
|
const capacityForm = reactive({
|
||||||
totalSeats: ''
|
totalSeats: ''
|
||||||
})
|
})
|
||||||
|
const remarkForm = reactive({
|
||||||
|
remark: ''
|
||||||
|
})
|
||||||
|
const remarkLimit = 1000
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ accessorKey: 'customerName', header: 'Guest' },
|
{ accessorKey: 'customerName', header: 'Guest' },
|
||||||
{ accessorKey: 'quantity', header: 'Booking' },
|
{ accessorKey: 'quantity', header: 'Booking' },
|
||||||
{ accessorKey: 'seatCount', header: 'Seats / Total' },
|
{ accessorKey: 'seatCount', header: 'Seats / Total' },
|
||||||
{ accessorKey: 'personInChargeName', header: 'PIC' },
|
{ accessorKey: 'personInChargeName', header: 'PIC' },
|
||||||
|
{ accessorKey: 'remark', header: 'Remark' },
|
||||||
{ id: 'status', header: 'Status' },
|
{ id: 'status', header: 'Status' },
|
||||||
{ accessorKey: 'createdAt', header: 'Submitted' },
|
{ accessorKey: 'createdAt', header: 'Submitted' },
|
||||||
{ id: 'actions', header: 'Actions' }
|
{ id: 'actions', header: 'Actions' }
|
||||||
@@ -370,6 +455,7 @@ const filteredBookings = computed(() => {
|
|||||||
booking.personInChargePhoneNumber,
|
booking.personInChargePhoneNumber,
|
||||||
booking.ticketType,
|
booking.ticketType,
|
||||||
booking.ticketLabel,
|
booking.ticketLabel,
|
||||||
|
booking.remark || '',
|
||||||
booking.status
|
booking.status
|
||||||
].some((value) => value.toLowerCase().includes(keyword))
|
].some((value) => value.toLowerCase().includes(keyword))
|
||||||
})
|
})
|
||||||
@@ -389,6 +475,11 @@ function ticketLabel(booking: PublicBooking) {
|
|||||||
return booking.ticketLabel || booking.ticketType.toUpperCase()
|
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) {
|
function confirmationPath(booking: PublicBooking) {
|
||||||
return `/confirmation/${booking.confirmationToken}`
|
return `/confirmation/${booking.confirmationToken}`
|
||||||
}
|
}
|
||||||
@@ -427,6 +518,32 @@ function applySummary(nextSummary: BookingInventorySummary) {
|
|||||||
summary.leftSeats = nextSummary.leftSeats
|
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() {
|
async function refreshBookings() {
|
||||||
if (loadingBookings.value) {
|
if (loadingBookings.value) {
|
||||||
return
|
return
|
||||||
@@ -493,4 +610,44 @@ async function saveCapacity(event: Event) {
|
|||||||
savingCapacity.value = false
|
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>
|
</script>
|
||||||
|
|||||||
29
server/api/bookings/[id]/remark.patch.ts
Normal file
29
server/api/bookings/[id]/remark.patch.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { PublicBooking } from '~~/shared/booking'
|
||||||
|
|
||||||
|
import { requireAuth } from '../../../utils/auth'
|
||||||
|
import { updateBookingRemark } from '../../../utils/booking-repository'
|
||||||
|
import { parseBookingRemarkInput } from '../../../utils/bookings'
|
||||||
|
import { getRequiredRouteParam, httpError } from '../../../utils/http'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<{ booking: PublicBooking }> => {
|
||||||
|
const auth = await requireAuth(event)
|
||||||
|
const bookingId = getRequiredRouteParam(event, 'id', 'Booking ID')
|
||||||
|
const body = await readBody<{
|
||||||
|
remark?: string | null
|
||||||
|
}>(event)
|
||||||
|
|
||||||
|
const input = parseBookingRemarkInput(body)
|
||||||
|
const booking = await updateBookingRemark({
|
||||||
|
bookingId,
|
||||||
|
remark: input.remark,
|
||||||
|
personInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
httpError(404, 'Booking not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
booking
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -47,6 +47,7 @@ type DbBookingRow = {
|
|||||||
person_in_charge_id: string
|
person_in_charge_id: string
|
||||||
person_in_charge_name: string | null
|
person_in_charge_name: string | null
|
||||||
person_in_charge_phone_number: string | null
|
person_in_charge_phone_number: string | null
|
||||||
|
remark?: string | null
|
||||||
status: BookingStatus | string
|
status: BookingStatus | string
|
||||||
status_label: string | null
|
status_label: string | null
|
||||||
created_at: Date | string
|
created_at: Date | string
|
||||||
@@ -139,6 +140,7 @@ function bookingSelectColumns(sql: any) {
|
|||||||
bookings.person_in_charge_id,
|
bookings.person_in_charge_id,
|
||||||
coalesce(users.full_name, bookings.person_in_charge_name) as person_in_charge_name,
|
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,
|
coalesce(users.phone_number, bookings.person_in_charge_phone_number) as person_in_charge_phone_number,
|
||||||
|
bookings.remark,
|
||||||
bookings.status,
|
bookings.status,
|
||||||
booking_statuses.label as status_label,
|
booking_statuses.label as status_label,
|
||||||
bookings.created_at,
|
bookings.created_at,
|
||||||
@@ -231,6 +233,7 @@ function mapBooking(row: DbBookingRow): PublicBooking {
|
|||||||
personInChargeId: row.person_in_charge_id,
|
personInChargeId: row.person_in_charge_id,
|
||||||
personInChargeName: row.person_in_charge_name || '',
|
personInChargeName: row.person_in_charge_name || '',
|
||||||
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
|
personInChargePhoneNumber: row.person_in_charge_phone_number || '',
|
||||||
|
remark: row.remark || null,
|
||||||
status,
|
status,
|
||||||
statusLabel: row.status_label || getBookingStatusLabel(status),
|
statusLabel: row.status_label || getBookingStatusLabel(status),
|
||||||
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
createdAt: toIsoString(row.created_at) ?? new Date().toISOString(),
|
||||||
@@ -493,6 +496,7 @@ export async function createBooking(input: {
|
|||||||
unit_price,
|
unit_price,
|
||||||
total_price,
|
total_price,
|
||||||
person_in_charge_id,
|
person_in_charge_id,
|
||||||
|
remark,
|
||||||
status
|
status
|
||||||
)
|
)
|
||||||
values (
|
values (
|
||||||
@@ -511,6 +515,7 @@ export async function createBooking(input: {
|
|||||||
${input.unitPrice},
|
${input.unitPrice},
|
||||||
${input.totalPrice},
|
${input.totalPrice},
|
||||||
${input.personInChargeId},
|
${input.personInChargeId},
|
||||||
|
null,
|
||||||
'pending'
|
'pending'
|
||||||
)
|
)
|
||||||
returning *
|
returning *
|
||||||
@@ -588,6 +593,50 @@ export async function listBookings(options?: {
|
|||||||
return rows.map(mapBooking)
|
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[]> {
|
export async function listBookingSeats(bookingId: string): Promise<PublicBookingSeat[]> {
|
||||||
await ensureDatabaseReady()
|
await ensureDatabaseReady()
|
||||||
const sql = getSqlClient()
|
const sql = getSqlClient()
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ export function parseCreateBookingInput(body: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
|
||||||
return [
|
return [
|
||||||
`I'd like to book tickets for the ${booking.event.title}.`,
|
`I'd like to book tickets for the ${booking.event.title}.`,
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ async function initializeDatabase() {
|
|||||||
person_in_charge_id text not null references users(id) on delete restrict,
|
person_in_charge_id text not null references users(id) on delete restrict,
|
||||||
person_in_charge_name text not null,
|
person_in_charge_name text not null,
|
||||||
person_in_charge_phone_number text not null,
|
person_in_charge_phone_number text not null,
|
||||||
|
remark text,
|
||||||
status text not null default 'pending',
|
status text not null default 'pending',
|
||||||
confirmed_at timestamptz,
|
confirmed_at timestamptz,
|
||||||
created_at timestamptz not null default now(),
|
created_at timestamptz not null default now(),
|
||||||
@@ -287,6 +288,11 @@ async function initializeDatabase() {
|
|||||||
add column if not exists ticket_type_id text
|
add column if not exists ticket_type_id text
|
||||||
`
|
`
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
alter table bookings
|
||||||
|
add column if not exists remark text
|
||||||
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
create unique index if not exists bookings_receipt_token_idx
|
create unique index if not exists bookings_receipt_token_idx
|
||||||
on bookings (receipt_token)
|
on bookings (receipt_token)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface PublicBooking {
|
|||||||
personInChargeId: string
|
personInChargeId: string
|
||||||
personInChargeName: string
|
personInChargeName: string
|
||||||
personInChargePhoneNumber: string
|
personInChargePhoneNumber: string
|
||||||
|
remark: string | null
|
||||||
status: BookingStatus
|
status: BookingStatus
|
||||||
statusLabel: string
|
statusLabel: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
|||||||
Reference in New Issue
Block a user