From 30753fdc611e54a91c044d556dbc90f9bcc7cd78 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Mon, 4 May 2026 11:59:41 +0800 Subject: [PATCH] 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. --- app/pages/bookings/index.vue | 161 ++++++++++++++++++++++- server/api/bookings/[id]/remark.patch.ts | 29 ++++ server/utils/booking-repository.ts | 49 +++++++ server/utils/bookings.ts | 12 ++ server/utils/db-init.ts | 6 + shared/booking.ts | 1 + 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 server/api/bookings/[id]/remark.patch.ts diff --git a/app/pages/bookings/index.vue b/app/pages/bookings/index.vue index e6dc691..16096c1 100644 --- a/app/pages/bookings/index.vue +++ b/app/pages/bookings/index.vue @@ -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" /> + + @@ -309,6 +386,9 @@ const auth = useAuth() const bookings = ref([]) const loadingBookings = ref(false) const savingCapacity = ref(false) +const savingRemark = ref(false) +const remarkModalOpen = ref(false) +const editingBooking = ref(null) const searchQuery = ref('') const settings = reactive({ totalSeats: null, @@ -323,12 +403,17 @@ const summary = reactive({ 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 + } +} diff --git a/server/api/bookings/[id]/remark.patch.ts b/server/api/bookings/[id]/remark.patch.ts new file mode 100644 index 0000000..dd64520 --- /dev/null +++ b/server/api/bookings/[id]/remark.patch.ts @@ -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 + } +}) diff --git a/server/utils/booking-repository.ts b/server/utils/booking-repository.ts index c144e7a..099f5e2 100644 --- a/server/utils/booking-repository.ts +++ b/server/utils/booking-repository.ts @@ -47,6 +47,7 @@ type DbBookingRow = { person_in_charge_id: 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 @@ -139,6 +140,7 @@ function bookingSelectColumns(sql: any) { 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, @@ -231,6 +233,7 @@ function mapBooking(row: DbBookingRow): PublicBooking { personInChargeId: row.person_in_charge_id, 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(), @@ -493,6 +496,7 @@ export async function createBooking(input: { unit_price, total_price, person_in_charge_id, + remark, status ) values ( @@ -511,6 +515,7 @@ export async function createBooking(input: { ${input.unitPrice}, ${input.totalPrice}, ${input.personInChargeId}, + null, 'pending' ) returning * @@ -588,6 +593,50 @@ export async function listBookings(options?: { return rows.map(mapBooking) } +export async function updateBookingRemark(input: { + bookingId: string + personInChargeId?: string + remark: string | null +}): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const rows = input.personInChargeId + ? await sql` + 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` + 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 { await ensureDatabaseReady() const sql = getSqlClient() diff --git a/server/utils/bookings.ts b/server/utils/bookings.ts index 1e74761..a7f0e4e 100644 --- a/server/utils/bookings.ts +++ b/server/utils/bookings.ts @@ -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) { return [ `I'd like to book tickets for the ${booking.event.title}.`, diff --git a/server/utils/db-init.ts b/server/utils/db-init.ts index 0548eae..cadf478 100644 --- a/server/utils/db-init.ts +++ b/server/utils/db-init.ts @@ -260,6 +260,7 @@ async function initializeDatabase() { 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, + remark text, status text not null default 'pending', confirmed_at timestamptz, created_at timestamptz not null default now(), @@ -287,6 +288,11 @@ async function initializeDatabase() { 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) diff --git a/shared/booking.ts b/shared/booking.ts index 6ee1b06..3c4e816 100644 --- a/shared/booking.ts +++ b/shared/booking.ts @@ -55,6 +55,7 @@ export interface PublicBooking { personInChargeId: string personInChargeName: string personInChargePhoneNumber: string + remark: string | null status: BookingStatus statusLabel: string createdAt: string