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
|
||||
|
||||
36
server/api/bookings/[id]/pic.patch.ts
Normal file
36
server/api/bookings/[id]/pic.patch.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -637,6 +637,50 @@ export async function updateBookingRemark(input: {
|
||||
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[]> {
|
||||
await ensureDatabaseReady()
|
||||
const sql = getSqlClient()
|
||||
|
||||
@@ -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) {
|
||||
return [
|
||||
`I'd like to book tickets for the ${booking.event.title}.`,
|
||||
|
||||
@@ -150,6 +150,10 @@ export interface CancelBookingConfirmationResponse {
|
||||
alreadyPending: boolean
|
||||
}
|
||||
|
||||
export interface TransferBookingPicResponse {
|
||||
booking: PublicBooking
|
||||
}
|
||||
|
||||
export function isBookingStatus(value: string | null | undefined): value is BookingStatus {
|
||||
return value === 'pending' || value === 'confirmed'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user