Files
dezky/apps/portal/pages/admin/scheduling.vue
T
2026-06-07 09:08:45 +02:00

962 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// dezky Scheduling admin. Make a workspace user bookable (auto-provisions their
// Stalwart calendar credential), then configure event types + availability and
// view bookings. Public pages live on booking.dezky.eu/:tenant/:host/:eventType.
interface Host {
_id: string
email: string
displayName: string
slug: string
timezone: string
isActive: boolean
defaultCalendarId?: string
}
interface EventType {
_id: string
slug: string
title: string
description?: string
durationMinutes: number
slotIntervalMinutes: number
bufferBeforeMinutes: number
bufferAfterMinutes: number
minimumNoticeMinutes: number
maximumDaysInFuture: number
availabilityScheduleId: string
locationType: string
ignoreAllDayEvents: boolean
isActive: boolean
}
interface MinuteInterval { startMinute: number; endMinute: number }
interface WeeklyRule { dayOfWeek: number; intervals: MinuteInterval[] }
interface DateOverride { date: string; isUnavailable: boolean; intervals: MinuteInterval[] }
interface Availability { _id: string; name: string; timezone: string; weeklyRules: WeeklyRule[]; dateOverrides?: DateOverride[] }
interface Booking {
_id: string
status: string
startUtc: string
endUtc: string
attendeeName: string
attendeeEmail: string
eventTypeId: string
}
const toast = useToast()
const { tenant } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
const { request } = useApiFetch()
const bookingBase = 'https://booking.dezky.local'
const base = computed(() => `/api/tenants/${slug.value}/scheduling`)
function toastErr(err: unknown, title: string) {
const e = err as { data?: { message?: string | string[] }; statusMessage?: string; message?: string }
const m = e?.data?.message ?? e?.statusMessage ?? e?.message ?? 'Unknown error'
toast.bad(title, Array.isArray(m) ? m.join(', ') : String(m))
}
const minToTime = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
// Generic confirm dialog (deletes + cancellations route through this).
const confirmBox = reactive({ open: false, title: '', message: '', confirmLabel: 'Confirm', busy: false, action: null as null | (() => Promise<void>) })
function askConfirm(opts: { title: string; message: string; confirmLabel?: string; action: () => Promise<void> }) {
Object.assign(confirmBox, { confirmLabel: 'Confirm', ...opts, open: true, busy: false })
}
async function runConfirm() {
if (!confirmBox.action) return
confirmBox.busy = true
try {
await confirmBox.action()
confirmBox.open = false
} catch (err) {
toastErr(err, 'Action failed')
} finally {
confirmBox.busy = false
}
}
const { data: hosts, refresh: refreshHosts } = await useFetch<Host[]>(() => `${base.value}/hosts`, {
key: 'sched-hosts',
default: () => [],
immediate: !!slug.value,
watch: [slug],
})
const selectedHostId = ref<string | null>(null)
const selectedHost = computed(() => hosts.value?.find((h) => h._id === selectedHostId.value) ?? null)
const detailTab = ref<'event-types' | 'availability' | 'bookings'>('event-types')
const eventTypes = ref<EventType[]>([])
const availability = ref<Availability[]>([])
const bookings = ref<Booking[]>([])
async function selectHost(id: string) {
selectedHostId.value = id
detailTab.value = 'event-types'
await loadHostData()
}
async function loadHostData() {
if (!selectedHostId.value) return
try {
const id = selectedHostId.value
;[eventTypes.value, availability.value, bookings.value] = await Promise.all([
request(`${base.value}/hosts/${id}/event-types`) as Promise<EventType[]>,
request(`${base.value}/hosts/${id}/availability`) as Promise<Availability[]>,
request(`${base.value}/bookings?hostId=${id}`) as Promise<Booking[]>,
])
} catch (err) {
toastErr(err, 'Could not load host data')
}
}
// ── Workspace users (bookable candidates need a mailbox) ──
interface WsUser { _id: string; name: string; email: string; mailboxAddress?: string }
const { data: users } = await useFetch<WsUser[]>(() => `/api/tenants/${slug.value}/users`, {
key: 'sched-users', default: () => [], immediate: !!slug.value, watch: [slug],
})
const hostEmails = computed(() => new Set((hosts.value ?? []).map((h) => h.email)))
const takenSlugs = computed(() => new Set((hosts.value ?? []).map((h) => h.slug)))
// Only users with a mailbox, and not already a host.
const availableUsers = computed(() =>
(users.value ?? []).filter((u) => !!u.mailboxAddress && !hostEmails.value.has(u.mailboxAddress!)),
)
const SLUG_RE = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/
function slugify(name: string): string {
return name.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '')
}
function freeSlug(baseSlug: string): string {
if (!baseSlug || !takenSlugs.value.has(baseSlug)) return baseSlug
let i = 2
while (takenSlugs.value.has(`${baseSlug}-${i}`)) i++
return `${baseSlug}-${i}`
}
// ── Create host ──
const hostOpen = ref(false)
const hostBusy = ref(false)
const hostForm = reactive({ userId: '', slug: '', timezone: 'Europe/Copenhagen' })
const slugTaken = computed(() => takenSlugs.value.has(hostForm.slug))
const slugValid = computed(() => SLUG_RE.test(hostForm.slug))
function openHost() {
const first = availableUsers.value[0]
hostForm.userId = first?._id ?? ''
hostForm.slug = first ? freeSlug(slugify(first.name)) : ''
hostForm.timezone = 'Europe/Copenhagen'
hostOpen.value = true
}
// Re-derive a free slug from the chosen user's display name.
function onHostUserChange() {
const u = availableUsers.value.find((x) => x._id === hostForm.userId)
if (u) hostForm.slug = freeSlug(slugify(u.name))
}
async function submitHost() {
if (!hostForm.userId || !slugValid.value || slugTaken.value) return
hostBusy.value = true
try {
await request(`${base.value}/hosts`, { method: 'POST', body: { ...hostForm } })
toast.ok('Host created', 'Calendar access was provisioned automatically.')
hostOpen.value = false
await refreshHosts()
} catch (err) {
toastErr(err, 'Could not create host')
} finally {
hostBusy.value = false
}
}
// ── Create availability ──
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const availOpen = ref(false)
const availBusy = ref(false)
// Date-override editor rows mirror the backend shape but hold times as HH:MM
// for the <input type="time"> controls; we convert to minutes on submit.
interface OverrideRow { date: string; isUnavailable: boolean; start: string; end: string }
const availForm = reactive({
name: 'Working hours',
timezone: 'Europe/Copenhagen',
days: DAYS.map((_, i) => ({ enabled: i >= 1 && i <= 5, start: '09:00', end: '17:00' })),
overrides: [] as OverrideRow[],
})
const availEditingId = ref<string | null>(null)
function openAvail(a?: Availability) {
if (a) {
availEditingId.value = a._id
availForm.name = a.name
availForm.timezone = a.timezone
availForm.days = DAYS.map((_, i) => {
const intv = a.weeklyRules.find((r) => r.dayOfWeek === i)?.intervals?.[0]
return intv
? { enabled: true, start: minToTime(intv.startMinute), end: minToTime(intv.endMinute) }
: { enabled: false, start: '09:00', end: '17:00' }
})
availForm.overrides = (a.dateOverrides ?? []).map((o) => {
const intv = o.intervals?.[0]
return {
date: o.date,
isUnavailable: o.isUnavailable,
start: intv ? minToTime(intv.startMinute) : '09:00',
end: intv ? minToTime(intv.endMinute) : '17:00',
}
})
} else {
availEditingId.value = null
availForm.name = 'Working hours'
availForm.timezone = selectedHost.value?.timezone ?? 'Europe/Copenhagen'
availForm.days = DAYS.map((_, i) => ({ enabled: i >= 1 && i <= 5, start: '09:00', end: '17:00' }))
availForm.overrides = []
}
availOpen.value = true
}
function addOverride() {
availForm.overrides.push({ date: '', isUnavailable: false, start: '09:00', end: '17:00' })
}
function removeOverride(idx: number) {
availForm.overrides.splice(idx, 1)
}
const timeToMin = (t: string) => { const [h, m] = t.split(':').map(Number); return h * 60 + m }
async function submitAvail() {
if (!selectedHostId.value) return
availBusy.value = true
try {
const weeklyRules: WeeklyRule[] = availForm.days
.map((d, i) => ({ dayOfWeek: i, enabled: d.enabled, start: d.start, end: d.end }))
.filter((d) => d.enabled && timeToMin(d.end) > timeToMin(d.start))
.map((d) => ({ dayOfWeek: d.dayOfWeek, intervals: [{ startMinute: timeToMin(d.start), endMinute: timeToMin(d.end) }] }))
const dateOverrides: DateOverride[] = availForm.overrides
.filter((o) => /^\d{4}-\d{2}-\d{2}$/.test(o.date))
.filter((o) => o.isUnavailable || timeToMin(o.end) > timeToMin(o.start))
.map((o) => ({
date: o.date,
isUnavailable: o.isUnavailable,
intervals: o.isUnavailable ? [] : [{ startMinute: timeToMin(o.start), endMinute: timeToMin(o.end) }],
}))
const body = { name: availForm.name, timezone: availForm.timezone, weeklyRules, dateOverrides }
if (availEditingId.value) {
await request(`${base.value}/availability/${availEditingId.value}`, { method: 'PATCH', body })
toast.ok('Availability updated')
} else {
await request(`${base.value}/hosts/${selectedHostId.value}/availability`, { method: 'POST', body })
toast.ok('Availability saved')
}
availOpen.value = false
await loadHostData()
} catch (err) {
toastErr(err, 'Could not save availability')
} finally {
availBusy.value = false
}
}
function deleteAvail(a: Availability) {
askConfirm({
title: 'Delete availability schedule',
message: `Delete “${a.name}”? Event types using it must be reassigned or deleted first.`,
confirmLabel: 'Delete',
action: async () => {
await request(`${base.value}/availability/${a._id}`, { method: 'DELETE' })
toast.ok('Schedule deleted')
await loadHostData()
},
})
}
function cancelBookingAdmin(b: Booking) {
askConfirm({
title: 'Cancel booking',
message: `Cancel ${b.attendeeName}s booking? Theyll be emailed a cancellation and the calendar event is removed.`,
confirmLabel: 'Cancel booking',
action: async () => {
await request(`${base.value}/bookings/${b._id}/cancel`, { method: 'POST', body: { reason: 'Cancelled by host' } })
toast.ok('Booking cancelled')
await loadHostData()
},
})
}
// ── Create event type ──
const etOpen = ref(false)
const etBusy = ref(false)
const etForm = reactive({
title: '',
slug: '',
durationMinutes: 30,
slotIntervalMinutes: 15,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
minimumNoticeMinutes: 60,
maximumDaysInFuture: 60,
availabilityScheduleId: '',
locationType: 'jitsi',
ignoreAllDayEvents: true,
})
const etEditingId = ref<string | null>(null)
const etSlugTouched = ref(false)
const etSlugValid = computed(() => SLUG_RE.test(etForm.slug))
const etValid = computed(() => etForm.title.trim().length > 0 && etSlugValid.value && !!etForm.availabilityScheduleId)
function openEt(et?: EventType) {
if (et) {
etEditingId.value = et._id
Object.assign(etForm, {
title: et.title, slug: et.slug, durationMinutes: et.durationMinutes,
slotIntervalMinutes: et.slotIntervalMinutes, bufferBeforeMinutes: et.bufferBeforeMinutes,
bufferAfterMinutes: et.bufferAfterMinutes, minimumNoticeMinutes: et.minimumNoticeMinutes,
maximumDaysInFuture: et.maximumDaysInFuture, availabilityScheduleId: et.availabilityScheduleId,
locationType: et.locationType, ignoreAllDayEvents: et.ignoreAllDayEvents ?? true,
})
etSlugTouched.value = true // don't auto-rewrite an existing slug
} else {
etEditingId.value = null
Object.assign(etForm, {
title: '', slug: '', durationMinutes: 30, slotIntervalMinutes: 15,
bufferBeforeMinutes: 0, bufferAfterMinutes: 0, minimumNoticeMinutes: 60,
maximumDaysInFuture: 60, availabilityScheduleId: availability.value[0]?._id ?? '', locationType: 'jitsi',
ignoreAllDayEvents: true,
})
etSlugTouched.value = false
}
etOpen.value = true
}
// Auto-derive the slug from the title until the user edits the slug themselves.
function onEtTitle() {
if (!etSlugTouched.value) etForm.slug = slugify(etForm.title)
}
async function submitEt() {
if (!selectedHostId.value || !etValid.value) return
etBusy.value = true
try {
if (etEditingId.value) {
// slug is immutable after creation (public links would break) → omit it.
const { slug: _slug, ...patch } = { ...etForm }
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 } })
toast.ok('Event type created')
}
etOpen.value = false
await loadHostData()
} catch (err) {
toastErr(err, etEditingId.value ? 'Could not update event type' : 'Could not create event type')
} finally {
etBusy.value = false
}
}
function deleteEt(et: EventType) {
askConfirm({
title: 'Delete event type',
message: `Delete “${et.title}”? Its public page stops accepting new bookings. Existing bookings are unaffected.`,
confirmLabel: 'Delete',
action: async () => {
await request(`${base.value}/event-types/${et._id}`, { method: 'DELETE' })
toast.ok('Event type deleted')
await loadHostData()
},
})
}
// ── Reschedule a booking (admin) ──
interface DaySlots { key: string; label: string; slots: { startUtc: string }[] }
const reschedule = reactive({
open: false,
busy: false,
booking: null as Booking | null,
hostTz: 'Europe/Copenhagen',
state: 'idle' as 'idle' | 'loading' | 'ready' | 'error',
days: [] as DaySlots[],
selectedKey: '',
})
const reschedDay = computed(() => reschedule.days.find((d) => d.key === reschedule.selectedKey) ?? null)
async function openReschedule(b: Booking) {
const et = eventTypes.value.find((e) => e._id === b.eventTypeId)
if (!et || !selectedHost.value) {
toast.bad('Cannot reschedule', 'The event type for this booking no longer exists.')
return
}
Object.assign(reschedule, {
open: true, busy: false, booking: b, hostTz: selectedHost.value.timezone, state: 'loading', days: [], selectedKey: '',
})
try {
const from = new Date().toISOString()
const to = new Date(Date.now() + 14 * 86_400_000).toISOString()
const res = (await request(
`/api/scheduling-slots/${slug.value}/${selectedHost.value.slug}/${et.slug}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&timezone=${encodeURIComponent(reschedule.hostTz)}`,
)) as { slots: { startUtc: string }[] }
reschedule.days = groupByDay(res.slots, reschedule.hostTz)
reschedule.selectedKey = reschedule.days[0]?.key ?? ''
reschedule.state = 'ready'
} catch (err) {
reschedule.state = 'error'
toastErr(err, 'Could not load times')
}
}
function groupByDay(slots: { startUtc: string }[], tz: string): DaySlots[] {
const keyFmt = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' })
const labelFmt = new Intl.DateTimeFormat('en-GB', { timeZone: tz, weekday: 'short', day: 'numeric', month: 'short' })
const map = new Map<string, DaySlots>()
for (const s of slots) {
const d = new Date(s.startUtc)
const key = keyFmt.format(d)
if (!map.has(key)) map.set(key, { key, label: labelFmt.format(d), slots: [] })
map.get(key)!.slots.push(s)
}
return [...map.values()]
}
function reschedTime(iso: string): string {
return new Intl.DateTimeFormat('en-GB', { timeZone: reschedule.hostTz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(iso))
}
async function confirmReschedule(startUtc: string) {
if (!reschedule.booking) return
reschedule.busy = true
try {
await request(`${base.value}/bookings/${reschedule.booking._id}/reschedule`, { method: 'POST', body: { startUtc } })
toast.ok('Booking rescheduled', 'The attendee has been emailed the new time.')
reschedule.open = false
await loadHostData()
} catch (err) {
toastErr(err, 'Could not reschedule')
} finally {
reschedule.busy = false
}
}
function publicUrl(et: EventType): string {
return `${bookingBase}/${slug.value}/${selectedHost.value?.slug}/${et.slug}`
}
async function copyUrl(et: EventType) {
try {
await navigator.clipboard.writeText(publicUrl(et))
toast.ok('Link copied')
} catch {
toast.bad('Copy failed', publicUrl(et))
}
}
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
const detailTabs = computed(() => [
{ value: 'event-types', label: 'Event types', count: eventTypes.value.length },
{ value: 'availability', label: 'Availability', count: availability.value.length },
{ value: 'bookings', label: 'Bookings', count: bookings.value.length },
])
// ── Webhooks (tenant-level: signed POSTs on booking lifecycle) ──
interface Webhook { _id: string; url: string; secret: string; events: string[]; active: boolean }
const ALL_WEBHOOK_EVENTS = ['booking.created', 'booking.cancelled', 'booking.rescheduled']
const webhooks = ref<Webhook[]>([])
const webhooksLoaded = ref(false)
const revealedSecrets = reactive<Record<string, boolean>>({})
async function loadWebhooks() {
try {
webhooks.value = (await request(`${base.value}/webhooks`)) as Webhook[]
} catch (err) {
toastErr(err, 'Could not load webhooks')
} finally {
webhooksLoaded.value = true
}
}
if (slug.value) loadWebhooks()
watch(slug, (s) => { if (s) loadWebhooks() })
const whOpen = ref(false)
const whBusy = ref(false)
const whEditingId = ref<string | null>(null)
const whForm = reactive({ url: '', events: [...ALL_WEBHOOK_EVENTS], active: true })
const whUrlValid = computed(() => /^https?:\/\/.+/i.test(whForm.url.trim()))
function openWebhook(w?: Webhook) {
if (w) {
whEditingId.value = w._id
Object.assign(whForm, { url: w.url, events: [...w.events], active: w.active })
} else {
whEditingId.value = null
Object.assign(whForm, { url: '', events: [...ALL_WEBHOOK_EVENTS], active: true })
}
whOpen.value = true
}
function toggleWhEvent(ev: string) {
const i = whForm.events.indexOf(ev)
if (i === -1) whForm.events.push(ev)
else whForm.events.splice(i, 1)
}
async function submitWebhook() {
if (!whUrlValid.value || !whForm.events.length) return
whBusy.value = true
try {
if (whEditingId.value) {
await request(`${base.value}/webhooks/${whEditingId.value}`, {
method: 'PATCH',
body: { url: whForm.url.trim(), events: whForm.events, active: whForm.active },
})
toast.ok('Webhook updated')
} else {
await request(`${base.value}/webhooks`, { method: 'POST', body: { url: whForm.url.trim(), events: whForm.events } })
toast.ok('Webhook created', 'Copy the signing secret now — keep it safe.')
}
whOpen.value = false
await loadWebhooks()
} catch (err) {
toastErr(err, 'Could not save webhook')
} finally {
whBusy.value = false
}
}
function deleteWebhook(w: Webhook) {
askConfirm({
title: 'Delete webhook',
message: `Stop sending booking events to ${w.url}?`,
confirmLabel: 'Delete',
action: async () => {
await request(`${base.value}/webhooks/${w._id}`, { method: 'DELETE' })
await loadWebhooks()
toast.ok('Webhook deleted')
},
})
}
function rotateWebhookSecret(w: Webhook) {
askConfirm({
title: 'Rotate signing secret',
message: 'The old secret stops working immediately. Update your receiver after rotating.',
confirmLabel: 'Rotate',
action: async () => {
await request(`${base.value}/webhooks/${w._id}/rotate-secret`, { method: 'POST' })
revealedSecrets[w._id] = true
await loadWebhooks()
toast.ok('Secret rotated', 'Copy the new signing secret now.')
},
})
}
async function copySecret(secret: string) {
try {
await navigator.clipboard.writeText(secret)
toast.ok('Secret copied')
} catch {
toast.bad('Copy failed')
}
}
const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}${s.slice(-4)}` : s)
</script>
<template>
<div>
<PageHeader eyebrow="Workspace" title="Scheduling" subtitle="Public booking pages backed by your team's calendars.">
<template #actions>
<UiButton variant="primary" @click="openHost">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add bookable host
</UiButton>
</template>
</PageHeader>
<div class="content">
<Card v-if="!hosts || !hosts.length" class="notice">
No bookable hosts yet. Add a host to create their booking pages calendar access is provisioned automatically.
</Card>
<div v-else class="layout">
<!-- Hosts list -->
<aside class="hosts">
<button
v-for="h in hosts"
:key="h._id"
class="hostrow"
:class="{ active: h._id === selectedHostId }"
@click="selectHost(h._id)"
>
<div class="hname">{{ h.displayName }}</div>
<div class="hmail">{{ h.email }}</div>
<Badge :tone="h.isActive ? 'ok' : 'neutral'" dot>{{ h.isActive ? 'active' : 'inactive' }}</Badge>
</button>
</aside>
<!-- Detail -->
<section class="detail">
<Card v-if="!selectedHost" class="notice">Select a host to manage their event types and availability.</Card>
<template v-else>
<Tabs v-model="detailTab" :items="detailTabs" />
<!-- Event types -->
<div v-if="detailTab === 'event-types'" class="pane">
<div class="panehead">
<span class="mute">{{ eventTypes.length }} event type(s)</span>
<UiButton variant="secondary" :disabled="!availability.length" @click="openEt">New event type</UiButton>
</div>
<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>
<a class="link" :href="publicUrl(et)" target="_blank" rel="noopener">{{ publicUrl(et) }}</a>
</div>
<div class="itemactions">
<UiButton variant="ghost" @click="copyUrl(et)"><UiIcon name="copy" :size="14" /> Copy</UiButton>
<UiButton variant="ghost" @click="openEt(et)">Edit</UiButton>
<UiButton variant="ghost" @click="deleteEt(et)">Delete</UiButton>
</div>
</Card>
</div>
<!-- Availability -->
<div v-else-if="detailTab === 'availability'" class="pane">
<div class="panehead">
<span class="mute">{{ availability.length }} schedule(s)</span>
<UiButton variant="secondary" @click="openAvail">New schedule</UiButton>
</div>
<Card v-for="a in availability" :key="a._id" class="item">
<div class="itemmain">
<div class="ititle">{{ a.name }} <span class="mute">· {{ a.timezone }}</span></div>
<div class="mute small">
{{ a.weeklyRules.map((r) => DAYS[r.dayOfWeek]).join(', ') || 'No days set' }}
</div>
</div>
<div class="itemactions">
<UiButton variant="ghost" @click="openAvail(a)">Edit</UiButton>
<UiButton variant="ghost" @click="deleteAvail(a)">Delete</UiButton>
</div>
</Card>
</div>
<!-- Bookings -->
<div v-else class="pane">
<Card v-if="!bookings.length" class="notice">No bookings yet.</Card>
<Card v-for="b in bookings" :key="b._id" class="item">
<div class="itemmain">
<div class="ititle">{{ b.attendeeName }} <span class="mute">· {{ b.attendeeEmail }}</span></div>
<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>
<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>
</div>
</Card>
</div>
</template>
</section>
</div>
<!-- Webhooks (tenant-level) -->
<section class="webhooks">
<div class="whhead">
<div>
<Eyebrow>Integrations</Eyebrow>
<h2 class="whtitle">Webhooks</h2>
<p class="mute small">Receive signed POSTs when bookings are created, cancelled or rescheduled. Verify the HMAC-SHA256 signature in the <code>X-Dezky-Signature</code> header using the signing secret.</p>
</div>
<UiButton variant="secondary" @click="openWebhook()">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add webhook
</UiButton>
</div>
<Card v-if="webhooksLoaded && !webhooks.length" class="notice">
No webhooks yet. Add one to forward booking lifecycle events to your own systems.
</Card>
<Card v-for="w in webhooks" :key="w._id" class="item">
<div class="itemmain">
<div class="ititle">
{{ w.url }}
<Badge :tone="w.active ? 'ok' : 'neutral'" dot>{{ w.active ? 'active' : 'paused' }}</Badge>
</div>
<div class="mute small whevents">
<Badge v-for="ev in w.events" :key="ev" tone="neutral">{{ ev }}</Badge>
</div>
<div class="secretrow mute small">
<span class="secretlabel">Signing secret</span>
<code>{{ revealedSecrets[w._id] ? w.secret : maskSecret(w.secret) }}</code>
<button class="linkbtn" @click="revealedSecrets[w._id] = !revealedSecrets[w._id]">
{{ revealedSecrets[w._id] ? 'Hide' : 'Reveal' }}
</button>
<button class="linkbtn" @click="copySecret(w.secret)">Copy</button>
</div>
</div>
<div class="itemactions">
<UiButton variant="ghost" @click="openWebhook(w)">Edit</UiButton>
<UiButton variant="ghost" @click="rotateWebhookSecret(w)">Rotate secret</UiButton>
<UiButton variant="ghost" @click="deleteWebhook(w)">Delete</UiButton>
</div>
</Card>
</section>
</div>
<!-- Webhook modal -->
<Modal
:open="whOpen"
eyebrow="Integrations"
:title="whEditingId ? 'Edit webhook' : 'Add webhook'"
size="md"
@close="whOpen = false"
>
<div class="form-stack">
<label class="field"><Eyebrow>Endpoint URL</Eyebrow>
<input class="input" v-model="whForm.url" placeholder="https://example.com/hooks/dezky" />
<span class="slughint" :class="{ bad: !!whForm.url && !whUrlValid }">
<template v-if="!whForm.url">We POST a signed JSON body here for each subscribed event.</template>
<template v-else-if="!whUrlValid">Enter a valid http(s) URL.</template>
<template v-else>Each delivery carries the <code>X-Dezky-Signature</code> HMAC header.</template>
</span>
</label>
<div class="field"><Eyebrow>Events</Eyebrow>
<label v-for="ev in ALL_WEBHOOK_EVENTS" :key="ev" class="checkrow">
<input type="checkbox" :checked="whForm.events.includes(ev)" @change="toggleWhEvent(ev)" />
<code>{{ ev }}</code>
</label>
<span class="slughint" :class="{ bad: !whForm.events.length }" v-if="!whForm.events.length">Select at least one event.</span>
</div>
<label v-if="whEditingId" class="checkrow">
<input type="checkbox" v-model="whForm.active" />
Active (deliver events)
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="whOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="whBusy || !whUrlValid || !whForm.events.length" @click="submitWebhook">
{{ whEditingId ? 'Save' : 'Create webhook' }}
</UiButton>
</template>
</Modal>
<!-- Add host modal -->
<Modal :open="hostOpen" eyebrow="Scheduling" title="Add bookable host" size="md" @close="hostOpen = false">
<div class="form-stack">
<p v-if="!availableUsers.length" class="hint">
No eligible users. A bookable host needs a workspace mailbox (and isn't already a host) add a user with a mailbox under Users &amp; groups first.
</p>
<template v-else>
<label class="field"><Eyebrow>User</Eyebrow>
<select class="input" v-model="hostForm.userId" @change="onHostUserChange">
<option v-for="u in availableUsers" :key="u._id" :value="u._id">{{ u.name }} {{ u.mailboxAddress }}</option>
</select>
<span class="slughint">Calendar access is provisioned automatically no connect step.</span>
</label>
<label class="field"><Eyebrow>URL slug</Eyebrow>
<input class="input" v-model="hostForm.slug" placeholder="anne" />
<span class="slughint" :class="{ bad: !!hostForm.slug && (slugTaken || !slugValid) }">
<template v-if="!hostForm.slug">Used in the public booking link.</template>
<template v-else-if="!slugValid">Lowercase letters, numbers and hyphens (240 chars).</template>
<template v-else-if="slugTaken">{{ hostForm.slug }} is already taken.</template>
<template v-else>booking.dezky.local/{{ slug }}/{{ hostForm.slug }} · available </template>
</span>
</label>
<label class="field"><Eyebrow>Timezone</Eyebrow>
<select class="input" v-model="hostForm.timezone">
<option>Europe/Copenhagen</option><option>Europe/London</option><option>Europe/Berlin</option><option>UTC</option>
</select>
</label>
</template>
</div>
<template #footer>
<UiButton variant="ghost" @click="hostOpen = false">Cancel</UiButton>
<UiButton
variant="primary"
:disabled="hostBusy || !availableUsers.length || !hostForm.userId || slugTaken || !slugValid"
@click="submitHost"
>{{ hostBusy ? 'Provisioning…' : 'Create host' }}</UiButton>
</template>
</Modal>
<!-- Availability modal -->
<Modal :open="availOpen" eyebrow="Scheduling" :title="availEditingId ? 'Edit availability schedule' : 'New availability schedule'" size="md" @close="availOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Name</Eyebrow><input class="input" v-model="availForm.name" /></label>
<label class="field"><Eyebrow>Timezone</Eyebrow>
<select class="input" v-model="availForm.timezone">
<option>Europe/Copenhagen</option><option>Europe/London</option><option>Europe/Berlin</option><option>UTC</option>
</select>
</label>
<div class="weekgrid">
<div v-for="(d, i) in availForm.days" :key="i" class="weekrow">
<label class="daytoggle"><input type="checkbox" v-model="d.enabled" /> {{ DAYS[i] }}</label>
<input class="input time" type="time" v-model="d.start" :disabled="!d.enabled" />
<span class="dash"></span>
<input class="input time" type="time" v-model="d.end" :disabled="!d.enabled" />
</div>
</div>
<div class="overrides">
<div class="overrides-head">
<Eyebrow>Date overrides</Eyebrow>
<UiButton variant="ghost" size="sm" @click="addOverride">Add override</UiButton>
</div>
<p class="mute small" v-if="!availForm.overrides.length">Block specific dates or set custom hours that replace the weekly rules.</p>
<div v-for="(o, i) in availForm.overrides" :key="i" class="overrow">
<input class="input date" type="date" v-model="o.date" />
<label class="daytoggle"><input type="checkbox" v-model="o.isUnavailable" /> Unavailable</label>
<input class="input time" type="time" v-model="o.start" :disabled="o.isUnavailable" />
<span class="dash"></span>
<input class="input time" type="time" v-model="o.end" :disabled="o.isUnavailable" />
<button type="button" class="removeov" aria-label="Remove override" @click="removeOverride(i)"><UiIcon name="x" /></button>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="availOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="availBusy" @click="submitAvail">{{ availBusy ? 'Saving' : 'Save' }}</UiButton>
</template>
</Modal>
<!-- Event type modal -->
<Modal :open="etOpen" eyebrow="Scheduling" :title="etEditingId ? 'Edit event type' : 'New event type'" size="md" @close="etOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Title</Eyebrow><input class="input" v-model="etForm.title" @input="onEtTitle" placeholder="30-min consultation" /></label>
<label class="field"><Eyebrow>URL slug</Eyebrow>
<input class="input" v-model="etForm.slug" :disabled="!!etEditingId" @input="etSlugTouched = true" placeholder="consult" />
<span class="slughint" :class="{ bad: !etEditingId && !!etForm.slug && !etSlugValid }">
<template v-if="etEditingId">The slug cant change after creation (it would break public links).</template>
<template v-else-if="!etForm.slug">Part of the public booking link.</template>
<template v-else-if="!etSlugValid">Lowercase letters, numbers and hyphens (240 chars).</template>
<template v-else>/{{ selectedHost?.slug }}/{{ etForm.slug }}</template>
</span>
</label>
<div class="row2">
<label class="field"><Eyebrow>Duration (min)</Eyebrow><input class="input" type="number" v-model.number="etForm.durationMinutes" /></label>
<label class="field"><Eyebrow>Slot step (min)</Eyebrow><input class="input" type="number" v-model.number="etForm.slotIntervalMinutes" /></label>
</div>
<div class="row2">
<label class="field"><Eyebrow>Buffer before</Eyebrow><input class="input" type="number" v-model.number="etForm.bufferBeforeMinutes" /></label>
<label class="field"><Eyebrow>Buffer after</Eyebrow><input class="input" type="number" v-model.number="etForm.bufferAfterMinutes" /></label>
</div>
<div class="row2">
<label class="field"><Eyebrow>Min notice (min)</Eyebrow><input class="input" type="number" v-model.number="etForm.minimumNoticeMinutes" /></label>
<label class="field"><Eyebrow>Horizon (days)</Eyebrow><input class="input" type="number" v-model.number="etForm.maximumDaysInFuture" /></label>
</div>
<label class="field"><Eyebrow>Availability schedule</Eyebrow>
<select class="input" v-model="etForm.availabilityScheduleId">
<option v-for="a in availability" :key="a._id" :value="a._id">{{ a.name }}</option>
</select>
</label>
<label class="field"><Eyebrow>Location</Eyebrow>
<select class="input" v-model="etForm.locationType">
<option value="jitsi">dezky Meet (video)</option><option value="phone">Phone</option>
<option value="in_person">In person</option><option value="custom">Custom</option>
</select>
</label>
<label class="daytoggle">
<input type="checkbox" v-model="etForm.ignoreAllDayEvents" />
Ignore all-day events when checking availability
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="etOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="etBusy || !etValid" @click="submitEt">{{ etBusy ? 'Saving' : etEditingId ? 'Save changes' : 'Create' }}</UiButton>
</template>
</Modal>
<!-- Reschedule modal -->
<Modal :open="reschedule.open" eyebrow="Scheduling" title="Reschedule booking" size="md" @close="reschedule.open = false">
<div class="form-stack">
<p v-if="reschedule.booking" class="mute small">
{{ reschedule.booking.attendeeName }} currently {{ fmtDateTime(reschedule.booking.startUtc) }}.
Pick a new time (shown in {{ reschedule.hostTz }}).
</p>
<p v-if="reschedule.state === 'loading'" class="mute">Loading times</p>
<p v-else-if="reschedule.state === 'error'" class="err">Couldnt load available times.</p>
<template v-else-if="reschedule.state === 'ready'">
<p v-if="!reschedule.days.length" class="mute">No available times in the next 14 days.</p>
<template v-else>
<div class="days">
<button
v-for="d in reschedule.days"
:key="d.key"
class="day"
:class="{ active: d.key === reschedule.selectedKey }"
@click="reschedule.selectedKey = d.key"
>
<span class="dlabel">{{ d.label }}</span>
<span class="dcount">{{ d.slots.length }}</span>
</button>
</div>
<div v-if="reschedDay" class="slotgrid">
<button
v-for="s in reschedDay.slots"
:key="s.startUtc"
class="slotbtn"
:disabled="reschedule.busy"
@click="confirmReschedule(s.startUtc)"
>{{ reschedTime(s.startUtc) }}</button>
</div>
</template>
</template>
</div>
<template #footer>
<UiButton variant="ghost" @click="reschedule.open = false">Close</UiButton>
</template>
</Modal>
<ConfirmDialog
:open="confirmBox.open"
eyebrow="Scheduling"
:title="confirmBox.title"
:confirm-label="confirmBox.confirmLabel"
:busy="confirmBox.busy"
tone="danger"
@close="confirmBox.open = false"
@confirm="runConfirm"
>
{{ confirmBox.message }}
</ConfirmDialog>
</div>
</template>
<style scoped>
.content { padding: 20px 40px 64px; }
.notice { display: flex; align-items: center; gap: 10px; color: var(--text-mute); padding: 18px; }
.layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; }
.hosts { display: flex; flex-direction: column; gap: 8px; }
.hostrow { text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; cursor: pointer; display: flex; flex-direction: column; gap: 4px; }
.hostrow.active { border-color: var(--text); box-shadow: inset 0 0 0 1px var(--text); }
.hname { font-weight: 600; }
.hmail { font-size: 12px; color: var(--text-mute); }
.detail { min-width: 0; }
.pane { margin-top: 14px; display: flex; flex-direction: column; gap: 10px; }
.panehead { display: flex; align-items: center; justify-content: space-between; }
.item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; }
.itemmain { min-width: 0; }
.itemactions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.ititle { font-weight: 600; }
.link { font-size: 12px; color: var(--text-mute); word-break: break-all; }
.mute { color: var(--text-mute); }
.small { font-size: 12px; }
.hint { color: var(--warn, #9a6a00); font-size: 13px; }
.slughint { font-size: 12px; color: var(--text-mute); }
.slughint.bad { color: var(--bad, #c0362c); }
.err { color: var(--bad, #c0362c); font-size: 13px; }
.days { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; }
.day { flex: 0 0 auto; min-width: 78px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.day.active { border-color: var(--text); box-shadow: inset 0 0 0 1px var(--text); }
.dlabel { font-size: 12px; }
.dcount { font-size: 11px; color: var(--text-mute); }
.slotgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); gap: 8px; margin-top: 12px; }
.slotbtn { height: 38px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); font-size: 14px; font-weight: 600; color: var(--text); cursor: pointer; }
.slotbtn:hover:not(:disabled) { border-color: var(--text); }
.slotbtn:disabled { opacity: .5; cursor: not-allowed; }
.form-stack { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-size: 13px; color: var(--text); }
.weekgrid { display: flex; flex-direction: column; gap: 8px; }
.weekrow { display: grid; grid-template-columns: 90px 1fr auto 1fr; align-items: center; gap: 8px; }
.daytoggle { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.time { width: 100%; }
.dash { text-align: center; color: var(--text-mute); }
.overrides { display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); padding-top: 12px; }
.overrides-head { display: flex; align-items: center; justify-content: space-between; }
.small { font-size: 12px; }
.overrow { display: grid; grid-template-columns: 150px auto 1fr auto 1fr auto; align-items: center; gap: 8px; }
.date { width: 100%; }
.removeov { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text-mute); cursor: pointer; }
.removeov:hover { background: rgba(226, 48, 48, 0.08); color: var(--bad); }
.webhooks { margin-top: 32px; border-top: 1px solid var(--border); padding-top: 24px; display: flex; flex-direction: column; gap: 10px; }
.whhead { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 4px; }
.whtitle { font-size: 16px; font-weight: 600; margin: 4px 0 6px; }
.whhead p { max-width: 620px; }
.whevents { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.secretrow { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.secretlabel { font-weight: 600; }
.checkrow { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.linkbtn { background: none; border: none; padding: 0; color: var(--text); font-size: 12px; text-decoration: underline; cursor: pointer; }
code { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; }
</style>