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