feat(scheduling): round-robin team event types
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user