feat(bookings): add transaction document uploads for bank payments

Add payment method selection (Cash/Bank) to booking details
Support uploading, downloading, and deleting transaction documents
Update database schema and API endpoints to handle file storage
This commit is contained in:
2026-05-09 12:56:32 +08:00
parent 3710216346
commit b64a2b4c1c
14 changed files with 888 additions and 31 deletions

View File

@@ -197,7 +197,7 @@
:empty="searchQuery.trim() ? 'No matching bookings found.' : 'No bookings available yet.'"
sticky="header"
caption="Bookings"
class="compact-table min-w-[1120px]"
class="compact-table min-w-[1280px]"
>
<template #customerName-cell="{ row }">
<div class="min-w-0 space-y-0.5 py-0.5">
@@ -240,6 +240,31 @@
</div>
</template>
<template #paymentMethod-cell="{ row }">
<div class="space-y-1 py-0.5">
<UBadge
:label="getPaymentMethodLabel(row.original.paymentMethod)"
:color="row.original.paymentMethod === 'bank' ? 'info' : 'neutral'"
variant="soft"
size="sm"
/>
<UButton
v-if="row.original.transactionDocument"
:to="row.original.transactionDocument.url"
:label="row.original.transactionDocument.originalName"
color="neutral"
variant="link"
icon="i-lucide-file-down"
size="xs"
class="-ms-2 max-w-44 justify-start truncate"
/>
<div v-else-if="row.original.paymentMethod === 'bank'" class="text-xs text-muted">
No document
</div>
</div>
</template>
<template #remark-cell="{ row }">
<div class="max-w-64 space-y-1 py-0.5">
<p
@@ -331,9 +356,9 @@
<UModal
v-model:open="detailsModalOpen"
title="Edit Booking"
description="Update guest details, ticket selection, quantity, and internal remark."
:dismissible="!savingDetails"
:close="!savingDetails"
description="Update guest details, payment, ticket selection, quantity, and handling note."
:dismissible="!savingDetails && !deletingTransactionDocument"
:close="!savingDetails && !deletingTransactionDocument"
:content="{ class: 'sm:max-w-2xl' }"
>
<template #body>
@@ -390,7 +415,7 @@
v-model="detailsForm.quantity"
:min="1"
:step="1"
:disabled="savingDetails"
:disabled="savingDetails || deletingTransactionDocument"
size="lg"
class="w-full"
/>
@@ -412,6 +437,76 @@
/>
</UFormField>
<UFormField name="paymentMethod" label="Payment Method" required>
<URadioGroup
v-model="detailsForm.paymentMethod"
orientation="horizontal"
variant="card"
indicator="hidden"
:items="paymentMethodItems"
:disabled="savingDetails"
:ui="{
fieldset: 'grid grid-cols-2 gap-3',
item: 'rounded-lg border border-default bg-default p-3 transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}"
/>
</UFormField>
<div v-if="detailsForm.paymentMethod === 'bank'" class="space-y-3">
<div
v-if="detailsBooking?.transactionDocument"
class="surface-panel flex flex-col gap-2 rounded-lg px-3 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-highlighted">
{{ detailsBooking.transactionDocument.originalName }}
</p>
<p class="text-xs text-muted">
{{ formatFileSize(detailsBooking.transactionDocument.size) }} - {{ formatDateTime(detailsBooking.transactionDocument.uploadedAt) }}
</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<UButton
:to="detailsBooking.transactionDocument.url"
label="Download"
color="neutral"
variant="outline"
icon="i-lucide-file-down"
size="sm"
class="justify-center"
:disabled="deletingTransactionDocument"
/>
<UButton
label="Delete"
color="error"
variant="outline"
icon="i-lucide-trash-2"
size="sm"
class="justify-center"
:loading="deletingTransactionDocument"
:disabled="savingDetails"
@click="deleteTransactionDocumentForDetails"
/>
</div>
</div>
<UFormField name="transactionDocument" label="Transaction Document">
<UInput
:key="transactionDocumentInputKey"
type="file"
:accept="transactionDocumentAccept"
:disabled="savingDetails || deletingTransactionDocument"
size="lg"
class="w-full"
@change="onTransactionDocumentChange"
/>
<template #help>
{{ selectedTransactionDocumentName || 'PDF, JPG, PNG, WEBP, HEIC, or HEIF - max 10MB.' }}
</template>
</UFormField>
</div>
<div class="surface-panel rounded-lg px-4 py-3">
<div class="flex items-center justify-between gap-4">
<span class="text-sm font-medium text-muted">Updated total</span>
@@ -433,7 +528,7 @@
:maxlength="remarkLimit"
autoresize
class="w-full"
placeholder="Internal handling note"
placeholder="Handling note"
/>
<template #help>
{{ detailsForm.remark.length }}/{{ remarkLimit }}
@@ -449,7 +544,7 @@
color="neutral"
variant="ghost"
class="justify-center"
:disabled="savingDetails"
:disabled="savingDetails || deletingTransactionDocument"
@click="closeBookingEditor"
/>
<UButton
@@ -459,6 +554,7 @@
icon="i-lucide-save"
class="justify-center"
:loading="savingDetails"
:disabled="deletingTransactionDocument"
/>
</div>
</template>
@@ -487,7 +583,7 @@
:maxlength="remarkLimit"
autoresize
class="w-full"
placeholder="Internal handling note"
placeholder="Handling note"
/>
<template #help>
{{ remarkForm.remark.length }}/{{ remarkLimit }}
@@ -580,6 +676,7 @@ import type {
BookingMode,
CancelBookingConfirmationResponse,
DeleteBookingResponse,
PaymentMethod,
PublicBooking,
PublicBookingConfig,
TicketCatalogItem,
@@ -626,6 +723,7 @@ const savingCapacity = ref(false)
const savingDetails = ref(false)
const savingRemark = ref(false)
const savingTransfer = ref(false)
const deletingTransactionDocument = ref(false)
const cancellingBookingId = ref<string | null>(null)
const deletingBookingId = ref<string | null>(null)
const detailsModalOpen = ref(false)
@@ -654,8 +752,11 @@ const detailsForm = reactive({
bookingMode: '' as BookingMode,
quantity: 1,
ticketType: '' as TicketType,
paymentMethod: 'cash' as PaymentMethod,
remark: ''
})
const detailsTransactionDocumentFile = ref<File | null>(null)
const transactionDocumentInputKey = ref(0)
const remarkForm = reactive({
remark: ''
})
@@ -663,6 +764,19 @@ const transferForm = reactive({
personInChargeId: ''
})
const remarkLimit = 1000
const transactionDocumentLimit = 10 * 1024 * 1024
const transactionDocumentAccept = '.pdf,.jpg,.jpeg,.png,.webp,.heic,.heif,application/pdf,image/jpeg,image/png,image/webp,image/heic,image/heif'
const paymentMethodItems: { label: string, value: PaymentMethod }[] = [
{
label: 'Cash',
value: 'cash'
},
{
label: 'Bank',
value: 'bank'
}
]
const bookingModeItems = computed(() => {
return bookingConfig.value?.bookingModes.map((mode: BookingModeOption) => ({
@@ -689,12 +803,14 @@ const selectedDetailsTicket = computed(() => {
const detailsSeatCount = computed(() => getSeatCount(selectedDetailsBookingMode.value, detailsForm.quantity))
const detailsTotalFormatted = computed(() => formatBookingCurrency(detailsSeatCount.value * (selectedDetailsTicket.value?.price ?? 0)))
const selectedTransactionDocumentName = computed(() => detailsTransactionDocumentFile.value?.name || '')
const columns = [
{ accessorKey: 'customerName', header: 'Guest' },
{ accessorKey: 'quantity', header: 'Booking' },
{ accessorKey: 'seatCount', header: 'Seats / Total' },
{ accessorKey: 'personInChargeName', header: 'PIC' },
{ accessorKey: 'paymentMethod', header: 'Payment' },
{ accessorKey: 'remark', header: 'Remark' },
{ id: 'status', header: 'Status' },
{ accessorKey: 'createdAt', header: 'Submitted' },
@@ -737,6 +853,8 @@ const filteredBookings = computed(() => {
booking.personInChargePhoneNumber,
booking.ticketType,
booking.ticketLabel,
getPaymentMethodLabel(booking.paymentMethod),
booking.transactionDocument?.originalName || '',
booking.remark || '',
booking.status
].some((value) => value.toLowerCase().includes(keyword))
@@ -772,6 +890,18 @@ function ticketLabel(booking: PublicBooking) {
return booking.ticketLabel || booking.ticketType.toUpperCase()
}
function getPaymentMethodLabel(paymentMethod: PaymentMethod) {
return paymentMethod === 'bank' ? 'Bank' : 'Cash'
}
function formatFileSize(size: number) {
if (size >= 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
return `${Math.max(Math.round(size / 1024), 1)} KB`
}
function remarkPreview(remark: string) {
const normalized = remark.trim()
return normalized.length > 120 ? `${normalized.slice(0, 120)}...` : normalized
@@ -845,12 +975,15 @@ function openBookingEditor(booking: PublicBooking) {
detailsForm.bookingMode = booking.bookingMode
detailsForm.quantity = booking.quantity
detailsForm.ticketType = booking.ticketType
detailsForm.paymentMethod = booking.paymentMethod
detailsForm.remark = booking.remark || ''
detailsTransactionDocumentFile.value = null
transactionDocumentInputKey.value += 1
detailsModalOpen.value = true
}
function closeBookingEditor() {
if (savingDetails.value) {
if (savingDetails.value || deletingTransactionDocument.value) {
return
}
@@ -861,7 +994,10 @@ function closeBookingEditor() {
detailsForm.bookingMode = ''
detailsForm.quantity = 1
detailsForm.ticketType = ''
detailsForm.paymentMethod = 'cash'
detailsForm.remark = ''
detailsTransactionDocumentFile.value = null
transactionDocumentInputKey.value += 1
}
function validateBookingDetails(state: typeof detailsForm): FormError[] {
@@ -889,6 +1025,14 @@ function validateBookingDetails(state: typeof detailsForm): FormError[] {
errors.push({ name: 'ticketType', message: 'Please select a ticket category.' })
}
if (state.paymentMethod !== 'cash' && state.paymentMethod !== 'bank') {
errors.push({ name: 'paymentMethod', message: 'Please select Cash or Bank.' })
}
if (detailsTransactionDocumentFile.value && detailsTransactionDocumentFile.value.size > transactionDocumentLimit) {
errors.push({ name: 'transactionDocument', message: 'Transaction document must be 10MB or smaller.' })
}
return errors
}
@@ -958,12 +1102,6 @@ function removeBooking(bookingId: string) {
bookings.value = bookings.value.filter((booking) => booking.id !== bookingId)
}
function applyCancelledConfirmationToSummary(booking: PublicBooking) {
summary.soldSeats = Math.max(summary.soldSeats - booking.seatCount, 0)
summary.pendingSeats += booking.seatCount
summary.leftSeats = summary.totalSeats === null ? null : Math.max(summary.totalSeats - summary.soldSeats, 0)
}
function openRemarkEditor(booking: PublicBooking) {
editingBooking.value = booking
remarkForm.remark = booking.remark || ''
@@ -1002,6 +1140,53 @@ function closeTransferEditor() {
transferForm.personInChargeId = ''
}
function onTransactionDocumentChange(event: Event) {
const input = event.target as HTMLInputElement
detailsTransactionDocumentFile.value = input.files?.[0] ?? null
}
async function deleteTransactionDocumentForDetails() {
const booking = detailsBooking.value
if (!booking?.transactionDocument || deletingTransactionDocument.value || savingDetails.value) {
return
}
if (import.meta.client && !window.confirm(`Delete transaction document for ${booking.customerName}?`)) {
return
}
deletingTransactionDocument.value = true
try {
const response = await apiClient<UpdateBookingDetailsResponse>(`/api/bookings/${booking.id}/transaction-document`, {
method: 'DELETE'
})
replaceBooking(response.booking)
detailsBooking.value = response.booking
detailsTransactionDocumentFile.value = null
transactionDocumentInputKey.value += 1
await refreshBookings()
toast.add({
title: 'Document deleted',
description: 'The transaction document has been removed.',
color: 'success',
icon: 'i-lucide-trash-2'
})
} catch (error: any) {
toast.add({
title: 'Delete failed',
description: getErrorMessage(error, 'Unable to delete the transaction document.'),
color: 'error',
icon: 'i-lucide-circle-alert'
})
} finally {
deletingTransactionDocument.value = false
}
}
async function refreshBookings() {
if (loadingBookings.value) {
return
@@ -1058,7 +1243,7 @@ async function saveBookingDetails(event: FormSubmitEvent<typeof detailsForm>) {
event.preventDefault()
if (!booking || savingDetails.value) {
if (!booking || savingDetails.value || deletingTransactionDocument.value) {
return
}
@@ -1073,14 +1258,31 @@ async function saveBookingDetails(event: FormSubmitEvent<typeof detailsForm>) {
bookingMode: detailsForm.bookingMode,
quantity: detailsForm.quantity,
ticketType: detailsForm.ticketType,
paymentMethod: detailsForm.paymentMethod,
remark: detailsForm.remark
}
})
replaceBooking(response.booking)
let updatedBooking = response.booking
if (detailsForm.paymentMethod === 'bank' && detailsTransactionDocumentFile.value) {
const formData = new FormData()
formData.append('document', detailsTransactionDocumentFile.value)
const uploadResponse = await apiClient<UpdateBookingDetailsResponse>(`/api/bookings/${booking.id}/transaction-document`, {
method: 'POST',
body: formData
})
updatedBooking = uploadResponse.booking
}
replaceBooking(updatedBooking)
await refreshBookings()
detailsModalOpen.value = false
detailsBooking.value = null
detailsTransactionDocumentFile.value = null
transactionDocumentInputKey.value += 1
toast.add({
title: 'Booking updated',
@@ -1280,10 +1482,7 @@ async function cancelBookingConfirmation(booking: PublicBooking) {
})
replaceBooking(response.booking)
if (!response.alreadyPending) {
applyCancelledConfirmationToSummary(booking)
}
await refreshBookings()
toast.add({
title: response.alreadyPending ? 'Booking already pending' : 'Confirmation cancelled',