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"
|
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
|
||||||
|
|||||||
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
|
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()
|
||||||
|
|||||||
@@ -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}.`,
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user