feat(scheduling): calendar_failed badge + admin "retry now" action
ci / typecheck (map[dir:apps/booking name:booking]) (push) Has been cancelled
ci / typecheck (map[dir:apps/portal name:portal]) (push) Has been cancelled
ci / typecheck (map[dir:apps/website name:website]) (push) Has been cancelled
ci / typecheck (map[dir:services/platform-api name:platform-api]) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / typecheck (map[dir:apps/booking name:booking]) (push) Has been cancelled
ci / typecheck (map[dir:apps/portal name:portal]) (push) Has been cancelled
ci / typecheck (map[dir:apps/website name:website]) (push) Has been cancelled
ci / typecheck (map[dir:services/platform-api name:platform-api]) (push) Has been cancelled
ci / test (push) Has been cancelled
Surface pending/calendar_failed booking states in the admin bookings list with proper status badges (failed shows the last calendar error as a tooltip), and add an operator "Retry now" action. The retry re-drives the same Stalwart calendar write (confirm + attendee email on success); for a terminal calendar_failed booking it re-claims the slot lock atomically first and refuses if the time was taken in the meantime, so a manual retry can never double-book.
This commit is contained in:
@@ -42,6 +42,7 @@ interface Booking {
|
||||
attendeeName: string
|
||||
attendeeEmail: string
|
||||
eventTypeId: string
|
||||
lastCalendarError?: string
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
@@ -323,6 +324,31 @@ function cancelBookingAdmin(b: Booking) {
|
||||
})
|
||||
}
|
||||
|
||||
// Badge tone for a booking status (Badge supports ok/warn/bad/neutral).
|
||||
function bookingTone(status: string): 'ok' | 'warn' | 'bad' | 'neutral' {
|
||||
if (status === 'confirmed') return 'ok'
|
||||
if (status === 'pending') return 'warn'
|
||||
if (status === 'calendar_failed') return 'bad'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
// "Retry now" — re-drive the Stalwart calendar write for a pending /
|
||||
// calendar_failed booking. On success it confirms + emails the attendee.
|
||||
const retryingId = ref<string | null>(null)
|
||||
async function retryBookingAdmin(b: Booking) {
|
||||
retryingId.value = b._id
|
||||
try {
|
||||
const updated = (await request(`${base.value}/bookings/${b._id}/retry`, { method: 'POST' })) as Booking
|
||||
toast.ok(updated.status === 'confirmed' ? 'Booking confirmed' : 'Retry queued', updated.status === 'confirmed' ? 'The calendar event was written and the attendee emailed.' : 'The calendar write failed again; the background worker will keep retrying.')
|
||||
await loadHostData()
|
||||
await loadOverview()
|
||||
} catch (err) {
|
||||
toastErr(err, 'Retry failed')
|
||||
} finally {
|
||||
retryingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create event type ──
|
||||
const etOpen = ref(false)
|
||||
const etBusy = ref(false)
|
||||
@@ -751,9 +777,15 @@ const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}…${s.slice
|
||||
<div class="mute small">{{ fmtDateTime(b.startUtc) }} – {{ fmtDateTime(b.endUtc) }}</div>
|
||||
</div>
|
||||
<div class="itemactions">
|
||||
<Badge :tone="b.status === 'confirmed' ? 'ok' : b.status === 'cancelled' ? 'bad' : 'neutral'">{{ b.status }}</Badge>
|
||||
<Badge :tone="bookingTone(b.status)" :title="b.lastCalendarError || undefined">{{ statusLabel[b.status] ?? b.status }}</Badge>
|
||||
<UiButton v-if="b.status === 'confirmed'" variant="ghost" @click="openReschedule(b)">Reschedule</UiButton>
|
||||
<UiButton v-if="b.status === 'confirmed'" variant="ghost" @click="cancelBookingAdmin(b)">Cancel</UiButton>
|
||||
<UiButton
|
||||
v-if="b.status === 'calendar_failed' || b.status === 'pending'"
|
||||
variant="ghost"
|
||||
:disabled="retryingId === b._id"
|
||||
@click="retryBookingAdmin(b)"
|
||||
>{{ retryingId === b._id ? 'Retrying…' : 'Retry now' }}</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user