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

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:
Ronni Baslund
2026-06-07 09:39:42 +02:00
parent 35bc7b6c31
commit 90e8a22de4
3 changed files with 102 additions and 1 deletions
+33 -1
View File
@@ -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>