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
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Commercial"
|
||||
title="Reports"
|
||||
icon="database"
|
||||
body="Cohort analyses, churn, expansion revenue, partner-margin reports. Tracked as a follow-up after billing lands."
|
||||
/>
|
||||
: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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
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 -->
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsEnum, IsObject, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
|
||||
|
||||
export class CreateReportDto {
|
||||
@IsString() @MinLength(1) @MaxLength(160)
|
||||
name!: string
|
||||
|
||||
@IsOptional() @IsEnum(['health', 'revenue', 'churn', 'custom'])
|
||||
kind?: 'health' | 'revenue' | 'churn' | 'custom'
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(500)
|
||||
description?: string
|
||||
|
||||
@IsOptional() @IsObject()
|
||||
definition?: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||
import { Report, ReportDocument } from '../schemas/report.schema.js'
|
||||
import type { CreateReportDto } from './dto/create-report.dto.js'
|
||||
|
||||
// Saved/custom reports CRUD, scoped to a partner. The live analytics are
|
||||
// computed in UsersService.partnerReports — this only stores named configs.
|
||||
@Injectable()
|
||||
export class PartnerReportsService {
|
||||
constructor(
|
||||
@InjectModel(Report.name) private readonly model: Model<ReportDocument>,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async list(partnerId: string | Types.ObjectId): Promise<ReportDocument[]> {
|
||||
return this.model.find({ partnerId }).sort({ createdAt: -1 }).exec()
|
||||
}
|
||||
|
||||
async create(
|
||||
partnerId: string | Types.ObjectId,
|
||||
dto: CreateReportDto,
|
||||
actor?: AuditActor,
|
||||
): Promise<ReportDocument> {
|
||||
const report = await this.model.create({
|
||||
partnerId,
|
||||
name: dto.name,
|
||||
kind: dto.kind ?? 'custom',
|
||||
description: dto.description,
|
||||
definition: dto.definition ?? {},
|
||||
createdByEmail: actor?.email,
|
||||
})
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'report.created',
|
||||
resourceType: 'partner',
|
||||
resourceId: String(report._id),
|
||||
resourceName: report.name,
|
||||
metadata: { kind: report.kind },
|
||||
},
|
||||
actor,
|
||||
)
|
||||
return report
|
||||
}
|
||||
|
||||
async remove(
|
||||
id: string,
|
||||
partnerId: string | Types.ObjectId,
|
||||
actor?: AuditActor,
|
||||
): Promise<{ removed: boolean }> {
|
||||
const report = await this.model.findById(id).exec()
|
||||
if (!report) throw new NotFoundException('Report not found')
|
||||
if (String(report.partnerId) !== String(partnerId)) {
|
||||
throw new ForbiddenException('Report is not in your portfolio')
|
||||
}
|
||||
await this.model.deleteOne({ _id: report._id }).exec()
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'report.deleted',
|
||||
resourceType: 'partner',
|
||||
resourceId: id,
|
||||
resourceName: report.name,
|
||||
},
|
||||
actor,
|
||||
)
|
||||
return { removed: true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
|
||||
export type ReportDocument = HydratedDocument<Report>
|
||||
|
||||
export type ReportKind = 'health' | 'revenue' | 'churn' | 'custom'
|
||||
|
||||
// A partner's saved/custom report definition. The analytics themselves are
|
||||
// computed live (UsersService.partnerReports); this only persists a named,
|
||||
// reusable configuration the partner created from the New-report modal.
|
||||
@Schema({ collection: 'reports', timestamps: true })
|
||||
export class Report {
|
||||
@Prop({ type: Types.ObjectId, ref: 'Partner', required: true, index: true })
|
||||
partnerId!: Types.ObjectId
|
||||
|
||||
@Prop({ required: true, trim: true })
|
||||
name!: string
|
||||
|
||||
@Prop({ enum: ['health', 'revenue', 'churn', 'custom'], default: 'custom' })
|
||||
kind!: ReportKind
|
||||
|
||||
@Prop({ trim: true })
|
||||
description?: string
|
||||
|
||||
// Free-form saved configuration (metrics, filters, groupBy, schedule,
|
||||
// recipients, format). UI-driven shape for v1.
|
||||
@Prop({ type: Object, default: {} })
|
||||
definition!: Record<string, unknown>
|
||||
|
||||
@Prop({ trim: true })
|
||||
createdByEmail?: string
|
||||
}
|
||||
|
||||
export const ReportSchema = SchemaFactory.createForClass(Report)
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||
import { OperatorGuard } from '../auth/operator.guard.js'
|
||||
import { UsersService } from './users.service.js'
|
||||
|
||||
// Platform-wide analytics for the operator reports page. Operator-only.
|
||||
@Controller('reports')
|
||||
@UseGuards(JwtAuthGuard, OperatorGuard)
|
||||
export class PlatformReportsController {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
|
||||
@Get('platform')
|
||||
async platform() {
|
||||
return this.users.platformReports()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema
|
||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||
import { PlatformReportsController } from './platform-reports.controller.js'
|
||||
import { UsersController } from './users.controller.js'
|
||||
import { UsersService } from './users.service.js'
|
||||
|
||||
@@ -33,7 +34,7 @@ import { UsersService } from './users.service.js'
|
||||
IntegrationsModule,
|
||||
TenantsModule,
|
||||
],
|
||||
controllers: [UsersController],
|
||||
controllers: [UsersController, PlatformReportsController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user