@@ -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 mir ror s 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 row s 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 -->