Add UI modal and button to reassign bookings to different contacts Create API endpoint and repository method to handle PIC transfers
891 lines
28 KiB
Vue
891 lines
28 KiB
Vue
<template>
|
|
<UContainer class="py-6">
|
|
<div class="mx-auto max-w-7xl space-y-4">
|
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
|
<div class="space-y-1">
|
|
<UBadge label="Bookings" color="primary" variant="soft" size="sm" class="rounded-full" />
|
|
<h1 class="text-2xl font-bold tracking-tight text-highlighted">
|
|
Booking list
|
|
</h1>
|
|
<p class="text-sm text-muted">
|
|
{{ 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="border border-default bg-default shadow-sm" :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="border border-default bg-default shadow-sm" :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="rounded-lg border border-default bg-muted/20 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="rounded-lg border border-default bg-muted/20 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="border border-default bg-default shadow-sm"
|
|
: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="border border-default bg-default shadow-sm" :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="border border-default bg-default shadow-sm" :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="border border-default bg-default shadow-sm" :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="border border-default bg-default shadow-sm"
|
|
: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="min-w-[1120px]"
|
|
>
|
|
<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 #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-wrap justify-end gap-1.5 py-0.5">
|
|
<UButton
|
|
:to="confirmationPath(row.original)"
|
|
label="Open"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-external-link"
|
|
size="sm"
|
|
/>
|
|
<UButton
|
|
:to="receiptPath(row.original)"
|
|
label="Receipt"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-receipt"
|
|
size="sm"
|
|
/>
|
|
<UButton
|
|
label="Transfer"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-send"
|
|
size="sm"
|
|
:disabled="!hasTransferTargets(row.original)"
|
|
@click="openTransferEditor(row.original)"
|
|
/>
|
|
<UButton
|
|
v-if="row.original.status === 'confirmed'"
|
|
label="Unconfirm"
|
|
color="error"
|
|
variant="outline"
|
|
icon="i-lucide-x-circle"
|
|
size="sm"
|
|
:loading="cancellingBookingId === row.original.id"
|
|
:disabled="cancellingBookingId !== null && cancellingBookingId !== row.original.id"
|
|
@click="cancelBookingConfirmation(row.original)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</UTable>
|
|
</div>
|
|
</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>
|
|
|
|
<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="rounded-lg border border-default bg-muted/20 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 { PublicContact } from '~~/shared/auth'
|
|
import type { BookingCapacitySettings, BookingInventorySummary, CancelBookingConfirmationResponse, PublicBooking, TransferBookingPicResponse } from '~~/shared/booking'
|
|
|
|
import {
|
|
formatBookingCurrency,
|
|
getBookingStatusLabel
|
|
} from '~~/shared/booking'
|
|
|
|
import { getErrorMessage } from '../../utils/errors'
|
|
import { formatDateTime } from '../../utils/formatters'
|
|
|
|
definePageMeta({
|
|
middleware: 'auth'
|
|
})
|
|
|
|
const toast = useToast()
|
|
const apiClient = useApiClient()
|
|
const auth = useAuth()
|
|
|
|
const bookings = ref<PublicBooking[]>([])
|
|
const contacts = ref<PublicContact[]>([])
|
|
const loadingBookings = ref(false)
|
|
const loadingContacts = ref(false)
|
|
const savingCapacity = ref(false)
|
|
const savingRemark = ref(false)
|
|
const savingTransfer = ref(false)
|
|
const cancellingBookingId = ref<string | null>(null)
|
|
const remarkModalOpen = ref(false)
|
|
const transferModalOpen = ref(false)
|
|
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 remarkForm = reactive({
|
|
remark: ''
|
|
})
|
|
const transferForm = reactive({
|
|
personInChargeId: ''
|
|
})
|
|
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' }
|
|
]
|
|
|
|
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,
|
|
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([
|
|
refreshBookings(),
|
|
refreshContacts()
|
|
])
|
|
|
|
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}`
|
|
}
|
|
|
|
function receiptPath(booking: PublicBooking) {
|
|
return `/receipt/${booking.receiptToken}`
|
|
}
|
|
|
|
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 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 || ''
|
|
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 = ''
|
|
}
|
|
|
|
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 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)
|
|
|
|
if (!response.alreadyPending) {
|
|
applyCancelledConfirmationToSummary(booking)
|
|
}
|
|
|
|
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>
|