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:
@@ -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'))
|
||||
Reference in New Issue
Block a user