feat(bookings): allow transferring bookings to another PIC

Add UI modal and button to reassign bookings to different contacts
Create API endpoint and repository method to handle PIC transfers
This commit is contained in:
2026-05-08 14:36:01 +08:00
parent 13e85cfcd0
commit f77f4390b6
5 changed files with 277 additions and 2 deletions

View File

@@ -302,6 +302,15 @@
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"
@@ -371,12 +380,62 @@
</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 { BookingCapacitySettings, BookingInventorySummary, CancelBookingConfirmationResponse, PublicBooking } from '~~/shared/booking'
import type { PublicContact } from '~~/shared/auth'
import type { BookingCapacitySettings, BookingInventorySummary, CancelBookingConfirmationResponse, PublicBooking, TransferBookingPicResponse } from '~~/shared/booking'
import {
formatBookingCurrency,
@@ -395,12 +454,17 @@ 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,
@@ -418,6 +482,9 @@ const capacityForm = reactive({
const remarkForm = reactive({
remark: ''
})
const transferForm = reactive({
personInChargeId: ''
})
const remarkLimit = 1000
const columns = [
@@ -481,7 +548,21 @@ const confirmedCount = computed(() => {
return bookings.value.filter((booking) => booking.status === 'confirmed').length
})
await refreshBookings()
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()
@@ -540,6 +621,10 @@ function replaceBooking(updatedBooking: PublicBooking) {
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
@@ -562,6 +647,28 @@ function closeRemarkEditor() {
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
@@ -591,6 +698,28 @@ async function refreshBookings() {
}
}
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()
@@ -669,6 +798,54 @@ async function saveRemark() {
}
}
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