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>