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:
@@ -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="0–100 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 -->
|
||||
|
||||
Reference in New Issue
Block a user