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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user