feat(scheduling): tenant scheduling overview/analytics
This commit is contained in:
@@ -78,6 +78,50 @@ async function runConfirm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Overview / analytics (read-only, tenant-level) ──
|
||||||
|
interface HostCount { hostId: string; displayName: string; count: number }
|
||||||
|
interface DayPoint { date: string; count: number }
|
||||||
|
interface SchedulingOverview {
|
||||||
|
totalBookings: number
|
||||||
|
upcomingCount: number
|
||||||
|
byStatus: Record<string, number>
|
||||||
|
byHost: HostCount[]
|
||||||
|
last30Days: DayPoint[]
|
||||||
|
}
|
||||||
|
const overview = ref<SchedulingOverview | null>(null)
|
||||||
|
async function loadOverview() {
|
||||||
|
try {
|
||||||
|
overview.value = (await request(`${base.value}/overview`)) as SchedulingOverview
|
||||||
|
} catch (err) {
|
||||||
|
toastErr(err, 'Could not load overview')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slug.value) loadOverview()
|
||||||
|
watch(slug, (s) => { if (s) loadOverview() })
|
||||||
|
|
||||||
|
// Series scaling for the simple bar sparkline.
|
||||||
|
const seriesMax = computed(() => Math.max(1, ...(overview.value?.last30Days ?? []).map((d) => d.count)))
|
||||||
|
function barHeight(count: number): number {
|
||||||
|
// Min 2% so empty days still show a faint baseline tick.
|
||||||
|
return Math.max(2, Math.round((count / seriesMax.value) * 100))
|
||||||
|
}
|
||||||
|
const dayLabel = (date: string) =>
|
||||||
|
new Date(`${date}T00:00:00Z`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
|
// Statuses shown in the breakdown, in a sensible reading order.
|
||||||
|
const STATUS_ORDER = ['confirmed', 'pending', 'rescheduled', 'cancelled', 'calendar_failed'] as const
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
confirmed: 'Confirmed',
|
||||||
|
pending: 'Pending',
|
||||||
|
rescheduled: 'Rescheduled',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
calendar_failed: 'Calendar failed',
|
||||||
|
}
|
||||||
|
const statusBreakdown = computed(() =>
|
||||||
|
STATUS_ORDER.map((s) => ({ status: s, label: statusLabel[s], count: overview.value?.byStatus?.[s] ?? 0 })).filter(
|
||||||
|
(r) => r.count > 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const { data: hosts, refresh: refreshHosts } = await useFetch<Host[]>(() => `${base.value}/hosts`, {
|
const { data: hosts, refresh: refreshHosts } = await useFetch<Host[]>(() => `${base.value}/hosts`, {
|
||||||
key: 'sched-hosts',
|
key: 'sched-hosts',
|
||||||
default: () => [],
|
default: () => [],
|
||||||
@@ -565,6 +609,70 @@ const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}…${s.slice
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
<!-- Overview / analytics (read-only) -->
|
||||||
|
<section v-if="overview" class="overview">
|
||||||
|
<div class="statcards">
|
||||||
|
<Card class="statcard">
|
||||||
|
<div class="statlabel">Total bookings</div>
|
||||||
|
<div class="statvalue">{{ overview.totalBookings }}</div>
|
||||||
|
</Card>
|
||||||
|
<Card class="statcard">
|
||||||
|
<div class="statlabel">Upcoming</div>
|
||||||
|
<div class="statvalue">{{ overview.upcomingCount }}</div>
|
||||||
|
<div class="statsub mute small">confirmed, in the future</div>
|
||||||
|
</Card>
|
||||||
|
<Card class="statcard">
|
||||||
|
<div class="statlabel">Active hosts</div>
|
||||||
|
<div class="statvalue">{{ overview.byHost.length }}</div>
|
||||||
|
<div class="statsub mute small">with bookings</div>
|
||||||
|
</Card>
|
||||||
|
<Card class="statcard">
|
||||||
|
<div class="statlabel">Last 30 days</div>
|
||||||
|
<div class="statvalue">{{ overview.last30Days.reduce((s, d) => s + d.count, 0) }}</div>
|
||||||
|
<div class="statsub mute small">bookings created</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overgrid">
|
||||||
|
<Card class="chartcard">
|
||||||
|
<div class="cardhead">
|
||||||
|
<Eyebrow>Bookings created · last 30 days</Eyebrow>
|
||||||
|
</div>
|
||||||
|
<div class="spark">
|
||||||
|
<div
|
||||||
|
v-for="d in overview.last30Days"
|
||||||
|
:key="d.date"
|
||||||
|
class="sparkbar"
|
||||||
|
:style="{ height: barHeight(d.count) + '%' }"
|
||||||
|
:title="`${dayLabel(d.date)}: ${d.count}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="brkcard">
|
||||||
|
<div class="cardhead"><Eyebrow>By status</Eyebrow></div>
|
||||||
|
<p v-if="!statusBreakdown.length" class="mute small">No bookings yet.</p>
|
||||||
|
<div v-else class="brklist">
|
||||||
|
<div v-for="r in statusBreakdown" :key="r.status" class="brkrow">
|
||||||
|
<Badge :tone="r.status === 'confirmed' ? 'ok' : r.status === 'cancelled' || r.status === 'calendar_failed' ? 'bad' : 'neutral'">{{ r.label }}</Badge>
|
||||||
|
<span class="brkcount">{{ r.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="brkcard">
|
||||||
|
<div class="cardhead"><Eyebrow>By host</Eyebrow></div>
|
||||||
|
<p v-if="!overview.byHost.length" class="mute small">No bookings yet.</p>
|
||||||
|
<div v-else class="brklist">
|
||||||
|
<div v-for="h in overview.byHost" :key="h.hostId" class="brkrow">
|
||||||
|
<span class="brkname">{{ h.displayName }}</span>
|
||||||
|
<span class="brkcount">{{ h.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<Card v-if="!hosts || !hosts.length" class="notice">
|
<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.
|
No bookable hosts yet. Add a host to create their booking pages — calendar access is provisioned automatically.
|
||||||
</Card>
|
</Card>
|
||||||
@@ -942,6 +1050,26 @@ const maskSecret = (s: string) => (s.length > 12 ? `${s.slice(0, 9)}…${s.slice
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.content { padding: 20px 40px 64px; }
|
.content { padding: 20px 40px 64px; }
|
||||||
.notice { display: flex; align-items: center; gap: 10px; color: var(--text-mute); padding: 18px; }
|
.notice { display: flex; align-items: center; gap: 10px; color: var(--text-mute); padding: 18px; }
|
||||||
|
.overview { display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px; }
|
||||||
|
.statcards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||||||
|
.statcard { padding: 16px 18px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.statlabel { font-size: 12px; color: var(--text-mute); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.statvalue { font-size: 28px; font-weight: 700; line-height: 1.1; }
|
||||||
|
.statsub { margin-top: 2px; }
|
||||||
|
.overgrid { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 14px; }
|
||||||
|
.chartcard, .brkcard { padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.cardhead { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.spark { display: flex; align-items: flex-end; gap: 2px; height: 96px; }
|
||||||
|
.sparkbar { flex: 1 1 0; min-width: 0; background: var(--text); opacity: 0.7; border-radius: 2px 2px 0 0; transition: opacity 0.15s; }
|
||||||
|
.sparkbar:hover { opacity: 1; }
|
||||||
|
.brklist { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.brkrow { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
|
.brkname { font-size: 13px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.brkcount { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.statcards { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.overgrid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
.layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; }
|
.layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; }
|
||||||
.hosts { display: flex; flex-direction: column; gap: 8px; }
|
.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 { 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; }
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Injectable } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model, Types } from 'mongoose'
|
||||||
|
import { Booking, BookingDocument, BookingStatus } from '../../schemas/booking.schema.js'
|
||||||
|
import { Host, HostDocument } from '../../schemas/scheduling-host.schema.js'
|
||||||
|
|
||||||
|
// Read-only analytics for the tenant scheduling admin (Phase 3). All figures are
|
||||||
|
// derived from the bookings collection scoped to a single tenant; nothing here
|
||||||
|
// mutates state. The per-day series powers a small sparkline-style chart, and the
|
||||||
|
// per-host breakdown lets an admin see who is carrying the booking load.
|
||||||
|
|
||||||
|
const SERIES_DAYS = 30
|
||||||
|
|
||||||
|
export interface HostCount {
|
||||||
|
hostId: string
|
||||||
|
displayName: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayPoint {
|
||||||
|
date: string // YYYY-MM-DD (UTC)
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulingOverview {
|
||||||
|
totalBookings: number
|
||||||
|
upcomingCount: number
|
||||||
|
byStatus: Record<BookingStatus, number>
|
||||||
|
byHost: HostCount[]
|
||||||
|
last30Days: DayPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OverviewService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Booking.name) private readonly bookingModel: Model<BookingDocument>,
|
||||||
|
@InjectModel(Host.name) private readonly hostModel: Model<HostDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async forTenant(tenantId: Types.ObjectId): Promise<SchedulingOverview> {
|
||||||
|
const now = new Date()
|
||||||
|
// Window start = midnight UTC, (SERIES_DAYS - 1) days ago, so the series
|
||||||
|
// contains exactly SERIES_DAYS calendar days ending today.
|
||||||
|
const windowStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
||||||
|
windowStart.setUTCDate(windowStart.getUTCDate() - (SERIES_DAYS - 1))
|
||||||
|
|
||||||
|
// Run the aggregates concurrently — they're independent reads.
|
||||||
|
const [statusAgg, hostAgg, dayAgg, upcomingCount, hosts] = await Promise.all([
|
||||||
|
// Counts per status.
|
||||||
|
this.bookingModel
|
||||||
|
.aggregate<{ _id: BookingStatus; count: number }>([
|
||||||
|
{ $match: { tenantId } },
|
||||||
|
{ $group: { _id: '$status', count: { $sum: 1 } } },
|
||||||
|
])
|
||||||
|
.exec(),
|
||||||
|
// Counts per host (any status).
|
||||||
|
this.bookingModel
|
||||||
|
.aggregate<{ _id: Types.ObjectId; count: number }>([
|
||||||
|
{ $match: { tenantId } },
|
||||||
|
{ $group: { _id: '$hostId', count: { $sum: 1 } } },
|
||||||
|
])
|
||||||
|
.exec(),
|
||||||
|
// Bookings created per day over the trailing window (by createdAt).
|
||||||
|
this.bookingModel
|
||||||
|
.aggregate<{ _id: string; count: number }>([
|
||||||
|
{ $match: { tenantId, createdAt: { $gte: windowStart } } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt', timezone: 'UTC' } },
|
||||||
|
count: { $sum: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.exec(),
|
||||||
|
// Confirmed bookings still in the future.
|
||||||
|
this.bookingModel.countDocuments({ tenantId, status: 'confirmed', startUtc: { $gte: now } }).exec(),
|
||||||
|
// Host display names for the breakdown labels.
|
||||||
|
this.hostModel.find({ tenantId }).select('_id displayName').exec(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const byStatus: Record<BookingStatus, number> = {
|
||||||
|
pending: 0,
|
||||||
|
confirmed: 0,
|
||||||
|
cancelled: 0,
|
||||||
|
rescheduled: 0,
|
||||||
|
calendar_failed: 0,
|
||||||
|
}
|
||||||
|
let totalBookings = 0
|
||||||
|
for (const row of statusAgg) {
|
||||||
|
if (row._id in byStatus) byStatus[row._id] = row.count
|
||||||
|
totalBookings += row.count
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameById = new Map(hosts.map((h) => [h._id.toString(), h.displayName]))
|
||||||
|
const byHost: HostCount[] = hostAgg
|
||||||
|
.map((row) => ({
|
||||||
|
hostId: row._id.toString(),
|
||||||
|
displayName: nameById.get(row._id.toString()) ?? 'Unknown host',
|
||||||
|
count: row.count,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
// Fill the day series so every day in the window has a point (zero when no
|
||||||
|
// bookings), keeping the front-end chart simple.
|
||||||
|
const countByDate = new Map(dayAgg.map((row) => [row._id, row.count]))
|
||||||
|
const last30Days: DayPoint[] = []
|
||||||
|
for (let i = 0; i < SERIES_DAYS; i++) {
|
||||||
|
const d = new Date(windowStart)
|
||||||
|
d.setUTCDate(d.getUTCDate() + i)
|
||||||
|
const date = d.toISOString().slice(0, 10)
|
||||||
|
last30Days.push({ date, count: countByDate.get(date) ?? 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalBookings, upcomingCount, byStatus, byHost, last30Days }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { CreateEventTypeDto, UpdateEventTypeDto } from './event-types/dto/event-
|
|||||||
import { EventTypesService } from './event-types/event-types.service.js'
|
import { EventTypesService } from './event-types/event-types.service.js'
|
||||||
import { CreateHostDto, SetHostActiveDto } from './hosts/dto/create-host.dto.js'
|
import { CreateHostDto, SetHostActiveDto } from './hosts/dto/create-host.dto.js'
|
||||||
import { HostsService } from './hosts/hosts.service.js'
|
import { HostsService } from './hosts/hosts.service.js'
|
||||||
|
import { OverviewService } from './overview/overview.service.js'
|
||||||
|
|
||||||
// Authenticated host/admin scheduling config (OIDC via Authentik). Tenant-scoped
|
// Authenticated host/admin scheduling config (OIDC via Authentik). Tenant-scoped
|
||||||
// and gated exactly like the rest of platform-api: platformAdmin OR a member of
|
// and gated exactly like the rest of platform-api: platformAdmin OR a member of
|
||||||
@@ -39,6 +40,7 @@ export class SchedulingAdminController {
|
|||||||
private readonly availability: AvailabilityService,
|
private readonly availability: AvailabilityService,
|
||||||
private readonly eventTypes: EventTypesService,
|
private readonly eventTypes: EventTypesService,
|
||||||
private readonly bookings: BookingsService,
|
private readonly bookings: BookingsService,
|
||||||
|
private readonly overview: OverviewService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async gate(slug: string, jwt: AuthentikJwtPayload): Promise<Types.ObjectId> {
|
private async gate(slug: string, jwt: AuthentikJwtPayload): Promise<Types.ObjectId> {
|
||||||
@@ -50,6 +52,12 @@ export class SchedulingAdminController {
|
|||||||
return tenant._id
|
return tenant._id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Overview / analytics (read-only) ──
|
||||||
|
@Get('overview')
|
||||||
|
async getOverview(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
return this.overview.forTenant(await this.gate(slug, jwt))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Hosts ──
|
// ── Hosts ──
|
||||||
@Get('hosts')
|
@Get('hosts')
|
||||||
async listHosts(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
async listHosts(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { CalendarRetryWorker } from './bookings/calendar-retry.worker.js'
|
|||||||
import { JmapMailer } from './email/jmap-mailer.service.js'
|
import { JmapMailer } from './email/jmap-mailer.service.js'
|
||||||
import { EventTypesService } from './event-types/event-types.service.js'
|
import { EventTypesService } from './event-types/event-types.service.js'
|
||||||
import { HostsService } from './hosts/hosts.service.js'
|
import { HostsService } from './hosts/hosts.service.js'
|
||||||
|
import { OverviewService } from './overview/overview.service.js'
|
||||||
import { PublicSchedulingController } from './public/public-scheduling.controller.js'
|
import { PublicSchedulingController } from './public/public-scheduling.controller.js'
|
||||||
import { PublicSchedulingService } from './public/public-scheduling.service.js'
|
import { PublicSchedulingService } from './public/public-scheduling.service.js'
|
||||||
import { BookingReminderWorker } from './reminders/booking-reminder.worker.js'
|
import { BookingReminderWorker } from './reminders/booking-reminder.worker.js'
|
||||||
@@ -66,6 +67,7 @@ import { WebhooksService } from './webhooks/webhooks.service.js'
|
|||||||
EventTypesService,
|
EventTypesService,
|
||||||
SlotService,
|
SlotService,
|
||||||
BookingsService,
|
BookingsService,
|
||||||
|
OverviewService,
|
||||||
PublicSchedulingService,
|
PublicSchedulingService,
|
||||||
JmapMailer,
|
JmapMailer,
|
||||||
BookingReminderWorker,
|
BookingReminderWorker,
|
||||||
|
|||||||
Reference in New Issue
Block a user