feat(reports): partner and platform analytics

Partner reports — health cohorts, revenue-by-plan, top customers, signup/churn cohorts, plus saved custom reports (create/list/delete). Operator platform-wide reports (MRR, revenue by plan, top tenants, growth). Replaces the reports fixtures in both apps.
This commit is contained in:
Ronni Baslund
2026-05-30 08:03:14 +02:00
parent 89691626f4
commit 6370e392cc
13 changed files with 633 additions and 86 deletions
+144 -5
View File
@@ -1,10 +1,149 @@
<script setup lang="ts"></script> <script setup lang="ts">
// Platform-wide analytics (all tenants/subscriptions). Real data from
// /api/reports/platform → platform-api GET /reports/platform (operator-only).
interface PlatformReports {
tenants: { active: number; pending: number; suspended: number; deleted: number; total: number }
revenueByPlan: Array<{
plan: 'mvp' | 'pro' | 'enterprise'
currency: 'DKK' | 'EUR' | 'USD'
monthlyMinor: number
count: number
}>
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
topTenants: Array<{ tenantId: string; tenantName: string; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
growth: Array<{ month: string; count: number }>
}
const { data: r, pending, refresh } = await useFetch<PlatformReports>('/api/reports/platform', {
default: () => ({
tenants: { active: 0, pending: 0, suspended: 0, deleted: 0, total: 0 },
revenueByPlan: [],
totals: [],
topTenants: [],
growth: [],
}),
})
const PLAN_LABEL: Record<'mvp' | 'pro' | 'enterprise', string> = {
mvp: 'Starter',
pro: 'Business',
enterprise: 'Enterprise',
}
const mrrTotal = computed(() =>
Math.round((r.value?.totals ?? []).reduce((s, t) => s + t.monthlyMinor, 0) / 100),
)
const maxGrowth = computed(() => Math.max(1, ...(r.value?.growth ?? []).map((g) => g.count)))
function fmtMonth(m: string) {
return new Date(`${m}-01`).toLocaleDateString('da-DK', { month: 'short', year: 'numeric' })
}
function money(minor: number) {
return Math.round(minor / 100).toLocaleString('da-DK')
}
</script>
<template> <template>
<OpPlaceholder <div>
<PageHeader
eyebrow="Commercial" eyebrow="Commercial"
title="Reports" title="Reports"
icon="database" :subtitle="`${r?.tenants.total ?? 0} tenants · ${mrrTotal.toLocaleString('da-DK')} DKK platform MRR`"
body="Cohort analyses, churn, expansion revenue, partner-margin reports. Tracked as a follow-up after billing lands." >
/> <template #actions>
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Refresh
</UiButton>
</template> </template>
</PageHeader>
<div class="stage">
<div class="vitals">
<Card><Stat label="Tenants" :value="r?.tenants.total ?? 0" :hint="`${r?.tenants.active ?? 0} active`" /></Card>
<Card><Stat label="MRR · platform" :value="`${mrrTotal.toLocaleString('da-DK')} DKK`" /></Card>
<Card><Stat label="Pending" :value="r?.tenants.pending ?? 0" /></Card>
<Card><Stat label="Suspended" :value="r?.tenants.suspended ?? 0" :delta-tone="(r?.tenants.suspended ?? 0) > 0 ? 'down' : undefined" /></Card>
</div>
<div class="grid">
<Card :pad="0">
<div class="head"><div><Eyebrow>Revenue</Eyebrow><div class="cap">By plan</div></div></div>
<table>
<thead><tr><th>Plan</th><th>Customers</th><th class="num">MRR</th></tr></thead>
<tbody>
<tr v-for="row in r?.revenueByPlan ?? []" :key="`${row.plan}-${row.currency}`">
<td class="name">{{ PLAN_LABEL[row.plan] }}</td>
<td><Mono dim>{{ row.count }}</Mono></td>
<td class="num"><Mono>{{ money(row.monthlyMinor) }} {{ row.currency }}</Mono></td>
</tr>
<tr v-if="!(r?.revenueByPlan ?? []).length"><td colspan="3" class="empty"><Mono dim>// no active subscriptions yet</Mono></td></tr>
</tbody>
</table>
</Card>
<Card :pad="0">
<div class="head"><div><Eyebrow>Top tenants</Eyebrow><div class="cap">By MRR</div></div></div>
<table>
<thead><tr><th>Tenant</th><th class="num">MRR</th></tr></thead>
<tbody>
<tr v-for="t in r?.topTenants ?? []" :key="t.tenantId">
<td class="name">{{ t.tenantName }}</td>
<td class="num"><Mono>{{ money(t.monthlyMinor) }} {{ t.currency }}</Mono></td>
</tr>
<tr v-if="!(r?.topTenants ?? []).length"><td colspan="2" class="empty"><Mono dim>// no revenue yet</Mono></td></tr>
</tbody>
</table>
</Card>
</div>
<Card :pad="0">
<div class="head"><div><Eyebrow>Growth</Eyebrow><div class="cap">New tenants by signup month</div></div></div>
<div class="growth">
<div v-for="g in r?.growth ?? []" :key="g.month" class="bar-row">
<Mono dim class="bar-label">{{ fmtMonth(g.month) }}</Mono>
<div class="bar-track"><div class="bar-fill" :style="{ width: `${(g.count / maxGrowth) * 100}%` }" /></div>
<Mono class="bar-val">{{ g.count }}</Mono>
</div>
<div v-if="!(r?.growth ?? []).length" class="empty"><Mono dim>// no tenants yet</Mono></div>
</div>
</Card>
</div>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
.vitals {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.vitals > * { background: var(--surface); padding: 20px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
.cap { font-family: var(--font-display); font-weight: 600; font-size: 17px; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
padding: 12px 20px;
font-weight: 500;
border-bottom: 1px solid var(--border);
}
th.num, td.num { text-align: right; }
td { padding: 10px 20px; font-size: 12px; border-top: 1px solid var(--border); }
td.name { font-weight: 500; }
.empty { text-align: center; color: var(--text-mute); padding: 24px; }
.growth { padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
.bar-row { display: grid; grid-template-columns: 90px 1fr 40px; align-items: center; gap: 12px; }
.bar-track { height: 8px; background: var(--border); border-radius: 999px; overflow: hidden; }
.bar-fill { height: 100%; background: var(--text); }
.bar-val { text-align: right; }
</style>
@@ -0,0 +1,4 @@
import { platformApi } from '~~/server/utils/platform-api'
// Platform-wide analytics for the operator reports page.
export default defineEventHandler(async (event) => platformApi(event, '/reports/platform'))
@@ -4,7 +4,22 @@
// recipients + format + live summary. // recipients + format + live summary.
defineProps<{ open: boolean }>() defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: []; created: [name: string] }>() const emit = defineEmits<{
close: []
created: [
payload: {
name: string
description: string
metrics: string[]
filterPlan: string
filterStatus: string
groupBy: string
schedule: string
recipients: string[]
format: string
},
]
}>()
const METRICS = [ const METRICS = [
{ id: 'mrr', label: 'MRR', group: 'Revenue' }, { id: 'mrr', label: 'MRR', group: 'Revenue' },
@@ -42,7 +57,7 @@ const grouped = computed(() => {
const out: Record<string, typeof METRICS[number][]> = {} const out: Record<string, typeof METRICS[number][]> = {}
for (const m of METRICS) { for (const m of METRICS) {
out[m.group] = out[m.group] || [] out[m.group] = out[m.group] || []
out[m.group].push(m) out[m.group]!.push(m)
} }
return out return out
}) })
@@ -184,7 +199,7 @@ function toggle(id: string) {
<UiButton <UiButton
variant="primary" variant="primary"
:disabled="!name || metrics.length === 0" :disabled="!name || metrics.length === 0"
@click="emit('created', name); emit('close')" @click="emit('created', { name, description, metrics, filterPlan, filterStatus, groupBy, schedule, recipients: recipients.split(',').map((r) => r.trim()).filter(Boolean), format }); emit('close')"
> >
<template #leading><UiIcon name="check" :size="14" /></template> <template #leading><UiIcon name="check" :size="14" /></template>
Create report Create report
+242 -75
View File
@@ -8,21 +8,77 @@
import { customers, partnerMrrSparkline } from '~/data/customers' // Decorative MRR sparkline shape only — historical MRR isn't stored yet (a
import type { CustomerOrg } from '~/data/customers' // daily-snapshot job lands later). The live numbers below are all real.
import { partnerMrrSparkline } from '~/data/customers'
import type { CustomerOrg, CustomerStatus } from '~/types/partner'
import type { TaskContext } from '~/components/partner/CustomerTaskPanel.vue' import type { TaskContext } from '~/components/partner/CustomerTaskPanel.vue'
const toast = useToast() const toast = useToast()
// ── Real data sources ─────────────────────────────────────────────────────
const { tenants } = usePartnerTenants()
const { mrrByTenant } = usePartnerMrr()
interface ReportsData {
health: { healthy: number; watch: number; atRisk: number; total: number; avgScore: number }
revenueByPlan: Array<{ plan: 'mvp' | 'pro' | 'enterprise'; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; count: number }>
topCustomers: Array<{ tenantId: string; tenantName: string; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; custom: boolean }>
churnCohorts: Array<{ month: string; total: number; retained: number; retentionPct: number }>
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
marginPct: number
}
const { data: reports } = useFetch<ReportsData>('/api/partner/reports', {
key: 'partner-reports',
default: () => ({
health: { healthy: 0, watch: 0, atRisk: 0, total: 0, avgScore: 0 },
revenueByPlan: [],
topCustomers: [],
churnCohorts: [],
totals: [],
marginPct: 0,
}),
})
interface SavedReport {
_id: string
name: string
kind: string
description?: string
definition?: Record<string, unknown>
createdByEmail?: string
createdAt?: string
}
const { data: savedRaw, refresh: refreshSaved } = useFetch<SavedReport[]>(
'/api/partner/reports/saved',
{ key: 'partner-reports-saved', default: () => [] },
)
const PLAN_INFO: Record<'mvp' | 'pro' | 'enterprise', { slug: CustomerOrg['plan']; label: CustomerOrg['planLabel'] }> = {
mvp: { slug: 'starter', label: 'Starter' },
pro: { slug: 'business', label: 'Business' },
enterprise: { slug: 'enterprise', label: 'Enterprise' },
}
const PLAN_COLOR: Record<'mvp' | 'pro' | 'enterprise', string> = {
enterprise: 'var(--text)',
pro: 'var(--info)',
mvp: 'var(--text-mute)',
}
function mapStatus(s: 'active' | 'pending' | 'suspended' | 'deleted'): CustomerStatus {
if (s === 'active') return 'healthy'
if (s === 'pending') return 'trial'
return 'suspended'
}
const tab = ref<'health' | 'revenue' | 'churn' | 'custom'>('health') const tab = ref<'health' | 'revenue' | 'churn' | 'custom'>('health')
const period = ref<'30d' | '90d' | '12mo' | 'ytd'>('90d') const period = ref<'30d' | '90d' | '12mo' | 'ytd'>('90d')
const tabs = [ const tabs = computed(() => [
{ value: 'health', label: 'Customer health' }, { value: 'health', label: 'Customer health' },
{ value: 'revenue', label: 'Revenue' }, { value: 'revenue', label: 'Revenue' },
{ value: 'churn', label: 'Churn' }, { value: 'churn', label: 'Churn' },
{ value: 'custom', label: 'Custom reports', count: 3 }, { value: 'custom', label: 'Custom reports', count: savedRaw.value?.length ?? 0 },
] ])
const periodOpts = [ const periodOpts = [
{ value: '30d', label: '30 days' }, { value: '30d', label: '30 days' },
@@ -35,25 +91,46 @@ const exportOpen = ref(false)
const newReportOpen = ref(false) const newReportOpen = ref(false)
// HEALTH ───────────────────────────────────────────────────────────────────── // HEALTH ─────────────────────────────────────────────────────────────────────
// Health scoring exactly mirrors platform-partner-depth.jsx:73-80. // Per-customer rows from real tenants (server-computed healthScore), shaped as
const scored = computed(() => customers.map((c) => { // CustomerOrg so the table + task panel consume them unchanged.
let score = 100 const scored = computed<Array<CustomerOrg & { score: number }>>(() =>
if (c.status === 'past_due') score -= 50 (tenants.value ?? [])
else if (c.status === 'attention') score -= 30 .filter((t) => t.status !== 'deleted')
else if (c.status === 'trial') score -= 10 .map((t) => {
if (c.seats.used / c.seats.total > 0.85) score -= 10 const info = PLAN_INFO[t.plan ?? 'pro']
return { ...c, score } const sub = mrrByTenant.value.get(t._id)
})) const score = t.healthScore ?? 100
return {
id: t._id,
name: t.name,
domain: t.domains?.[0] ?? `${t.slug}.dezky.com`,
plan: info.slug,
planLabel: info.label,
seats: { used: t.userCount ?? 0, total: t.seats ?? 0 },
health: score,
score,
status: mapStatus(t.status),
mrrDkk: sub ? Math.round(sub.monthlyMinor / 100) : 0,
brandColor: t.brandColor || '#3F6BFF',
industry: t.industry ?? '—',
createdOn: t.createdAt ?? '',
since: t.createdAt ?? '',
}
}),
)
// Cohort counts + average from the server reports endpoint (single source of
// truth for the health buckets).
const cohort = computed(() => ({ const cohort = computed(() => ({
healthy: scored.value.filter((c) => c.score >= 75).length, healthy: reports.value?.health.healthy ?? 0,
watch: scored.value.filter((c) => c.score >= 50 && c.score < 75).length, watch: reports.value?.health.watch ?? 0,
risk: scored.value.filter((c) => c.score < 50).length, risk: reports.value?.health.atRisk ?? 0,
})) }))
const avgHealth = computed(() => reports.value?.health.avgScore ?? 0)
function healthColor(h: number) { function healthColor(h: number) {
if (h >= 75) return 'var(--ok)' if (h >= 80) return 'var(--ok)'
if (h >= 50) return 'var(--warn)' if (h >= 60) return 'var(--warn)'
return 'var(--bad)' return 'var(--bad)'
} }
@@ -68,35 +145,77 @@ function miniTrend(seed: number) {
} }
// REVENUE ──────────────────────────────────────────────────────────────────── // REVENUE ────────────────────────────────────────────────────────────────────
const totalMrr = computed(() => customers.reduce((s, c) => s + c.mrrDkk, 0)) // Totals summed across currencies into one headline figure (internal view —
// per-currency totals are in reports.totals). Margin/ARR/ARPU derive from MRR.
const totalMrr = computed(() =>
Math.round((reports.value?.totals ?? []).reduce((s, t) => s + t.monthlyMinor, 0) / 100),
)
const custCount = computed(() => reports.value?.health.total ?? 0)
const marginMrr = computed(() => Math.round((totalMrr.value * (reports.value?.marginPct ?? 0)) / 100))
const arr = computed(() => totalMrr.value * 12)
const arpu = computed(() => (custCount.value ? Math.round(totalMrr.value / custCount.value) : 0))
// Top 5 by MRR const PLAN_LABEL: Record<'mvp' | 'pro' | 'enterprise', string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
const topByMrr = computed(() => [...customers].sort((a, b) => b.mrrDkk - a.mrrDkk).slice(0, 5)) const revenueMix = computed(() => {
const byPlan = new Map<'mvp' | 'pro' | 'enterprise', number>()
for (const r of reports.value?.revenueByPlan ?? []) byPlan.set(r.plan, (byPlan.get(r.plan) ?? 0) + r.monthlyMinor)
const grand = [...byPlan.values()].reduce((a, b) => a + b, 0) || 1
return [...byPlan.entries()]
.sort((a, b) => b[1] - a[1])
.map(([plan, minor]) => ({
n: PLAN_LABEL[plan],
v: Math.round(minor / 100),
p: Math.round((minor / grand) * 100),
c: PLAN_COLOR[plan],
}))
})
// By-plan revenue mix · platform-partner-depth.jsx:176-180 const topByMrr = computed(() => {
const revenueMix = [ const colorOf = (id: string) => (tenants.value ?? []).find((t) => t._id === id)?.brandColor || '#3F6BFF'
{ n: 'Enterprise', v: 42900, p: 77, c: 'var(--text)' }, return (reports.value?.topCustomers ?? []).slice(0, 5).map((r) => ({
{ n: 'Business', v: 11340, p: 20, c: 'var(--info)' }, id: r.tenantId,
{ n: 'Starter', v: 1510, p: 3, c: 'var(--text-mute)' }, name: r.tenantName,
] brandColor: colorOf(r.tenantId),
mrrDkk: Math.round(r.monthlyMinor / 100),
}))
})
// CHURN cohort heatmap · platform-partner-depth.jsx:237-243 // CHURN — signup-month cohorts with (approximate) current retention. Real
const cohorts: Array<[string, number, Array<number | ''>]> = [ // month-over-month retention needs cancellation dates (Phase 3 billing).
['Nov 2024', 1, [100, 100, 100, 100, 100, 100]], const churnRows = computed(() =>
['Aug 2025', 1, [100, 100, 100, 100, 100, '—']], (reports.value?.churnCohorts ?? []).map((c) => ({
['Sep 2025', 1, [100, 100, 100, 100, 100, '—']], label: new Date(`${c.month}-01`).toLocaleDateString('da-DK', { month: 'short', year: 'numeric' }),
['Feb 2026', 3, [100, 100, 100, '—', '—', '—']], total: c.total,
['Mar 2026', 2, [100, 100, '—', '—', '—', '—']], retained: c.retained,
['May 2026', 1, [100, '—', '—', '—', '—', '—']], retentionPct: c.retentionPct,
] })),
const cohortHeaders = ['M+0', 'M+1', 'M+2', 'M+3', 'M+6', 'M+12'] )
const avgRetention = computed(() => {
const rows = churnRows.value
return rows.length ? Math.round(rows.reduce((s, r) => s + r.retentionPct, 0) / rows.length) : 0
})
// CUSTOM REPORTS · platform-partner-depth.jsx:280-283 // CUSTOM REPORTS — real saved definitions from /api/partner/reports/saved.
const savedReports = ref([ function fmtDate(iso?: string) {
{ id: 'r1', name: 'Quarterly board · Q1 2026', owner: 'Anne Baslund', schedule: 'Quarterly · 1st', last: '03 Apr 2026', recipients: 4, format: 'PDF' }, return iso
{ id: 'r2', name: 'Customer Health · weekly digest', owner: 'Anne Baslund', schedule: 'Mondays 09:00 CET', last: '13 May 2026', recipients: 2, format: 'PDF' }, ? new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' })
{ id: 'r3', name: 'Margin breakdown by partner cut', owner: 'Mikkel Nørgaard', schedule: 'On-demand', last: '08 May 2026', recipients: 1, format: 'CSV' }, : '—'
]) }
const savedReports = computed(() =>
(savedRaw.value ?? []).map((r) => {
const def = r.definition ?? {}
const recips = (def as { recipients?: unknown[] }).recipients
return {
id: r._id,
name: r.name,
owner: r.createdByEmail || '—',
schedule: String((def as { schedule?: string }).schedule ?? 'On-demand'),
last: fmtDate(r.createdAt),
recipients: Array.isArray(recips) ? recips.length : 0,
format: String((def as { format?: string }).format ?? 'PDF').toUpperCase(),
}
}),
)
const running = ref<string | null>(null) const running = ref<string | null>(null)
const reportMenuFor = ref<string | null>(null) const reportMenuFor = ref<string | null>(null)
@@ -134,13 +253,58 @@ function reportActions(r: typeof savedReports.value[number]) {
const confirmDeleteReport = computed(() => savedReports.value.find((r) => r.id === confirmDeleteId.value)) const confirmDeleteReport = computed(() => savedReports.value.find((r) => r.id === confirmDeleteId.value))
function deleteReport() { async function deleteReport() {
const r = savedReports.value.find((x) => x.id === confirmDeleteId.value) const r = savedReports.value.find((x) => x.id === confirmDeleteId.value)
if (r) { if (!r) {
savedReports.value = savedReports.value.filter((x) => x.id !== confirmDeleteId.value)
toast.bad('Report deleted', r.name)
}
confirmDeleteId.value = null confirmDeleteId.value = null
return
}
try {
await $fetch(`/api/partner/reports/saved/${r.id}`, { method: 'DELETE' })
toast.bad('Report deleted', r.name)
confirmDeleteId.value = null
await Promise.all([refreshSaved(), refreshNuxtData('partner-reports-saved')])
} catch (e: unknown) {
const err = e as { data?: { message?: string }; statusMessage?: string }
toast.bad('Delete failed', err.data?.message || err.statusMessage || 'Could not delete report')
}
}
async function onCreated(payload: {
name: string
description: string
metrics: string[]
filterPlan: string
filterStatus: string
groupBy: string
schedule: string
recipients: string[]
format: string
}) {
try {
await $fetch('/api/partner/reports/saved', {
method: 'POST',
body: {
name: payload.name,
kind: 'custom',
description: payload.description,
definition: {
metrics: payload.metrics,
filterPlan: payload.filterPlan,
filterStatus: payload.filterStatus,
groupBy: payload.groupBy,
schedule: payload.schedule,
recipients: payload.recipients,
format: payload.format,
},
},
})
toast.ok('Report created', payload.name)
await Promise.all([refreshSaved(), refreshNuxtData('partner-reports-saved')])
} catch (e: unknown) {
const err = e as { data?: { message?: string }; statusMessage?: string }
toast.bad('Create failed', err.data?.message || err.statusMessage || 'Could not create report')
}
} }
function closeMenu() { reportMenuFor.value = null } function closeMenu() { reportMenuFor.value = null }
@@ -191,16 +355,16 @@ onMounted(() => {
<div v-if="tab === 'health'" class="content"> <div v-if="tab === 'health'" class="content">
<div class="stat-strip"> <div class="stat-strip">
<Card> <Card>
<Stat label="Healthy" :value="cohort.healthy" :delta="`${Math.round(cohort.healthy / scored.length * 100)}% of portfolio`" /> <Stat label="Healthy" :value="cohort.healthy" :hint="`of ${cohort.healthy + cohort.watch + cohort.risk} customers`" />
</Card> </Card>
<Card> <Card>
<Stat label="Watch" :value="cohort.watch" delta="2 customers · check in" delta-tone="up" /> <Stat label="Watch" :value="cohort.watch" />
</Card> </Card>
<Card> <Card>
<Stat label="At risk" :value="cohort.risk" delta="1 customer · escalate" delta-tone="down" /> <Stat label="At risk" :value="cohort.risk" :delta-tone="cohort.risk > 0 ? 'down' : undefined" />
</Card> </Card>
<Card> <Card>
<Stat label="NPS · est" value="58" delta="+6 from last period" delta-tone="up" hint="based on 12 responses" /> <Stat label="Avg health" :value="avgHealth" hint="0100 score" />
</Card> </Card>
</div> </div>
@@ -265,16 +429,16 @@ onMounted(() => {
<div v-if="tab === 'revenue'" class="content"> <div v-if="tab === 'revenue'" class="content">
<div class="stat-strip"> <div class="stat-strip">
<Card> <Card>
<Stat label="MRR · current" value="55.750 DKK" delta="+18.2%" delta-tone="up" hint="vs. 90d ago" /> <Stat label="MRR · current" :value="`${totalMrr.toLocaleString('da-DK')} DKK`" hint="across all customers" />
</Card> </Card>
<Card> <Card>
<Stat label="Partner margin" value="11.150 DKK" delta="+19.0%" delta-tone="up" hint="20% of MRR" /> <Stat label="Partner margin" :value="`${marginMrr.toLocaleString('da-DK')} DKK`" :hint="`${reports?.marginPct ?? 0}% of MRR`" />
</Card> </Card>
<Card> <Card>
<Stat label="ARR · projected" value="669.000 DKK" delta="+24% YoY" delta-tone="up" /> <Stat label="ARR · projected" :value="`${arr.toLocaleString('da-DK')} DKK`" />
</Card> </Card>
<Card> <Card>
<Stat label="ARPU" value="6.969 DKK" hint="per customer / mo" /> <Stat label="ARPU" :value="`${arpu.toLocaleString('da-DK')} DKK`" hint="per customer / mo" />
</Card> </Card>
</div> </div>
@@ -288,8 +452,8 @@ onMounted(() => {
<div class="big-chart"> <div class="big-chart">
<PartnerSparkline :values="partnerMrrSparkline" :width="1080" :height="160" stroke="var(--text)" fill="var(--row-hover)" /> <PartnerSparkline :values="partnerMrrSparkline" :width="1080" :height="160" stroke="var(--text)" fill="var(--row-hover)" />
<div class="chart-foot"> <div class="chart-foot">
<span>Feb 14 · 38.180 DKK</span> <span>90-day trend · illustrative</span>
<span>May 14 · 55.750 DKK</span> <span>{{ totalMrr.toLocaleString('da-DK') }} DKK / mo now</span>
</div> </div>
</div> </div>
</Card> </Card>
@@ -326,16 +490,16 @@ onMounted(() => {
<div v-if="tab === 'churn'" class="content"> <div v-if="tab === 'churn'" class="content">
<div class="stat-strip"> <div class="stat-strip">
<Card> <Card>
<Stat label="Gross churn · 90d" value="0%" delta="0 customers" /> <Stat label="Signup cohorts" :value="churnRows.length" />
</Card> </Card>
<Card> <Card>
<Stat label="Net churn · MRR" value="2.1%" delta="contracted from upgrades" delta-tone="up" /> <Stat label="Avg retention" :value="`${avgRetention}%`" :delta-tone="avgRetention >= 80 ? 'up' : 'down'" />
</Card> </Card>
<Card> <Card>
<Stat label="At-risk MRR" value="2.940 DKK" hint="1 customer · past-due" delta-tone="down" /> <Stat label="Active customers" :value="cohort.healthy + cohort.watch" />
</Card> </Card>
<Card> <Card>
<Stat label="Avg tenure" value="14 mo" delta="trending up" delta-tone="up" /> <Stat label="At risk" :value="cohort.risk" :delta-tone="cohort.risk > 0 ? 'down' : undefined" />
</Card> </Card>
</div> </div>
@@ -351,26 +515,29 @@ onMounted(() => {
<thead> <thead>
<tr> <tr>
<th>Cohort</th> <th>Cohort</th>
<th>Size</th> <th>Customers</th>
<th v-for="h in cohortHeaders" :key="h">{{ h }}</th> <th>Retained</th>
<th>Retention</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(c, i) in cohorts" :key="i"> <tr v-for="(c, i) in churnRows" :key="i">
<td><Mono>{{ c[0] }}</Mono></td> <td><Mono>{{ c.label }}</Mono></td>
<td class="cohort-size"><Mono>{{ c[1] }}</Mono></td> <td class="cohort-size"><Mono>{{ c.total }}</Mono></td>
<td v-for="(v, j) in c[2]" :key="j" class="cell"> <td><Mono>{{ c.retained }}</Mono></td>
<Mono v-if="v === '—'" dim></Mono> <td class="cell">
<span <span
v-else
class="heat" class="heat"
:style="{ :style="{
background: (v as number) >= 100 ? 'rgba(31,138,91,0.16)' : (v as number) >= 80 ? 'rgba(232,154,31,0.16)' : 'rgba(226,48,48,0.16)', background: c.retentionPct >= 90 ? 'rgba(31,138,91,0.16)' : c.retentionPct >= 70 ? 'rgba(232,154,31,0.16)' : 'rgba(226,48,48,0.16)',
color: (v as number) >= 100 ? 'var(--ok)' : (v as number) >= 80 ? 'var(--warn)' : 'var(--bad)', color: c.retentionPct >= 90 ? 'var(--ok)' : c.retentionPct >= 70 ? 'var(--warn)' : 'var(--bad)',
}" }"
>{{ v }}%</span> >{{ c.retentionPct }}%</span>
</td> </td>
</tr> </tr>
<tr v-if="!churnRows.length">
<td colspan="4" class="cohort-empty"><Mono dim>// no signup cohorts yet</Mono></td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -502,7 +669,7 @@ onMounted(() => {
<PartnerNewCustomReportModal <PartnerNewCustomReportModal
:open="newReportOpen" :open="newReportOpen"
@close="newReportOpen = false" @close="newReportOpen = false"
@created="(n) => toast.ok('Report created', n)" @created="onCreated"
/> />
<!-- Export PDF Modal --> <!-- Export PDF Modal -->
@@ -0,0 +1,20 @@
// Partner reports analytics. Forwards to platform-api GET /me/partner/reports.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
try {
return await $fetch(`${base}/me/partner/reports`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
} catch (err: unknown) {
const e = err as { statusCode?: number; data?: unknown }
throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
}
})
@@ -0,0 +1,20 @@
// Saved partner reports. Forwards to platform-api GET /me/partner/reports/saved.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
try {
return await $fetch(`${base}/me/partner/reports/saved`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
} catch (err: unknown) {
const e = err as { statusCode?: number; data?: unknown }
throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
}
})
@@ -0,0 +1,24 @@
// Create a saved partner report. Forwards to platform-api
// POST /me/partner/reports/saved.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
try {
return await $fetch(`${base}/me/partner/reports/saved`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
} catch (err: unknown) {
const e = err as { statusCode?: number; data?: unknown }
throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
}
})
@@ -0,0 +1,23 @@
// Delete a saved partner report. Forwards to platform-api
// DELETE /me/partner/reports/saved/:id.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const id = getRouterParam(event, 'id')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
try {
return await $fetch(`${base}/me/partner/reports/saved/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
})
} catch (err: unknown) {
const e = err as { statusCode?: number; data?: unknown }
throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
}
})
@@ -0,0 +1,15 @@
import { IsEnum, IsObject, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
export class CreateReportDto {
@IsString() @MinLength(1) @MaxLength(160)
name!: string
@IsOptional() @IsEnum(['health', 'revenue', 'churn', 'custom'])
kind?: 'health' | 'revenue' | 'churn' | 'custom'
@IsOptional() @IsString() @MaxLength(500)
description?: string
@IsOptional() @IsObject()
definition?: Record<string, unknown>
}
@@ -0,0 +1,69 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { Report, ReportDocument } from '../schemas/report.schema.js'
import type { CreateReportDto } from './dto/create-report.dto.js'
// Saved/custom reports CRUD, scoped to a partner. The live analytics are
// computed in UsersService.partnerReports — this only stores named configs.
@Injectable()
export class PartnerReportsService {
constructor(
@InjectModel(Report.name) private readonly model: Model<ReportDocument>,
private readonly audit: AuditService,
) {}
async list(partnerId: string | Types.ObjectId): Promise<ReportDocument[]> {
return this.model.find({ partnerId }).sort({ createdAt: -1 }).exec()
}
async create(
partnerId: string | Types.ObjectId,
dto: CreateReportDto,
actor?: AuditActor,
): Promise<ReportDocument> {
const report = await this.model.create({
partnerId,
name: dto.name,
kind: dto.kind ?? 'custom',
description: dto.description,
definition: dto.definition ?? {},
createdByEmail: actor?.email,
})
void this.audit.record(
{
action: 'report.created',
resourceType: 'partner',
resourceId: String(report._id),
resourceName: report.name,
metadata: { kind: report.kind },
},
actor,
)
return report
}
async remove(
id: string,
partnerId: string | Types.ObjectId,
actor?: AuditActor,
): Promise<{ removed: boolean }> {
const report = await this.model.findById(id).exec()
if (!report) throw new NotFoundException('Report not found')
if (String(report.partnerId) !== String(partnerId)) {
throw new ForbiddenException('Report is not in your portfolio')
}
await this.model.deleteOne({ _id: report._id }).exec()
void this.audit.record(
{
action: 'report.deleted',
resourceType: 'partner',
resourceId: id,
resourceName: report.name,
},
actor,
)
return { removed: true }
}
}
@@ -0,0 +1,34 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type ReportDocument = HydratedDocument<Report>
export type ReportKind = 'health' | 'revenue' | 'churn' | 'custom'
// A partner's saved/custom report definition. The analytics themselves are
// computed live (UsersService.partnerReports); this only persists a named,
// reusable configuration the partner created from the New-report modal.
@Schema({ collection: 'reports', timestamps: true })
export class Report {
@Prop({ type: Types.ObjectId, ref: 'Partner', required: true, index: true })
partnerId!: Types.ObjectId
@Prop({ required: true, trim: true })
name!: string
@Prop({ enum: ['health', 'revenue', 'churn', 'custom'], default: 'custom' })
kind!: ReportKind
@Prop({ trim: true })
description?: string
// Free-form saved configuration (metrics, filters, groupBy, schedule,
// recipients, format). UI-driven shape for v1.
@Prop({ type: Object, default: {} })
definition!: Record<string, unknown>
@Prop({ trim: true })
createdByEmail?: string
}
export const ReportSchema = SchemaFactory.createForClass(Report)
@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import { OperatorGuard } from '../auth/operator.guard.js'
import { UsersService } from './users.service.js'
// Platform-wide analytics for the operator reports page. Operator-only.
@Controller('reports')
@UseGuards(JwtAuthGuard, OperatorGuard)
export class PlatformReportsController {
constructor(private readonly users: UsersService) {}
@Get('platform')
async platform() {
return this.users.platformReports()
}
}
@@ -9,6 +9,7 @@ import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js' import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js' import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js' import { TenantsModule } from '../tenants/tenants.module.js'
import { PlatformReportsController } from './platform-reports.controller.js'
import { UsersController } from './users.controller.js' import { UsersController } from './users.controller.js'
import { UsersService } from './users.service.js' import { UsersService } from './users.service.js'
@@ -33,7 +34,7 @@ import { UsersService } from './users.service.js'
IntegrationsModule, IntegrationsModule,
TenantsModule, TenantsModule,
], ],
controllers: [UsersController], controllers: [UsersController, PlatformReportsController],
providers: [UsersService], providers: [UsersService],
exports: [UsersService], exports: [UsersService],
}) })