723 lines
32 KiB
Vue
723 lines
32 KiB
Vue
<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 Availability { _id: string; name: string; timezone: string; weeklyRules: WeeklyRule[] }
|
||
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)
|
||
const availForm = reactive({
|
||
name: 'Working hours',
|
||
timezone: 'Europe/Copenhagen',
|
||
days: DAYS.map((_, i) => ({ enabled: i >= 1 && i <= 5, start: '09:00', end: '17:00' })),
|
||
})
|
||
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' }
|
||
})
|
||
} 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' }))
|
||
}
|
||
availOpen.value = true
|
||
}
|
||
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 body = { name: availForm.name, timezone: availForm.timezone, weeklyRules }
|
||
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? They’ll 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 },
|
||
])
|
||
</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>
|
||
</div>
|
||
|
||
<!-- 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 & 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 (2–40 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>
|
||
<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 can’t 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 (2–40 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">Couldn’t 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); }
|
||
</style>
|