6370e392cc
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.
150 lines
6.0 KiB
Vue
150 lines
6.0 KiB
Vue
<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>
|
|
<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>
|