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" icon="i-lucide-receipt"
size="sm" size="sm"
/> />
<UButton
label="Transfer"
color="neutral"
variant="outline"
icon="i-lucide-send"
size="sm"
:disabled="!hasTransferTargets(row.original)"
@click="openTransferEditor(row.original)"
/>
<UButton <UButton
v-if="row.original.status === 'confirmed'" v-if="row.original.status === 'confirmed'"
label="Unconfirm" label="Unconfirm"
@@ -371,12 +380,62 @@
</div> </div>
</template> </template>
</UModal> </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> </div>
</UContainer> </UContainer>
</template> </template>
<script lang="ts" setup> <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 { import {
formatBookingCurrency, formatBookingCurrency,
@@ -395,12 +454,17 @@ const apiClient = useApiClient()
const auth = useAuth() const auth = useAuth()
const bookings = ref<PublicBooking[]>([]) const bookings = ref<PublicBooking[]>([])
const contacts = ref<PublicContact[]>([])
const loadingBookings = ref(false) const loadingBookings = ref(false)
const loadingContacts = ref(false)
const savingCapacity = ref(false) const savingCapacity = ref(false)
const savingRemark = ref(false) const savingRemark = ref(false)
const savingTransfer = ref(false)
const cancellingBookingId = ref<string | null>(null) const cancellingBookingId = ref<string | null>(null)
const remarkModalOpen = ref(false) const remarkModalOpen = ref(false)
const transferModalOpen = ref(false)
const editingBooking = ref<PublicBooking | null>(null) const editingBooking = ref<PublicBooking | null>(null)
const transferringBooking = ref<PublicBooking | null>(null)
const searchQuery = ref('') const searchQuery = ref('')
const settings = reactive<BookingCapacitySettings>({ const settings = reactive<BookingCapacitySettings>({
totalSeats: null, totalSeats: null,
@@ -418,6 +482,9 @@ const capacityForm = reactive({
const remarkForm = reactive({ const remarkForm = reactive({
remark: '' remark: ''
}) })
const transferForm = reactive({
personInChargeId: ''
})
const remarkLimit = 1000 const remarkLimit = 1000
const columns = [ const columns = [
@@ -481,7 +548,21 @@ const confirmedCount = computed(() => {
return bookings.value.filter((booking) => booking.status === 'confirmed').length 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) { function ticketLabel(booking: PublicBooking) {
return booking.ticketLabel || booking.ticketType.toUpperCase() return booking.ticketLabel || booking.ticketType.toUpperCase()
@@ -540,6 +621,10 @@ function replaceBooking(updatedBooking: PublicBooking) {
bookings.value.splice(index, 1, updatedBooking) bookings.value.splice(index, 1, updatedBooking)
} }
function removeBooking(bookingId: string) {
bookings.value = bookings.value.filter((booking) => booking.id !== bookingId)
}
function applyCancelledConfirmationToSummary(booking: PublicBooking) { function applyCancelledConfirmationToSummary(booking: PublicBooking) {
summary.soldSeats = Math.max(summary.soldSeats - booking.seatCount, 0) summary.soldSeats = Math.max(summary.soldSeats - booking.seatCount, 0)
summary.pendingSeats += booking.seatCount summary.pendingSeats += booking.seatCount
@@ -562,6 +647,28 @@ function closeRemarkEditor() {
remarkForm.remark = '' 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() { async function refreshBookings() {
if (loadingBookings.value) { if (loadingBookings.value) {
return 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) { async function saveCapacity(event: Event) {
event.preventDefault() 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) { async function cancelBookingConfirmation(booking: PublicBooking) {
if (booking.status !== 'confirmed' || cancellingBookingId.value) { if (booking.status !== 'confirmed' || cancellingBookingId.value) {
return return

View File

@@ -0,0 +1,36 @@
import type { TransferBookingPicResponse } from '~~/shared/booking'
import { requireAuth } from '../../../utils/auth'
import { updateBookingPersonInCharge } from '../../../utils/booking-repository'
import { parseBookingPicTransferInput } from '../../../utils/bookings'
import { getRequiredRouteParam, httpError } from '../../../utils/http'
import { getPublicContactById } from '../../../utils/user-repository'
export default defineEventHandler(async (event): Promise<TransferBookingPicResponse> => {
const auth = await requireAuth(event)
const bookingId = getRequiredRouteParam(event, 'id', 'Booking ID')
const body = await readBody<{
personInChargeId?: string | null
}>(event)
const input = parseBookingPicTransferInput(body)
const nextPersonInCharge = await getPublicContactById(input.personInChargeId)
if (!nextPersonInCharge) {
httpError(404, 'Selected person in charge is not available')
}
const booking = await updateBookingPersonInCharge({
bookingId,
nextPersonInChargeId: nextPersonInCharge.id,
currentPersonInChargeId: auth.user.role === 'super_admin' ? undefined : auth.user.id
})
if (!booking) {
httpError(404, 'Booking not found')
}
return {
booking
}
})

View File

@@ -637,6 +637,50 @@ export async function updateBookingRemark(input: {
return rows[0] ? mapBooking(rows[0]) : null return rows[0] ? mapBooking(rows[0]) : null
} }
export async function updateBookingPersonInCharge(input: {
bookingId: string
currentPersonInChargeId?: string
nextPersonInChargeId: string
}): Promise<PublicBooking | null> {
await ensureDatabaseReady()
const sql = getSqlClient()
const rows = input.currentPersonInChargeId
? await sql<DbBookingRow[]>`
with updated_booking as (
update bookings
set
person_in_charge_id = ${input.nextPersonInChargeId},
updated_at = now()
where id = ${input.bookingId}
and person_in_charge_id = ${input.currentPersonInChargeId}
returning *
)
select ${bookingSelectColumns(sql)}
from updated_booking as bookings
${bookingJoins(sql)}
where dinner_events.is_active = true
limit 1
`
: await sql<DbBookingRow[]>`
with updated_booking as (
update bookings
set
person_in_charge_id = ${input.nextPersonInChargeId},
updated_at = now()
where id = ${input.bookingId}
returning *
)
select ${bookingSelectColumns(sql)}
from updated_booking as bookings
${bookingJoins(sql)}
where dinner_events.is_active = true
limit 1
`
return rows[0] ? mapBooking(rows[0]) : null
}
export async function listBookingSeats(bookingId: string): Promise<PublicBookingSeat[]> { export async function listBookingSeats(bookingId: string): Promise<PublicBookingSeat[]> {
await ensureDatabaseReady() await ensureDatabaseReady()
const sql = getSqlClient() const sql = getSqlClient()

View File

@@ -51,6 +51,20 @@ export function parseBookingRemarkInput(body: {
} }
} }
export function parseBookingPicTransferInput(body: {
personInChargeId?: string | null
}) {
const personInChargeId = typeof body.personInChargeId === 'string'
? body.personInChargeId.trim()
: ''
assertBadRequest(personInChargeId, 'Person in charge is required')
return {
personInChargeId
}
}
export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) { export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) {
return [ return [
`I'd like to book tickets for the ${booking.event.title}.`, `I'd like to book tickets for the ${booking.event.title}.`,

View File

@@ -150,6 +150,10 @@ export interface CancelBookingConfirmationResponse {
alreadyPending: boolean alreadyPending: boolean
} }
export interface TransferBookingPicResponse {
booking: PublicBooking
}
export function isBookingStatus(value: string | null | undefined): value is BookingStatus { export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
return value === 'pending' || value === 'confirmed' return value === 'pending' || value === 'confirmed'
} }