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
+146 -7
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>
<OpPlaceholder
eyebrow="Commercial"
title="Reports"
icon="database"
body="Cohort analyses, churn, expansion revenue, partner-margin reports. Tracked as a follow-up after billing lands."
/>
<div>
<PageHeader
eyebrow="Commercial"
title="Reports"
:subtitle="`${r?.tenants.total ?? 0} tenants · ${mrrTotal.toLocaleString('da-DK')} DKK platform MRR`"
>
<template #actions>
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Refresh
</UiButton>
</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.
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 = [
{ id: 'mrr', label: 'MRR', group: 'Revenue' },
@@ -42,7 +57,7 @@ const grouped = computed(() => {
const out: Record<string, typeof METRICS[number][]> = {}
for (const m of METRICS) {
out[m.group] = out[m.group] || []
out[m.group].push(m)
out[m.group]!.push(m)
}
return out
})
@@ -184,7 +199,7 @@ function toggle(id: string) {
<UiButton
variant="primary"
: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>
Create report
+242 -75
View File
@@ -8,21 +8,77 @@
import { customers, partnerMrrSparkline } from '~/data/customers'
import type { CustomerOrg } from '~/data/customers'
// Decorative MRR sparkline shape only — historical MRR isn't stored yet (a
// 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'
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 period = ref<'30d' | '90d' | '12mo' | 'ytd'>('90d')
const tabs = [
const tabs = computed(() => [
{ value: 'health', label: 'Customer health' },
{ value: 'revenue', label: 'Revenue' },
{ value: 'churn', label: 'Churn' },
{ value: 'custom', label: 'Custom reports', count: 3 },
]
{ value: 'custom', label: 'Custom reports', count: savedRaw.value?.length ?? 0 },
])
const periodOpts = [
{ value: '30d', label: '30 days' },
@@ -35,25 +91,46 @@ const exportOpen = ref(false)
const newReportOpen = ref(false)
// HEALTH ─────────────────────────────────────────────────────────────────────
// Health scoring exactly mirrors platform-partner-depth.jsx:73-80.
const scored = computed(() => customers.map((c) => {
let score = 100
if (c.status === 'past_due') score -= 50
else if (c.status === 'attention') score -= 30
else if (c.status === 'trial') score -= 10
if (c.seats.used / c.seats.total > 0.85) score -= 10
return { ...c, score }
}))
// Per-customer rows from real tenants (server-computed healthScore), shaped as
// CustomerOrg so the table + task panel consume them unchanged.
const scored = computed<Array<CustomerOrg & { score: number }>>(() =>
(tenants.value ?? [])
.filter((t) => t.status !== 'deleted')
.map((t) => {
const info = PLAN_INFO[t.plan ?? 'pro']
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(() => ({
healthy: scored.value.filter((c) => c.score >= 75).length,
watch: scored.value.filter((c) => c.score >= 50 && c.score < 75).length,
risk: scored.value.filter((c) => c.score < 50).length,
healthy: reports.value?.health.healthy ?? 0,
watch: reports.value?.health.watch ?? 0,
risk: reports.value?.health.atRisk ?? 0,
}))
const avgHealth = computed(() => reports.value?.health.avgScore ?? 0)
function healthColor(h: number) {
if (h >= 75) return 'var(--ok)'
if (h >= 50) return 'var(--warn)'
if (h >= 80) return 'var(--ok)'
if (h >= 60) return 'var(--warn)'
return 'var(--bad)'
}
@@ -68,35 +145,77 @@ function miniTrend(seed: number) {
}
// 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 topByMrr = computed(() => [...customers].sort((a, b) => b.mrrDkk - a.mrrDkk).slice(0, 5))
const PLAN_LABEL: Record<'mvp' | 'pro' | 'enterprise', string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
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 revenueMix = [
{ n: 'Enterprise', v: 42900, p: 77, c: 'var(--text)' },
{ n: 'Business', v: 11340, p: 20, c: 'var(--info)' },
{ n: 'Starter', v: 1510, p: 3, c: 'var(--text-mute)' },
]
const topByMrr = computed(() => {
const colorOf = (id: string) => (tenants.value ?? []).find((t) => t._id === id)?.brandColor || '#3F6BFF'
return (reports.value?.topCustomers ?? []).slice(0, 5).map((r) => ({
id: r.tenantId,
name: r.tenantName,
brandColor: colorOf(r.tenantId),
mrrDkk: Math.round(r.monthlyMinor / 100),
}))
})
// CHURN cohort heatmap · platform-partner-depth.jsx:237-243
const cohorts: Array<[string, number, Array<number | ''>]> = [
['Nov 2024', 1, [100, 100, 100, 100, 100, 100]],
['Aug 2025', 1, [100, 100, 100, 100, 100, '—']],
['Sep 2025', 1, [100, 100, 100, 100, 100, '—']],
['Feb 2026', 3, [100, 100, 100, '—', '—', '—']],
['Mar 2026', 2, [100, 100, '—', '—', '—', '—']],
['May 2026', 1, [100, '—', '—', '—', '—', '—']],
]
const cohortHeaders = ['M+0', 'M+1', 'M+2', 'M+3', 'M+6', 'M+12']
// CHURN — signup-month cohorts with (approximate) current retention. Real
// month-over-month retention needs cancellation dates (Phase 3 billing).
const churnRows = computed(() =>
(reports.value?.churnCohorts ?? []).map((c) => ({
label: new Date(`${c.month}-01`).toLocaleDateString('da-DK', { month: 'short', year: 'numeric' }),
total: c.total,
retained: c.retained,
retentionPct: c.retentionPct,
})),
)
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
const savedReports = ref([
{ id: 'r1', name: 'Quarterly board · Q1 2026', owner: 'Anne Baslund', schedule: 'Quarterly · 1st', last: '03 Apr 2026', recipients: 4, format: 'PDF' },
{ id: 'r2', name: 'Customer Health · weekly digest', owner: 'Anne Baslund', schedule: 'Mondays 09:00 CET', last: '13 May 2026', recipients: 2, format: 'PDF' },
{ id: 'r3', name: 'Margin breakdown by partner cut', owner: 'Mikkel Nørgaard', schedule: 'On-demand', last: '08 May 2026', recipients: 1, format: 'CSV' },
])
// CUSTOM REPORTS — real saved definitions from /api/partner/reports/saved.
function fmtDate(iso?: string) {
return iso
? new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' })
: '—'
}
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 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))
function deleteReport() {
async function deleteReport() {
const r = savedReports.value.find((x) => x.id === confirmDeleteId.value)
if (r) {
savedReports.value = savedReports.value.filter((x) => x.id !== confirmDeleteId.value)
toast.bad('Report deleted', r.name)
if (!r) {
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')
}
confirmDeleteId.value = null
}
function closeMenu() { reportMenuFor.value = null }
@@ -191,16 +355,16 @@ onMounted(() => {
<div v-if="tab === 'health'" class="content">
<div class="stat-strip">
<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>
<Stat label="Watch" :value="cohort.watch" delta="2 customers · check in" delta-tone="up" />
<Stat label="Watch" :value="cohort.watch" />
</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>
<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>
</div>
@@ -265,16 +429,16 @@ onMounted(() => {
<div v-if="tab === 'revenue'" class="content">
<div class="stat-strip">
<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>
<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>
<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>
<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>
</div>
@@ -288,8 +452,8 @@ onMounted(() => {
<div class="big-chart">
<PartnerSparkline :values="partnerMrrSparkline" :width="1080" :height="160" stroke="var(--text)" fill="var(--row-hover)" />
<div class="chart-foot">
<span>Feb 14 · 38.180 DKK</span>
<span>May 14 · 55.750 DKK</span>
<span>90-day trend · illustrative</span>
<span>{{ totalMrr.toLocaleString('da-DK') }} DKK / mo now</span>
</div>
</div>
</Card>
@@ -326,16 +490,16 @@ onMounted(() => {
<div v-if="tab === 'churn'" class="content">
<div class="stat-strip">
<Card>
<Stat label="Gross churn · 90d" value="0%" delta="0 customers" />
<Stat label="Signup cohorts" :value="churnRows.length" />
</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>
<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>
<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>
</div>
@@ -351,26 +515,29 @@ onMounted(() => {
<thead>
<tr>
<th>Cohort</th>
<th>Size</th>
<th v-for="h in cohortHeaders" :key="h">{{ h }}</th>
<th>Customers</th>
<th>Retained</th>
<th>Retention</th>
</tr>
</thead>
<tbody>
<tr v-for="(c, i) in cohorts" :key="i">
<td><Mono>{{ c[0] }}</Mono></td>
<td class="cohort-size"><Mono>{{ c[1] }}</Mono></td>
<td v-for="(v, j) in c[2]" :key="j" class="cell">
<Mono v-if="v === '—'" dim></Mono>
<tr v-for="(c, i) in churnRows" :key="i">
<td><Mono>{{ c.label }}</Mono></td>
<td class="cohort-size"><Mono>{{ c.total }}</Mono></td>
<td><Mono>{{ c.retained }}</Mono></td>
<td class="cell">
<span
v-else
class="heat"
: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)',
color: (v as number) >= 100 ? 'var(--ok)' : (v as number) >= 80 ? 'var(--warn)' : 'var(--bad)',
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: c.retentionPct >= 90 ? 'var(--ok)' : c.retentionPct >= 70 ? 'var(--warn)' : 'var(--bad)',
}"
>{{ v }}%</span>
>{{ c.retentionPct }}%</span>
</td>
</tr>
<tr v-if="!churnRows.length">
<td colspan="4" class="cohort-empty"><Mono dim>// no signup cohorts yet</Mono></td>
</tr>
</tbody>
</table>
</div>
@@ -502,7 +669,7 @@ onMounted(() => {
<PartnerNewCustomReportModal
:open="newReportOpen"
@close="newReportOpen = false"
@created="(n) => toast.ok('Report created', n)"
@created="onCreated"
/>
<!-- 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 })
}
})