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
1507 lines
47 KiB
Vue
1507 lines
47 KiB
Vue
<template>
|
|
<UContainer class="page-shell">
|
|
<div class="space-y-5">
|
|
<div class="page-header lg:flex-row lg:items-end lg:justify-between">
|
|
<div class="space-y-1">
|
|
<UBadge label="Bookings" color="primary" variant="soft" size="sm" class="page-eyebrow" />
|
|
<h1 class="page-title">
|
|
Booking list
|
|
</h1>
|
|
<p class="page-description">
|
|
{{ auth.isSuperAdmin.value ? 'All submitted bookings across every PIC.' : 'Bookings assigned to you as PIC.' }}
|
|
</p>
|
|
</div>
|
|
|
|
<UBadge
|
|
:label="auth.isSuperAdmin.value ? 'Super Admin View' : 'Staff View'"
|
|
:color="auth.isSuperAdmin.value ? 'primary' : 'neutral'"
|
|
variant="soft"
|
|
size="sm"
|
|
class="rounded-full"
|
|
/>
|
|
</div>
|
|
|
|
<UAlert
|
|
title="Inventory counting rule"
|
|
:description="inventoryDescription"
|
|
color="info"
|
|
icon="i-lucide-info"
|
|
variant="soft"
|
|
:ui="{ root: 'py-3', title: 'text-sm font-medium', description: 'text-xs sm:text-sm' }"
|
|
/>
|
|
|
|
<div v-if="auth.isSuperAdmin.value" class="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_18rem]">
|
|
<UCard class="surface-card rounded-lg" :ui="{ header: 'px-4 py-3', body: 'px-4 py-3' }">
|
|
<template #header>
|
|
<div class="space-y-1">
|
|
<h2 class="text-base font-semibold text-highlighted">
|
|
Capacity settings
|
|
</h2>
|
|
<p class="text-xs text-muted sm:text-sm">
|
|
Configure the event capacity used to calculate sold and left inventory.
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<UForm :state="capacityForm" class="space-y-3" @submit="saveCapacity">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
<UFormField name="totalSeats" label="Total Seats" class="flex-1">
|
|
<UInput
|
|
v-model="capacityForm.totalSeats"
|
|
type="number"
|
|
inputmode="numeric"
|
|
min="0"
|
|
size="md"
|
|
class="w-full"
|
|
placeholder="Leave blank for no seat limit"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UButton
|
|
type="submit"
|
|
label="Save Capacity"
|
|
icon="i-lucide-save"
|
|
size="md"
|
|
:loading="savingCapacity"
|
|
class="justify-center sm:min-w-32"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between gap-3 border-t border-default pt-3">
|
|
<p class="text-xs text-muted sm:text-sm">
|
|
Last updated {{ settings.updatedAt ? formatDateTime(settings.updatedAt) : 'Not set yet' }}
|
|
</p>
|
|
</div>
|
|
</UForm>
|
|
</UCard>
|
|
|
|
<UCard class="surface-card rounded-lg" :ui="{ header: 'px-4 py-3', body: 'px-4 py-3' }">
|
|
<template #header>
|
|
<div class="space-y-1">
|
|
<h2 class="text-base font-semibold text-highlighted">
|
|
Pending reservations
|
|
</h2>
|
|
<p class="text-xs text-muted sm:text-sm">
|
|
Pending bookings are not counted as sold until the PIC confirms them.
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div class="surface-panel rounded-lg p-3">
|
|
<p class="text-xs uppercase tracking-wide text-muted">
|
|
Pending bookings
|
|
</p>
|
|
<p class="mt-1 text-xl font-semibold text-highlighted">
|
|
{{ pendingCount }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="surface-panel rounded-lg p-3">
|
|
<p class="text-xs uppercase tracking-wide text-muted">
|
|
Pending seats
|
|
</p>
|
|
<p class="mt-1 text-xl font-semibold text-highlighted">
|
|
{{ summary.pendingSeats }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
|
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
<UCard
|
|
v-for="item in inventoryCards"
|
|
:key="item.label"
|
|
class="metric-card rounded-lg"
|
|
:ui="{ body: 'px-4 py-3' }"
|
|
>
|
|
<div class="space-y-0.5">
|
|
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
|
{{ item.label }}
|
|
</p>
|
|
<p class="text-2xl font-semibold leading-none text-highlighted">
|
|
{{ item.value }}
|
|
</p>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
|
|
<div class="grid gap-3 md:grid-cols-3">
|
|
<UCard class="metric-card rounded-lg" :ui="{ body: 'px-4 py-3' }">
|
|
<div class="space-y-0.5">
|
|
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
|
Total Seats
|
|
</p>
|
|
<p class="text-2xl font-semibold leading-none text-highlighted">
|
|
{{ formatInventoryNumber(summary.totalSeats) }}
|
|
</p>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UCard class="metric-card rounded-lg" :ui="{ body: 'px-4 py-3' }">
|
|
<div class="space-y-0.5">
|
|
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
|
Total bookings
|
|
</p>
|
|
<p class="text-2xl font-semibold leading-none text-highlighted">
|
|
{{ bookings.length }}
|
|
</p>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UCard class="metric-card rounded-lg" :ui="{ body: 'px-4 py-3' }">
|
|
<div class="space-y-0.5">
|
|
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
|
Booking status
|
|
</p>
|
|
<p class="text-sm font-medium text-default">
|
|
{{ confirmedCount }} confirmed, {{ pendingCount }} pending
|
|
</p>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
|
|
<UCard
|
|
class="surface-card overflow-hidden rounded-lg"
|
|
:ui="{ header: 'px-4 py-3', body: 'p-0 sm:p-0' }"
|
|
>
|
|
<template #header>
|
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<UInput
|
|
v-model="searchQuery"
|
|
size="md"
|
|
class="w-full sm:w-72"
|
|
placeholder="Search guest, phone, PIC, ticket, or remark"
|
|
/>
|
|
|
|
<UButton
|
|
label="Refresh"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-refresh-cw"
|
|
size="md"
|
|
:loading="loadingBookings"
|
|
@click="refreshBookings"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="overflow-x-auto">
|
|
<UTable
|
|
:data="filteredBookings"
|
|
:columns="columns"
|
|
:loading="loadingBookings"
|
|
:empty="searchQuery.trim() ? 'No matching bookings found.' : 'No bookings available yet.'"
|
|
sticky="header"
|
|
caption="Bookings"
|
|
class="compact-table min-w-[1280px]"
|
|
>
|
|
<template #customerName-cell="{ row }">
|
|
<div class="min-w-0 space-y-0.5 py-0.5">
|
|
<div class="text-sm font-semibold leading-tight text-highlighted">
|
|
{{ row.original.customerName }}
|
|
</div>
|
|
<div class="text-xs text-muted">
|
|
{{ row.original.customerPhone }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #quantity-cell="{ row }">
|
|
<div class="space-y-0.5 py-0.5">
|
|
<div class="text-sm font-medium text-default">
|
|
{{ ticketLabel(row.original) }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #seatCount-cell="{ row }">
|
|
<div class="space-y-0.5 py-0.5">
|
|
<div class="text-sm font-medium text-default">
|
|
{{ row.original.seatCount }} seats
|
|
</div>
|
|
<div class="text-xs text-muted">
|
|
{{ formatBookingCurrency(row.original.totalPrice) }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #personInChargeName-cell="{ row }">
|
|
<div class="min-w-0 space-y-0.5 py-0.5">
|
|
<div class="text-sm font-medium text-default">
|
|
{{ row.original.personInChargeName }}
|
|
</div>
|
|
<div class="text-xs text-muted">
|
|
{{ row.original.personInChargePhoneNumber }}
|
|
</div>
|
|
</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
|
|
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
|
|
:label="getBookingStatusLabel(row.original.status, row.original.statusLabel)"
|
|
:color="row.original.status === 'confirmed' ? 'success' : 'warning'"
|
|
variant="soft"
|
|
size="sm"
|
|
/>
|
|
<div class="text-xs text-muted">
|
|
{{ row.original.status === 'confirmed' ? `Confirmed ${formatDateTime(row.original.confirmedAt)}` : 'Awaiting PIC confirmation' }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #createdAt-cell="{ row }">
|
|
<span class="text-xs text-muted">
|
|
{{ formatDateTime(row.original.createdAt) }}
|
|
</span>
|
|
</template>
|
|
|
|
<template #actions-cell="{ row }">
|
|
<div class="flex flex-nowrap items-center justify-end gap-1 py-0.5 whitespace-nowrap">
|
|
<UTooltip text="Open booking">
|
|
<UButton
|
|
:to="confirmationPath(row.original)"
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-external-link"
|
|
size="sm"
|
|
class="min-w-9 justify-center"
|
|
aria-label="Open booking"
|
|
/>
|
|
</UTooltip>
|
|
|
|
<UTooltip text="Open receipt">
|
|
<UButton
|
|
:to="receiptPath(row.original)"
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-receipt"
|
|
size="sm"
|
|
class="min-w-9 justify-center"
|
|
aria-label="Open receipt"
|
|
/>
|
|
</UTooltip>
|
|
|
|
<UDropdownMenu :items="bookingActionMenuItems(row.original)">
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-ellipsis-vertical"
|
|
size="sm"
|
|
class="min-w-9 justify-center"
|
|
:loading="isBookingRowActionBusy(row.original)"
|
|
:disabled="isBookingRowActionBusy(row.original)"
|
|
aria-label="More actions"
|
|
/>
|
|
</UDropdownMenu>
|
|
</div>
|
|
</template>
|
|
</UTable>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UModal
|
|
v-model:open="detailsModalOpen"
|
|
title="Edit Booking"
|
|
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>
|
|
<UForm
|
|
id="bookingDetailsForm"
|
|
:state="detailsForm"
|
|
:validate="validateBookingDetails"
|
|
class="space-y-4"
|
|
@submit="saveBookingDetails"
|
|
>
|
|
<div v-if="detailsBooking" class="surface-panel rounded-lg px-3 py-2">
|
|
<p class="text-sm font-medium text-highlighted">
|
|
{{ detailsBooking.customerName }}
|
|
</p>
|
|
<p class="text-xs text-muted">
|
|
Current: {{ ticketLabel(detailsBooking) }} - {{ detailsBooking.seatCount }} seats
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<UFormField name="customerName" label="Guest / Organizer" required>
|
|
<UInput
|
|
v-model="detailsForm.customerName"
|
|
size="lg"
|
|
class="w-full"
|
|
placeholder="e.g. John Doe"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UFormField name="customerPhone" label="Phone Number" required>
|
|
<UInput
|
|
v-model="detailsForm.customerPhone"
|
|
size="lg"
|
|
type="tel"
|
|
class="w-full"
|
|
placeholder="e.g. +60123456789"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<UFormField name="bookingMode" label="Booking Mode" required>
|
|
<USelect
|
|
v-model="detailsForm.bookingMode"
|
|
:items="bookingModeItems"
|
|
:disabled="savingDetails || !bookingModeItems.length"
|
|
size="lg"
|
|
class="w-full"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UFormField name="quantity" :label="selectedDetailsBookingMode?.quantityLabel || 'Quantity'" required>
|
|
<UInputNumber
|
|
v-model="detailsForm.quantity"
|
|
:min="1"
|
|
:step="1"
|
|
:disabled="savingDetails || deletingTransactionDocument"
|
|
size="lg"
|
|
class="w-full"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
|
|
<UFormField name="ticketType" label="Ticket Category" required>
|
|
<URadioGroup
|
|
v-model="detailsForm.ticketType"
|
|
orientation="horizontal"
|
|
variant="card"
|
|
indicator="hidden"
|
|
:items="ticketCatalogItems"
|
|
:disabled="savingDetails || !ticketCatalogItems.length"
|
|
: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>
|
|
|
|
<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>
|
|
<div class="text-right">
|
|
<div class="text-lg font-semibold text-highlighted">
|
|
{{ detailsTotalFormatted }}
|
|
</div>
|
|
<div class="text-xs text-muted">
|
|
{{ detailsSeatCount }} seats
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UFormField name="remark" label="Remark">
|
|
<UTextarea
|
|
v-model="detailsForm.remark"
|
|
:rows="4"
|
|
:maxlength="remarkLimit"
|
|
autoresize
|
|
class="w-full"
|
|
placeholder="Handling note"
|
|
/>
|
|
<template #help>
|
|
{{ detailsForm.remark.length }}/{{ remarkLimit }}
|
|
</template>
|
|
</UFormField>
|
|
</UForm>
|
|
</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="savingDetails || deletingTransactionDocument"
|
|
@click="closeBookingEditor"
|
|
/>
|
|
<UButton
|
|
type="submit"
|
|
form="bookingDetailsForm"
|
|
label="Save Booking"
|
|
icon="i-lucide-save"
|
|
class="justify-center"
|
|
:loading="savingDetails"
|
|
:disabled="deletingTransactionDocument"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
|
|
<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="surface-panel rounded-lg 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="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>
|
|
|
|
<UModal
|
|
v-model:open="transferModalOpen"
|
|
title="Transfer Booking"
|
|
description="Assign this booking to another PIC."
|
|
>
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<div v-if="transferringBooking" class="surface-panel rounded-lg px-3 py-2">
|
|
<p class="text-sm font-medium text-highlighted">
|
|
{{ transferringBooking.customerName }}
|
|
</p>
|
|
<p class="text-xs text-muted">
|
|
Current PIC: {{ transferringBooking.personInChargeName }}
|
|
</p>
|
|
</div>
|
|
|
|
<UFormField name="personInChargeId" label="New PIC">
|
|
<USelect
|
|
v-model="transferForm.personInChargeId"
|
|
:items="transferPersonInChargeItems"
|
|
:disabled="savingTransfer || !transferPersonInChargeItems.length"
|
|
class="w-full"
|
|
/>
|
|
</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="savingTransfer"
|
|
@click="closeTransferEditor"
|
|
/>
|
|
<UButton
|
|
label="Transfer Booking"
|
|
icon="i-lucide-send"
|
|
class="justify-center"
|
|
:loading="savingTransfer"
|
|
:disabled="!transferForm.personInChargeId"
|
|
@click="saveTransfer"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</div>
|
|
</UContainer>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
|
|
|
import type { PublicContact } from '~~/shared/auth'
|
|
import type {
|
|
BookingCapacitySettings,
|
|
BookingInventorySummary,
|
|
BookingModeOption,
|
|
BookingMode,
|
|
CancelBookingConfirmationResponse,
|
|
DeleteBookingResponse,
|
|
PaymentMethod,
|
|
PublicBooking,
|
|
PublicBookingConfig,
|
|
TicketCatalogItem,
|
|
TicketType,
|
|
TransferBookingPicResponse,
|
|
UpdateBookingDetailsResponse
|
|
} from '~~/shared/booking'
|
|
|
|
import {
|
|
DEFAULT_PHONE_COUNTRY_CODE,
|
|
isValidPhoneNumber,
|
|
normalizePhoneNumber
|
|
} from '~~/shared/auth'
|
|
import {
|
|
formatBookingCurrency,
|
|
getBookingStatusLabel,
|
|
getSeatCount
|
|
} from '~~/shared/booking'
|
|
|
|
import { getErrorMessage } from '../../utils/errors'
|
|
import { formatDateTime } from '../../utils/formatters'
|
|
|
|
definePageMeta({
|
|
middleware: 'auth'
|
|
})
|
|
|
|
useSeoMeta({
|
|
title: 'Bookings',
|
|
description: 'Manage dinner ticket bookings, confirmations, receipts, and seat allocation.',
|
|
robots: 'noindex,nofollow'
|
|
})
|
|
|
|
const toast = useToast()
|
|
const apiClient = useApiClient()
|
|
const auth = useAuth()
|
|
|
|
const bookings = ref<PublicBooking[]>([])
|
|
const bookingConfig = ref<PublicBookingConfig | null>(null)
|
|
const contacts = ref<PublicContact[]>([])
|
|
const loadingBookings = ref(false)
|
|
const loadingBookingConfig = ref(false)
|
|
const loadingContacts = ref(false)
|
|
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)
|
|
const remarkModalOpen = ref(false)
|
|
const transferModalOpen = ref(false)
|
|
const detailsBooking = ref<PublicBooking | null>(null)
|
|
const editingBooking = ref<PublicBooking | null>(null)
|
|
const transferringBooking = ref<PublicBooking | null>(null)
|
|
const searchQuery = ref('')
|
|
const settings = reactive<BookingCapacitySettings>({
|
|
totalSeats: null,
|
|
updatedAt: null
|
|
})
|
|
const summary = reactive<BookingInventorySummary>({
|
|
totalSeats: null,
|
|
soldSeats: 0,
|
|
pendingSeats: 0,
|
|
leftSeats: null
|
|
})
|
|
const capacityForm = reactive({
|
|
totalSeats: ''
|
|
})
|
|
const detailsForm = reactive({
|
|
customerName: '',
|
|
customerPhone: DEFAULT_PHONE_COUNTRY_CODE,
|
|
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: ''
|
|
})
|
|
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) => ({
|
|
label: mode.label,
|
|
value: mode.value
|
|
})) || []
|
|
})
|
|
|
|
const ticketCatalogItems = computed(() => {
|
|
return bookingConfig.value?.ticketCatalog.map((ticket: TicketCatalogItem) => ({
|
|
label: ticket.label,
|
|
value: ticket.value,
|
|
description: ticket.description
|
|
})) || []
|
|
})
|
|
|
|
const selectedDetailsBookingMode = computed(() => {
|
|
return bookingConfig.value?.bookingModes.find((mode) => mode.value === detailsForm.bookingMode) ?? null
|
|
})
|
|
|
|
const selectedDetailsTicket = computed(() => {
|
|
return bookingConfig.value?.ticketCatalog.find((ticket) => ticket.value === detailsForm.ticketType) ?? null
|
|
})
|
|
|
|
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' },
|
|
{ id: 'actions', header: 'Actions' }
|
|
]
|
|
|
|
const inventoryDescription = computed(() => {
|
|
return 'Every booking is converted into seats immediately, so sold and remaining capacity are tracked only in seats.'
|
|
})
|
|
|
|
const inventoryCards = computed(() => {
|
|
return [
|
|
{
|
|
label: 'Seats Sold',
|
|
value: String(summary.soldSeats)
|
|
},
|
|
{
|
|
label: 'Pending Seats',
|
|
value: String(summary.pendingSeats)
|
|
},
|
|
{
|
|
label: 'Seats Left',
|
|
value: formatInventoryNumber(summary.leftSeats)
|
|
}
|
|
]
|
|
})
|
|
|
|
const filteredBookings = computed(() => {
|
|
const keyword = searchQuery.value.trim().toLowerCase()
|
|
|
|
if (!keyword) {
|
|
return bookings.value
|
|
}
|
|
|
|
return bookings.value.filter((booking) => {
|
|
return [
|
|
booking.customerName,
|
|
booking.customerPhone,
|
|
booking.personInChargeName,
|
|
booking.personInChargePhoneNumber,
|
|
booking.ticketType,
|
|
booking.ticketLabel,
|
|
getPaymentMethodLabel(booking.paymentMethod),
|
|
booking.transactionDocument?.originalName || '',
|
|
booking.remark || '',
|
|
booking.status
|
|
].some((value) => value.toLowerCase().includes(keyword))
|
|
})
|
|
})
|
|
|
|
const pendingCount = computed(() => {
|
|
return bookings.value.filter((booking) => booking.status === 'pending').length
|
|
})
|
|
|
|
const confirmedCount = computed(() => {
|
|
return bookings.value.filter((booking) => booking.status === 'confirmed').length
|
|
})
|
|
|
|
const transferPersonInChargeItems = computed(() => {
|
|
const currentPicId = transferringBooking.value?.personInChargeId
|
|
|
|
return contacts.value
|
|
.filter((contact) => contact.id !== currentPicId)
|
|
.map((contact) => ({
|
|
label: contact.fullName,
|
|
value: contact.id
|
|
}))
|
|
})
|
|
|
|
await Promise.all([
|
|
refreshBookingConfig(),
|
|
refreshBookings(),
|
|
refreshContacts()
|
|
])
|
|
|
|
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
|
|
}
|
|
|
|
function confirmationPath(booking: PublicBooking) {
|
|
return `/confirmation/${booking.confirmationToken}`
|
|
}
|
|
|
|
function receiptPath(booking: PublicBooking) {
|
|
return `/receipt/${booking.receiptToken}`
|
|
}
|
|
|
|
function isBookingRowActionBusy(booking: PublicBooking) {
|
|
return Boolean(
|
|
(deletingBookingId.value && deletingBookingId.value !== booking.id)
|
|
|| (cancellingBookingId.value && cancellingBookingId.value !== booking.id)
|
|
|| deletingBookingId.value === booking.id
|
|
|| cancellingBookingId.value === booking.id
|
|
)
|
|
}
|
|
|
|
function bookingActionMenuItems(booking: PublicBooking) {
|
|
const busy = isBookingRowActionBusy(booking)
|
|
const items = [
|
|
[
|
|
{
|
|
label: 'Edit booking',
|
|
icon: 'i-lucide-pencil-line',
|
|
disabled: busy || !bookingConfig.value,
|
|
onSelect: () => openBookingEditor(booking)
|
|
},
|
|
{
|
|
label: 'Transfer booking',
|
|
icon: 'i-lucide-send',
|
|
disabled: busy || !hasTransferTargets(booking),
|
|
onSelect: () => openTransferEditor(booking)
|
|
}
|
|
]
|
|
]
|
|
|
|
if (booking.status === 'confirmed') {
|
|
items.push([
|
|
{
|
|
label: 'Unconfirm booking',
|
|
icon: 'i-lucide-x-circle',
|
|
color: 'error',
|
|
disabled: busy,
|
|
onSelect: () => cancelBookingConfirmation(booking)
|
|
}
|
|
])
|
|
}
|
|
|
|
items.push([
|
|
{
|
|
label: 'Delete booking',
|
|
icon: 'i-lucide-trash-2',
|
|
color: 'error',
|
|
disabled: busy,
|
|
onSelect: () => deleteBooking(booking)
|
|
}
|
|
])
|
|
|
|
return items
|
|
}
|
|
|
|
function openBookingEditor(booking: PublicBooking) {
|
|
detailsBooking.value = booking
|
|
detailsForm.customerName = booking.customerName
|
|
detailsForm.customerPhone = booking.customerPhone
|
|
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 || deletingTransactionDocument.value) {
|
|
return
|
|
}
|
|
|
|
detailsModalOpen.value = false
|
|
detailsBooking.value = null
|
|
detailsForm.customerName = ''
|
|
detailsForm.customerPhone = DEFAULT_PHONE_COUNTRY_CODE
|
|
detailsForm.bookingMode = ''
|
|
detailsForm.quantity = 1
|
|
detailsForm.ticketType = ''
|
|
detailsForm.paymentMethod = 'cash'
|
|
detailsForm.remark = ''
|
|
detailsTransactionDocumentFile.value = null
|
|
transactionDocumentInputKey.value += 1
|
|
}
|
|
|
|
function validateBookingDetails(state: typeof detailsForm): FormError[] {
|
|
const errors: FormError[] = []
|
|
|
|
if (!state.customerName.trim()) {
|
|
errors.push({ name: 'customerName', message: 'Please enter the guest or organizer name.' })
|
|
}
|
|
|
|
if (!state.customerPhone.trim()) {
|
|
errors.push({ name: 'customerPhone', message: 'Please enter a contact number.' })
|
|
} else if (!isValidPhoneNumber(state.customerPhone.trim())) {
|
|
errors.push({ name: 'customerPhone', message: 'Use a valid phone number with country code, e.g. +60123456789.' })
|
|
}
|
|
|
|
if (state.quantity < 1) {
|
|
errors.push({ name: 'quantity', message: 'Quantity must be at least 1.' })
|
|
}
|
|
|
|
if (!selectedDetailsBookingMode.value) {
|
|
errors.push({ name: 'bookingMode', message: 'Please select a booking mode.' })
|
|
}
|
|
|
|
if (!selectedDetailsTicket.value) {
|
|
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
|
|
}
|
|
|
|
async function refreshBookingConfig() {
|
|
if (loadingBookingConfig.value) {
|
|
return
|
|
}
|
|
|
|
loadingBookingConfig.value = true
|
|
|
|
try {
|
|
const response = await apiClient<PublicBookingConfig>('/api/public/booking-config')
|
|
bookingConfig.value = response
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: 'Unable to load booking settings',
|
|
description: getErrorMessage(error, 'The booking editor could not load the active event settings.'),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
loadingBookingConfig.value = false
|
|
}
|
|
}
|
|
|
|
function formatInventoryNumber(value: number | null) {
|
|
return value === null ? 'Not set' : String(value)
|
|
}
|
|
|
|
function normalizeCapacityValue(value: string | number | null | undefined) {
|
|
if (typeof value === 'number') {
|
|
return Number.isFinite(value) ? String(value) : null
|
|
}
|
|
|
|
const normalized = String(value || '').trim()
|
|
return normalized || null
|
|
}
|
|
|
|
function syncCapacityForm(nextSettings: BookingCapacitySettings) {
|
|
capacityForm.totalSeats = nextSettings.totalSeats === null ? '' : String(nextSettings.totalSeats)
|
|
}
|
|
|
|
function applySettings(nextSettings: BookingCapacitySettings) {
|
|
settings.totalSeats = nextSettings.totalSeats
|
|
settings.updatedAt = nextSettings.updatedAt
|
|
syncCapacityForm(nextSettings)
|
|
}
|
|
|
|
function applySummary(nextSummary: BookingInventorySummary) {
|
|
summary.totalSeats = nextSummary.totalSeats
|
|
summary.soldSeats = nextSummary.soldSeats
|
|
summary.pendingSeats = nextSummary.pendingSeats
|
|
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 removeBooking(bookingId: string) {
|
|
bookings.value = bookings.value.filter((booking) => booking.id !== bookingId)
|
|
}
|
|
|
|
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 = ''
|
|
}
|
|
|
|
function hasTransferTargets(booking: PublicBooking) {
|
|
return contacts.value.some((contact) => contact.id !== booking.personInChargeId)
|
|
}
|
|
|
|
function openTransferEditor(booking: PublicBooking) {
|
|
const nextTarget = contacts.value.find((contact) => contact.id !== booking.personInChargeId)
|
|
|
|
transferringBooking.value = booking
|
|
transferForm.personInChargeId = nextTarget?.id ?? ''
|
|
transferModalOpen.value = true
|
|
}
|
|
|
|
function closeTransferEditor() {
|
|
if (savingTransfer.value) {
|
|
return
|
|
}
|
|
|
|
transferModalOpen.value = false
|
|
transferringBooking.value = null
|
|
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
|
|
}
|
|
|
|
loadingBookings.value = true
|
|
|
|
try {
|
|
const response = await apiClient<{
|
|
bookings: PublicBooking[]
|
|
settings: BookingCapacitySettings
|
|
summary: BookingInventorySummary
|
|
}>('/api/bookings')
|
|
|
|
bookings.value = response.bookings
|
|
applySettings(response.settings)
|
|
applySummary(response.summary)
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: 'Unable to load bookings',
|
|
description: getErrorMessage(error, 'The booking list could not be loaded.'),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
loadingBookings.value = false
|
|
}
|
|
}
|
|
|
|
async function refreshContacts() {
|
|
if (loadingContacts.value) {
|
|
return
|
|
}
|
|
|
|
loadingContacts.value = true
|
|
|
|
try {
|
|
const response = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts')
|
|
contacts.value = response.contacts
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: 'Unable to load PIC list',
|
|
description: getErrorMessage(error, 'The PIC transfer list could not be loaded.'),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
loadingContacts.value = false
|
|
}
|
|
}
|
|
|
|
async function saveBookingDetails(event: FormSubmitEvent<typeof detailsForm>) {
|
|
const booking = detailsBooking.value
|
|
|
|
event.preventDefault()
|
|
|
|
if (!booking || savingDetails.value || deletingTransactionDocument.value) {
|
|
return
|
|
}
|
|
|
|
savingDetails.value = true
|
|
|
|
try {
|
|
const response = await apiClient<UpdateBookingDetailsResponse>(`/api/bookings/${booking.id}`, {
|
|
method: 'PATCH',
|
|
body: {
|
|
customerName: detailsForm.customerName.trim(),
|
|
customerPhone: normalizePhoneNumber(detailsForm.customerPhone),
|
|
bookingMode: detailsForm.bookingMode,
|
|
quantity: detailsForm.quantity,
|
|
ticketType: detailsForm.ticketType,
|
|
paymentMethod: detailsForm.paymentMethod,
|
|
remark: detailsForm.remark
|
|
}
|
|
})
|
|
|
|
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',
|
|
description: 'The booking details have been saved.',
|
|
color: 'success',
|
|
icon: 'i-lucide-check-circle-2'
|
|
})
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: 'Booking update failed',
|
|
description: getErrorMessage(error, 'Unable to save the booking details.'),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
savingDetails.value = false
|
|
}
|
|
}
|
|
|
|
async function deleteBooking(booking: PublicBooking) {
|
|
if (deletingBookingId.value) {
|
|
return
|
|
}
|
|
|
|
if (import.meta.client && !window.confirm(`Delete booking for ${booking.customerName}? It will be archived and removed from the active list.`)) {
|
|
return
|
|
}
|
|
|
|
deletingBookingId.value = booking.id
|
|
|
|
try {
|
|
const response = await apiClient<DeleteBookingResponse>(`/api/bookings/${booking.id}`, {
|
|
method: 'DELETE'
|
|
})
|
|
|
|
removeBooking(response.booking.id)
|
|
await refreshBookings()
|
|
|
|
toast.add({
|
|
title: 'Booking deleted',
|
|
description: 'The booking has been moved to archived state.',
|
|
color: 'success',
|
|
icon: 'i-lucide-trash-2'
|
|
})
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: 'Delete failed',
|
|
description: getErrorMessage(error, 'Unable to delete the booking.'),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
deletingBookingId.value = null
|
|
}
|
|
}
|
|
|
|
async function saveCapacity(event: Event) {
|
|
event.preventDefault()
|
|
|
|
if (!auth.isSuperAdmin.value || savingCapacity.value) {
|
|
return
|
|
}
|
|
|
|
savingCapacity.value = true
|
|
|
|
try {
|
|
const response = await apiClient<{ settings: BookingCapacitySettings }>('/api/bookings/capacity', {
|
|
method: 'PATCH',
|
|
body: {
|
|
totalSeats: normalizeCapacityValue(capacityForm.totalSeats)
|
|
}
|
|
})
|
|
|
|
applySettings(response.settings)
|
|
await refreshBookings()
|
|
|
|
toast.add({
|
|
title: 'Capacity updated',
|
|
description: 'The booking inventory limits have been saved.',
|
|
color: 'success',
|
|
icon: 'i-lucide-check-circle-2'
|
|
})
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: 'Capacity update failed',
|
|
description: getErrorMessage(error, 'Unable to save the booking inventory limits.'),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
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
|
|
}
|
|
}
|
|
|
|
async function saveTransfer() {
|
|
const booking = transferringBooking.value
|
|
const nextPersonInChargeId = transferForm.personInChargeId
|
|
|
|
if (!booking || !nextPersonInChargeId || savingTransfer.value) {
|
|
return
|
|
}
|
|
|
|
savingTransfer.value = true
|
|
|
|
try {
|
|
const response = await apiClient<TransferBookingPicResponse>(`/api/bookings/${booking.id}/pic`, {
|
|
method: 'PATCH',
|
|
body: {
|
|
personInChargeId: nextPersonInChargeId
|
|
}
|
|
})
|
|
|
|
const nextPicName = response.booking.personInChargeName || 'the selected PIC'
|
|
|
|
if (auth.isSuperAdmin.value || response.booking.personInChargeId === auth.user.value?.id) {
|
|
replaceBooking(response.booking)
|
|
} else {
|
|
removeBooking(response.booking.id)
|
|
}
|
|
|
|
transferModalOpen.value = false
|
|
transferringBooking.value = null
|
|
transferForm.personInChargeId = ''
|
|
|
|
toast.add({
|
|
title: 'Booking transferred',
|
|
description: `${booking.customerName} is now assigned to ${nextPicName}.`,
|
|
color: 'success',
|
|
icon: 'i-lucide-check-circle-2'
|
|
})
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: 'Transfer failed',
|
|
description: getErrorMessage(error, 'Unable to transfer this booking.'),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
savingTransfer.value = false
|
|
}
|
|
}
|
|
|
|
async function cancelBookingConfirmation(booking: PublicBooking) {
|
|
if (booking.status !== 'confirmed' || cancellingBookingId.value) {
|
|
return
|
|
}
|
|
|
|
if (import.meta.client && !window.confirm(`Cancel confirmation for ${booking.customerName}? The booking will return to pending and the seats will be released.`)) {
|
|
return
|
|
}
|
|
|
|
cancellingBookingId.value = booking.id
|
|
|
|
try {
|
|
const response = await apiClient<CancelBookingConfirmationResponse>(`/api/public/bookings/${booking.confirmationToken}/cancel`, {
|
|
method: 'POST'
|
|
})
|
|
|
|
replaceBooking(response.booking)
|
|
await refreshBookings()
|
|
|
|
toast.add({
|
|
title: response.alreadyPending ? 'Booking already pending' : 'Confirmation cancelled',
|
|
description: response.alreadyPending
|
|
? `${booking.customerName} was already pending confirmation.`
|
|
: `${booking.customerName} has been returned to pending status.`,
|
|
color: response.alreadyPending ? 'warning' : 'success',
|
|
icon: 'i-lucide-x-circle'
|
|
})
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: 'Cancellation failed',
|
|
description: getErrorMessage(error, 'Unable to cancel the booking confirmation.'),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
cancellingBookingId.value = null
|
|
}
|
|
}
|
|
</script>
|