refactor(ui): streamline booking form layout and table actions

Consolidate booking table row actions into a dropdown menu
Update booking page layout to use a sidebar card for event details
This commit is contained in:
2026-05-08 16:52:30 +08:00
parent b6749bc5e7
commit 25720b21e1
2 changed files with 132 additions and 97 deletions

View File

@@ -285,62 +285,43 @@
</template> </template>
<template #actions-cell="{ row }"> <template #actions-cell="{ row }">
<div class="flex flex-wrap justify-end gap-1.5 py-0.5"> <div class="flex flex-nowrap items-center justify-end gap-1 py-0.5 whitespace-nowrap">
<UTooltip text="Open booking">
<UButton <UButton
:to="confirmationPath(row.original)" :to="confirmationPath(row.original)"
label="Open"
color="neutral" color="neutral"
variant="outline" variant="ghost"
icon="i-lucide-external-link" icon="i-lucide-external-link"
size="sm" size="sm"
class="min-w-9 justify-center"
aria-label="Open booking"
/> />
</UTooltip>
<UTooltip text="Open receipt">
<UButton <UButton
:to="receiptPath(row.original)" :to="receiptPath(row.original)"
label="Receipt"
color="neutral" color="neutral"
variant="outline" variant="ghost"
icon="i-lucide-receipt" icon="i-lucide-receipt"
size="sm" size="sm"
class="min-w-9 justify-center"
aria-label="Open receipt"
/> />
</UTooltip>
<UDropdownMenu :items="bookingActionMenuItems(row.original)">
<UButton <UButton
label="Edit"
color="neutral" color="neutral"
variant="outline" variant="ghost"
icon="i-lucide-pencil-line" icon="i-lucide-ellipsis-vertical"
size="sm" size="sm"
:disabled="!bookingConfig" class="min-w-9 justify-center"
@click="openBookingEditor(row.original)" :loading="isBookingRowActionBusy(row.original)"
/> :disabled="isBookingRowActionBusy(row.original)"
<UButton aria-label="More actions"
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"
color="error"
variant="outline"
icon="i-lucide-x-circle"
size="sm"
:loading="cancellingBookingId === row.original.id"
:disabled="cancellingBookingId !== null && cancellingBookingId !== row.original.id"
@click="cancelBookingConfirmation(row.original)"
/>
<UButton
label="Delete"
color="error"
variant="outline"
icon="i-lucide-trash-2"
size="sm"
:loading="deletingBookingId === row.original.id"
:disabled="Boolean(deletingBookingId) && deletingBookingId !== row.original.id"
@click="deleteBooking(row.original)"
/> />
</UDropdownMenu>
</div> </div>
</template> </template>
</UTable> </UTable>
@@ -798,6 +779,59 @@ function receiptPath(booking: PublicBooking) {
return `/receipt/${booking.receiptToken}` return `/receipt/${booking.receiptToken}`
} }
function isBookingRowActionBusy(booking: PublicBooking) {
return Boolean(
(deletingBookingId.value && deletingBookingId.value !== booking.id)
|| (cancellingBookingId.value && cancellingBookingId.value !== booking.id)
|| deletingBookingId.value === booking.id
|| cancellingBookingId.value === booking.id
)
}
function bookingActionMenuItems(booking: PublicBooking) {
const busy = isBookingRowActionBusy(booking)
const items = [
[
{
label: 'Edit booking',
icon: 'i-lucide-pencil-line',
disabled: busy || !bookingConfig.value,
onSelect: () => openBookingEditor(booking)
},
{
label: 'Transfer booking',
icon: 'i-lucide-send',
disabled: busy || !hasTransferTargets(booking),
onSelect: () => openTransferEditor(booking)
}
]
]
if (booking.status === 'confirmed') {
items.push([
{
label: 'Unconfirm booking',
icon: 'i-lucide-x-circle',
color: 'error',
disabled: busy,
onSelect: () => cancelBookingConfirmation(booking)
}
])
}
items.push([
{
label: 'Delete booking',
icon: 'i-lucide-trash-2',
color: 'error',
disabled: busy,
onSelect: () => deleteBooking(booking)
}
])
return items
}
function openBookingEditor(booking: PublicBooking) { function openBookingEditor(booking: PublicBooking) {
detailsBooking.value = booking detailsBooking.value = booking
detailsForm.customerName = booking.customerName detailsForm.customerName = booking.customerName

View File

@@ -235,23 +235,28 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
<template> <template>
<UContainer class="page-shell-narrow"> <UContainer class="page-shell-narrow">
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_34rem] xl:items-start xl:gap-8"> <div class="grid gap-5 xl:grid-cols-[22rem_minmax(0,1fr)] xl:items-start xl:gap-8">
<section class="space-y-4 xl:sticky xl:top-6 xl:space-y-6"> <UCard
<div class="page-header"> class="surface-card overflow-hidden rounded-lg xl:sticky xl:top-6"
:ui="{ header: 'px-4 py-4 sm:px-5 sm:py-5', body: 'space-y-4 px-4 pb-4 pt-0 sm:px-5 sm:pb-5' }"
>
<template #header>
<div class="space-y-1">
<UBadge :label="t('layout.brand')" color="primary" variant="soft" class="page-eyebrow" /> <UBadge :label="t('layout.brand')" color="primary" variant="soft" class="page-eyebrow" />
<h1 class="page-title"> <h1 class="text-2xl font-bold leading-tight text-highlighted sm:text-3xl xl:text-2xl">
{{ bookingConfig.event.title }} {{ bookingConfig.event.title }}
</h1> </h1>
<p class="page-description"> <p class="page-description">
{{ t('booking.seatGeneration', { count: seatCount, seatLabel: locale === 'zh' ? '座位' : `seat${seatCount === 1 ? '' : 's'}` }) }} {{ t('booking.seatGeneration', { count: seatCount, seatLabel: locale === 'zh' ? '座位' : `seat${seatCount === 1 ? '' : 's'}` }) }}
</p> </p>
</div> </div>
</template>
<div class="grid gap-2 sm:grid-cols-3 xl:grid-cols-1 xl:gap-3"> <div class="grid gap-3">
<div <div
v-for="detail in eventDetails" v-for="detail in eventDetails"
:key="detail.label" :key="detail.label"
class="surface-card rounded-lg p-3 sm:p-4" class="surface-panel rounded-lg p-4"
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"> <div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
@@ -268,7 +273,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
</div> </div>
</div> </div>
</div> </div>
</section> </UCard>
<UCard <UCard
id="booking-form" id="booking-form"
@@ -285,7 +290,6 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
</div> </div>
<UForm :state="form" :validate="validateBooking" class="space-y-5 sm:space-y-6" @submit="bookTicket"> <UForm :state="form" :validate="validateBooking" class="space-y-5 sm:space-y-6" @submit="bookTicket">
<div class="grid gap-4 sm:grid-cols-2 sm:gap-5">
<UFormField name="name" :label="t('booking.name')" required> <UFormField name="name" :label="t('booking.name')" required>
<UInput v-model="form.name" size="xl" class="w-full" :placeholder="t('booking.namePlaceholder')" /> <UInput v-model="form.name" size="xl" class="w-full" :placeholder="t('booking.namePlaceholder')" />
</UFormField> </UFormField>
@@ -293,7 +297,6 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
<UFormField name="phone" :label="t('common.phoneNumber')" required> <UFormField name="phone" :label="t('common.phoneNumber')" required>
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" :placeholder="t('booking.phonePlaceholder')" /> <UInput v-model="form.phone" size="xl" type="tel" class="w-full" :placeholder="t('booking.phonePlaceholder')" />
</UFormField> </UFormField>
</div>
<UFormField :label="t('booking.bookingMode')" name="bookingMode"> <UFormField :label="t('booking.bookingMode')" name="bookingMode">
<URadioGroup <URadioGroup
@@ -303,13 +306,12 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
indicator="hidden" indicator="hidden"
:items="bookingModeOptions" :items="bookingModeOptions"
:ui="{ :ui="{
fieldset: 'grid grid-cols-1 gap-3 sm:grid-cols-2', fieldset: 'grid grid-cols-2 gap-3',
item: 'min-h-14 rounded-lg border border-default bg-default p-3 transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary/5' item: 'min-h-14 rounded-lg border border-default bg-default p-3 transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}" }"
/> />
</UFormField> </UFormField>
<div class="grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] sm:gap-5">
<UFormField :label="quantityLabel" name="quantity"> <UFormField :label="quantityLabel" name="quantity">
<UInputNumber v-model="form.quantity" size="xl" class="w-full" :min="1" :step="1" /> <UInputNumber v-model="form.quantity" size="xl" class="w-full" :min="1" :step="1" />
<template #help> <template #help>
@@ -326,7 +328,6 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
:disabled="!personInCharge.length" :disabled="!personInCharge.length"
/> />
</UFormField> </UFormField>
</div>
<UFormField :label="t('booking.ticketCategory')" name="ticketType"> <UFormField :label="t('booking.ticketCategory')" name="ticketType">
<URadioGroup <URadioGroup
@@ -336,7 +337,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
indicator="hidden" indicator="hidden"
:items="ticketCatalogOptions" :items="ticketCatalogOptions"
:ui="{ :ui="{
fieldset: 'grid grid-cols-1 gap-3 sm:grid-cols-2', fieldset: 'grid grid-cols-2 gap-3',
item: 'min-h-14 rounded-lg border border-default bg-default p-3 transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary/5' item: 'min-h-14 rounded-lg border border-default bg-default p-3 transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary/5'
}" }"
/> />