feat(scheduling): round-robin team event types

This commit is contained in:
Ronni Baslund
2026-06-07 09:14:08 +02:00
parent b9b4d56a2d
commit 95cbdc4e3d
7 changed files with 203 additions and 8 deletions
+43 -4
View File
@@ -26,6 +26,8 @@ interface EventType {
availabilityScheduleId: string
locationType: string
ignoreAllDayEvents: boolean
assignment?: 'single' | 'round_robin'
hostPool?: string[]
isActive: boolean
}
interface MinuteInterval { startMinute: number; endMinute: number }
@@ -85,6 +87,9 @@ const { data: hosts, refresh: refreshHosts } = await useFetch<Host[]>(() => `${b
const selectedHostId = ref<string | null>(null)
const selectedHost = computed(() => hosts.value?.find((h) => h._id === selectedHostId.value) ?? null)
// Round-robin pool candidates: every other host in the tenant (the owning host is
// always implicitly part of the pool, so it is excluded from the selectable list).
const poolCandidates = computed(() => (hosts.value ?? []).filter((h) => h._id !== selectedHostId.value))
const detailTab = ref<'event-types' | 'availability' | 'bookings'>('event-types')
const eventTypes = ref<EventType[]>([])
@@ -289,6 +294,8 @@ const etForm = reactive({
availabilityScheduleId: '',
locationType: 'jitsi',
ignoreAllDayEvents: true,
assignment: 'single' as 'single' | 'round_robin',
hostPool: [] as string[],
})
const etEditingId = ref<string | null>(null)
const etSlugTouched = ref(false)
@@ -303,6 +310,7 @@ function openEt(et?: EventType) {
bufferAfterMinutes: et.bufferAfterMinutes, minimumNoticeMinutes: et.minimumNoticeMinutes,
maximumDaysInFuture: et.maximumDaysInFuture, availabilityScheduleId: et.availabilityScheduleId,
locationType: et.locationType, ignoreAllDayEvents: et.ignoreAllDayEvents ?? true,
assignment: et.assignment ?? 'single', hostPool: [...(et.hostPool ?? [])],
})
etSlugTouched.value = true // don't auto-rewrite an existing slug
} else {
@@ -311,7 +319,7 @@ function openEt(et?: EventType) {
title: '', slug: '', durationMinutes: 30, slotIntervalMinutes: 15,
bufferBeforeMinutes: 0, bufferAfterMinutes: 0, minimumNoticeMinutes: 60,
maximumDaysInFuture: 60, availabilityScheduleId: availability.value[0]?._id ?? '', locationType: 'jitsi',
ignoreAllDayEvents: true,
ignoreAllDayEvents: true, assignment: 'single', hostPool: [],
})
etSlugTouched.value = false
}
@@ -325,13 +333,18 @@ async function submitEt() {
if (!selectedHostId.value || !etValid.value) return
etBusy.value = true
try {
// Only send a host pool for round-robin; single-host keeps the legacy shape.
const payload = {
...etForm,
hostPool: etForm.assignment === 'round_robin' ? [...etForm.hostPool] : [],
}
if (etEditingId.value) {
// slug is immutable after creation (public links would break) → omit it.
const { slug: _slug, ...patch } = { ...etForm }
const { slug: _slug, ...patch } = payload
await request(`${base.value}/event-types/${etEditingId.value}`, { method: 'PATCH', body: patch })
toast.ok('Event type updated')
} else {
await request(`${base.value}/hosts/${selectedHostId.value}/event-types`, { method: 'POST', body: { ...etForm } })
await request(`${base.value}/hosts/${selectedHostId.value}/event-types`, { method: 'POST', body: payload })
toast.ok('Event type created')
}
etOpen.value = false
@@ -587,7 +600,10 @@ const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}…${s.slice
<p v-if="!availability.length" class="hint">Create an availability schedule first.</p>
<Card v-for="et in eventTypes" :key="et._id" class="item">
<div class="itemmain">
<div class="ititle">{{ et.title }} <span class="mute">· {{ et.durationMinutes }} min</span></div>
<div class="ititle">
{{ et.title }} <span class="mute">· {{ et.durationMinutes }} min</span>
<Badge v-if="et.assignment === 'round_robin'" tone="neutral">round-robin</Badge>
</div>
<a class="link" :href="publicUrl(et)" target="_blank" rel="noopener">{{ publicUrl(et) }}</a>
</div>
<div class="itemactions">
@@ -837,6 +853,29 @@ const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}…${s.slice
<input type="checkbox" v-model="etForm.ignoreAllDayEvents" />
Ignore all-day events when checking availability
</label>
<label class="field"><Eyebrow>Host assignment</Eyebrow>
<select class="input" v-model="etForm.assignment">
<option value="single">Single host (this host)</option>
<option value="round_robin">Round-robin (share across a team)</option>
</select>
<span class="slughint">
<template v-if="etForm.assignment === 'round_robin'">
Bookings are spread across the selected team. Visitors see the combined free time of everyone in the pool; each booking is assigned to a free team member.
</template>
<template v-else>Every booking goes to {{ selectedHost?.displayName ?? 'this host' }}.</template>
</span>
</label>
<div v-if="etForm.assignment === 'round_robin'" class="field">
<Eyebrow>Team pool</Eyebrow>
<p class="slughint">{{ selectedHost?.displayName ?? 'This host' }} is always included. Add other hosts to share bookings with:</p>
<div class="poollist">
<label v-for="h in poolCandidates" :key="h._id" class="daytoggle">
<input type="checkbox" :value="h._id" v-model="etForm.hostPool" />
{{ h.displayName }} <span class="mute small">· {{ h.slug }}</span>
</label>
<p v-if="!poolCandidates.length" class="mute small">No other hosts in this tenant yet.</p>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="etOpen = false">Cancel</UiButton>