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.
932 lines
33 KiB
Vue
932 lines
33 KiB
Vue
<script setup lang="ts">
|
||
// Partner reports. Strict port of PartnerReportsScreen
|
||
// (platform-partner-depth.jsx lines 22-318 + 559-852). Four tabs:
|
||
// • Customer health · Stats + per-customer health table (Escalate / Check in)
|
||
// • Revenue · Stats + MRR sparkline + By plan + Top customers
|
||
// • Churn · Stats + cohort retention heatmap + exit reasons
|
||
// • Custom reports · Saved reports table + create modal
|
||
|
||
|
||
|
||
// 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 = computed(() => [
|
||
{ value: 'health', label: 'Customer health' },
|
||
{ value: 'revenue', label: 'Revenue' },
|
||
{ value: 'churn', label: 'Churn' },
|
||
{ value: 'custom', label: 'Custom reports', count: savedRaw.value?.length ?? 0 },
|
||
])
|
||
|
||
const periodOpts = [
|
||
{ value: '30d', label: '30 days' },
|
||
{ value: '90d', label: '90 days' },
|
||
{ value: '12mo', label: '12 months' },
|
||
{ value: 'ytd', label: 'Year-to-date' },
|
||
] as const
|
||
|
||
const exportOpen = ref(false)
|
||
const newReportOpen = ref(false)
|
||
|
||
// HEALTH ─────────────────────────────────────────────────────────────────────
|
||
// 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: 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 >= 80) return 'var(--ok)'
|
||
if (h >= 60) return 'var(--warn)'
|
||
return 'var(--bad)'
|
||
}
|
||
|
||
const taskCtx = ref<TaskContext | null>(null)
|
||
function openTask(c: CustomerOrg & { score: number }, mode: 'escalate' | 'checkin') {
|
||
taskCtx.value = { customer: c, score: c.score, mode }
|
||
}
|
||
|
||
// Deterministic mini trend sparkline (30 points) for the per-customer row.
|
||
function miniTrend(seed: number) {
|
||
return Array.from({ length: 30 }, (_, i) => 60 + Math.sin((i + seed) / 4) * 12 + ((i * seed) % 5))
|
||
}
|
||
|
||
// REVENUE ────────────────────────────────────────────────────────────────────
|
||
// 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))
|
||
|
||
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],
|
||
}))
|
||
})
|
||
|
||
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 — 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 — 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)
|
||
const reportMenuPos = ref<{ top: number; right: number }>({ top: 0, right: 0 })
|
||
const confirmDeleteId = ref<string | null>(null)
|
||
|
||
function runReport(id: string) {
|
||
running.value = id
|
||
const r = savedReports.value.find((x) => x.id === id)
|
||
setTimeout(() => { running.value = null }, 1800)
|
||
if (r) toast.info(`Running ${r.name}`, 'You will be emailed when ready')
|
||
}
|
||
|
||
function openReportMenu(rId: string, e: MouseEvent) {
|
||
e.stopPropagation()
|
||
const btn = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||
reportMenuPos.value = { top: btn.bottom + 4, right: window.innerWidth - btn.right }
|
||
reportMenuFor.value = reportMenuFor.value === rId ? null : rId
|
||
}
|
||
|
||
function reportActions(r: typeof savedReports.value[number]) {
|
||
return [
|
||
{ i: 'external', l: 'Run again', fn: () => runReport(r.id) },
|
||
{ i: 'download', l: `Download last (${r.format})`, fn: () => toast.ok('Downloading', `${r.name}.${r.format.toLowerCase()}`) },
|
||
{ i: 'mail', l: 'Send to recipients now', fn: () => toast.info('Sending', `${r.recipients} recipients`) },
|
||
{ i: 'copy', l: 'Copy shareable link', fn: () => toast.ok('Link copied') },
|
||
{ sep: true },
|
||
{ i: 'brush', l: 'Edit report…', fn: () => toast.info('Editing', r.name) },
|
||
{ i: 'copy', l: 'Duplicate', fn: () => toast.ok('Duplicated', r.name) },
|
||
{ i: 'calendar', l: r.schedule === 'On-demand' ? 'Add schedule…' : 'Pause schedule', fn: () => toast.info('Schedule', r.schedule) },
|
||
{ sep: true },
|
||
{ i: 'trash', l: 'Delete report', danger: true, fn: () => { confirmDeleteId.value = r.id } },
|
||
] as Array<{ i?: string; l?: string; danger?: boolean; sep?: boolean; fn?: () => void }>
|
||
}
|
||
|
||
const confirmDeleteReport = computed(() => savedReports.value.find((r) => r.id === confirmDeleteId.value))
|
||
|
||
async function deleteReport() {
|
||
const r = savedReports.value.find((x) => x.id === confirmDeleteId.value)
|
||
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 }
|
||
|
||
onMounted(() => {
|
||
const onScroll = () => closeMenu()
|
||
document.addEventListener('click', closeMenu)
|
||
window.addEventListener('scroll', onScroll, true)
|
||
window.addEventListener('resize', onScroll)
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('click', closeMenu)
|
||
window.removeEventListener('scroll', onScroll, true)
|
||
window.removeEventListener('resize', onScroll)
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<PageHeader
|
||
eyebrow="Analytics"
|
||
title="Partner reports"
|
||
subtitle="Health, revenue, churn, and custom rollups across your customer portfolio."
|
||
>
|
||
<template #actions>
|
||
<UiButton variant="secondary" @click="exportOpen = true">
|
||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||
Export PDF
|
||
</UiButton>
|
||
<UiButton variant="primary" @click="newReportOpen = true">
|
||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||
New report
|
||
</UiButton>
|
||
</template>
|
||
</PageHeader>
|
||
|
||
<div class="tabs-bar">
|
||
<Tabs v-model="tab" :items="tabs" class="tabs-stretch" />
|
||
<div class="period-chip">
|
||
<span class="seg-label">Period</span>
|
||
<select v-model="period">
|
||
<option v-for="o in periodOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- HEALTH -->
|
||
<div v-if="tab === 'health'" class="content">
|
||
<div class="stat-strip">
|
||
<Card>
|
||
<Stat label="Healthy" :value="cohort.healthy" :hint="`of ${cohort.healthy + cohort.watch + cohort.risk} customers`" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="Watch" :value="cohort.watch" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="At risk" :value="cohort.risk" :delta-tone="cohort.risk > 0 ? 'down' : undefined" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="Avg health" :value="avgHealth" hint="0–100 score" />
|
||
</Card>
|
||
</div>
|
||
|
||
<Card :pad="0">
|
||
<div class="card-head">
|
||
<div>
|
||
<Eyebrow>Per customer</Eyebrow>
|
||
<div class="card-title">Health scores</div>
|
||
</div>
|
||
</div>
|
||
<table class="dtable">
|
||
<thead>
|
||
<tr>
|
||
<th>Customer</th>
|
||
<th>Plan</th>
|
||
<th>Health</th>
|
||
<th>Seat usage</th>
|
||
<th class="num">MRR</th>
|
||
<th>Trend · 90d</th>
|
||
<th class="action-col" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(c, i) in scored" :key="c.id">
|
||
<td>
|
||
<div class="cust-cell">
|
||
<div class="cust-swatch" :style="{ background: c.brandColor }" />
|
||
<div>
|
||
<div class="cust-name">{{ c.name }}</div>
|
||
<Mono dim>{{ c.domain }}</Mono>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<Badge :tone="c.plan === 'enterprise' ? 'invert' : 'neutral'">{{ c.planLabel }}</Badge>
|
||
</td>
|
||
<td>
|
||
<div class="health-cell">
|
||
<div class="hbar">
|
||
<div class="hfill" :style="{ width: c.score + '%', background: healthColor(c.score) }" />
|
||
</div>
|
||
<Mono>{{ c.score }}</Mono>
|
||
</div>
|
||
</td>
|
||
<td><Mono dim>{{ c.seats.used }}/{{ c.seats.total }} · {{ Math.round(c.seats.used/c.seats.total*100) }}%</Mono></td>
|
||
<td class="num"><Mono>{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' DKK' : '—' }}</Mono></td>
|
||
<td>
|
||
<PartnerSparkline :values="miniTrend(i + 1)" :width="80" :height="22" stroke="var(--text)" fill="transparent" :show-dot="false" />
|
||
</td>
|
||
<td class="action-col">
|
||
<UiButton v-if="c.score < 50" size="sm" variant="primary" @click="openTask(c, 'escalate')">Escalate</UiButton>
|
||
<UiButton v-else-if="c.score < 75" size="sm" variant="secondary" @click="openTask(c, 'checkin')">Check in</UiButton>
|
||
<Mono v-else dim>—</Mono>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
</div>
|
||
|
||
<!-- REVENUE -->
|
||
<div v-if="tab === 'revenue'" class="content">
|
||
<div class="stat-strip">
|
||
<Card>
|
||
<Stat label="MRR · current" :value="`${totalMrr.toLocaleString('da-DK')} DKK`" hint="across all customers" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="Partner margin" :value="`${marginMrr.toLocaleString('da-DK')} DKK`" :hint="`${reports?.marginPct ?? 0}% of MRR`" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="ARR · projected" :value="`${arr.toLocaleString('da-DK')} DKK`" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="ARPU" :value="`${arpu.toLocaleString('da-DK')} DKK`" hint="per customer / mo" />
|
||
</Card>
|
||
</div>
|
||
|
||
<Card :pad="0">
|
||
<div class="card-head">
|
||
<div>
|
||
<Eyebrow>MRR · last 90 days</Eyebrow>
|
||
<div class="card-title">Trend</div>
|
||
</div>
|
||
</div>
|
||
<div class="big-chart">
|
||
<PartnerSparkline :values="partnerMrrSparkline" :width="1080" :height="160" stroke="var(--text)" fill="var(--row-hover)" />
|
||
<div class="chart-foot">
|
||
<span>90-day trend · illustrative</span>
|
||
<span>{{ totalMrr.toLocaleString('da-DK') }} DKK / mo now</span>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<div class="grid-2">
|
||
<Card>
|
||
<Eyebrow>By plan</Eyebrow>
|
||
<div class="card-title">Revenue mix</div>
|
||
<div class="plan-mix">
|
||
<div v-for="p in revenueMix" :key="p.n" class="mix-row">
|
||
<div class="mix-head">
|
||
<span class="mix-name">{{ p.n }}</span>
|
||
<Mono>{{ p.v.toLocaleString('da-DK') }} DKK · {{ p.p }}%</Mono>
|
||
</div>
|
||
<div class="mix-bar"><div class="mix-fill" :style="{ width: p.p + '%', background: p.c }" /></div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card>
|
||
<Eyebrow>Top customers</Eyebrow>
|
||
<div class="card-title">By MRR</div>
|
||
<div class="top-list">
|
||
<div v-for="c in topByMrr" :key="c.id" class="top-row">
|
||
<div class="top-swatch" :style="{ background: c.brandColor }" />
|
||
<span class="top-name">{{ c.name }}</span>
|
||
<Mono>{{ c.mrrDkk.toLocaleString('da-DK') }} DKK</Mono>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CHURN -->
|
||
<div v-if="tab === 'churn'" class="content">
|
||
<div class="stat-strip">
|
||
<Card>
|
||
<Stat label="Signup cohorts" :value="churnRows.length" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="Avg retention" :value="`${avgRetention}%`" :delta-tone="avgRetention >= 80 ? 'up' : 'down'" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="Active customers" :value="cohort.healthy + cohort.watch" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="At risk" :value="cohort.risk" :delta-tone="cohort.risk > 0 ? 'down' : undefined" />
|
||
</Card>
|
||
</div>
|
||
|
||
<Card :pad="0">
|
||
<div class="card-head">
|
||
<div>
|
||
<Eyebrow>Cohort retention</Eyebrow>
|
||
<div class="card-title">Customers by signup month</div>
|
||
</div>
|
||
</div>
|
||
<div class="cohort-wrap">
|
||
<table class="cohort">
|
||
<thead>
|
||
<tr>
|
||
<th>Cohort</th>
|
||
<th>Customers</th>
|
||
<th>Retained</th>
|
||
<th>Retention</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<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
|
||
class="heat"
|
||
:style="{
|
||
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)',
|
||
}"
|
||
>{{ 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>
|
||
</Card>
|
||
|
||
<Card>
|
||
<Eyebrow>Why customers leave</Eyebrow>
|
||
<div class="card-title">Top exit reasons (last 12 months)</div>
|
||
<p class="exit-empty">
|
||
No churn yet. When customers do leave, exit reasons will surface here automatically (from cancel/pause flow inputs).
|
||
</p>
|
||
</Card>
|
||
</div>
|
||
|
||
<!-- CUSTOM REPORTS -->
|
||
<div v-if="tab === 'custom'" class="content">
|
||
<div class="custom-head">
|
||
<p class="custom-blurb">
|
||
Build a report once, schedule or run on demand. We'll email a PDF to the recipients you specify.
|
||
</p>
|
||
<UiButton variant="primary" @click="newReportOpen = true">
|
||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||
New custom report
|
||
</UiButton>
|
||
</div>
|
||
|
||
<Card :pad="0">
|
||
<table class="dtable">
|
||
<thead>
|
||
<tr>
|
||
<th>Report</th>
|
||
<th>Schedule</th>
|
||
<th>Owner</th>
|
||
<th>Last run</th>
|
||
<th class="action-col" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="r in savedReports" :key="r.id">
|
||
<td><span class="cust-name">{{ r.name }}</span></td>
|
||
<td><Mono>{{ r.schedule }}</Mono></td>
|
||
<td>
|
||
<div class="owner-cell">
|
||
<Avatar :name="r.owner" :size="20" />
|
||
<span>{{ r.owner }}</span>
|
||
</div>
|
||
</td>
|
||
<td><Mono dim>{{ r.last }}</Mono></td>
|
||
<td class="action-col" @click.stop>
|
||
<div class="actions-row">
|
||
<UiButton size="sm" variant="ghost" @click="runReport(r.id)">
|
||
<template #leading>
|
||
<UiIcon :name="running === r.id ? 'refresh' : 'external'" :size="13" />
|
||
</template>
|
||
{{ running === r.id ? 'Running…' : 'Run' }}
|
||
</UiButton>
|
||
<button class="kebab" @click="openReportMenu(r.id, $event)">
|
||
<UiIcon name="more" :size="13" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
</div>
|
||
|
||
<!-- Portaled custom report row actions menu -->
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="reportMenuFor"
|
||
class="menu"
|
||
:style="{ top: reportMenuPos.top + 'px', right: reportMenuPos.right + 'px' }"
|
||
@click.stop
|
||
>
|
||
<template v-for="(it, i) in reportActions(savedReports.find(r => r.id === reportMenuFor)!)" :key="i">
|
||
<div v-if="it.sep" class="menu-sep" />
|
||
<button
|
||
v-else
|
||
class="menu-item"
|
||
:class="{ danger: it.danger }"
|
||
@click="(it.fn?.(), closeMenu())"
|
||
>
|
||
<UiIcon :name="(it.i as any)" :size="14" />
|
||
<span>{{ it.l }}</span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<!-- Confirm delete report modal -->
|
||
<Modal
|
||
:open="!!confirmDeleteId"
|
||
eyebrow="Permanent action"
|
||
title="Delete this report?"
|
||
size="sm"
|
||
@close="confirmDeleteId = null"
|
||
>
|
||
<template v-if="confirmDeleteReport">
|
||
<div class="danger-callout">
|
||
<UiIcon name="trash" :size="16" />
|
||
<p>
|
||
The report configuration and its schedule will be deleted. Past PDFs already delivered to recipients are unaffected.
|
||
</p>
|
||
</div>
|
||
<div class="del-summary">
|
||
<dl class="def">
|
||
<div><dt>Report</dt><dd>{{ confirmDeleteReport.name }}</dd></div>
|
||
<div><dt>Schedule</dt><dd>{{ confirmDeleteReport.schedule }}</dd></div>
|
||
<div><dt>Recipients</dt><dd>{{ confirmDeleteReport.recipients }} addresses</dd></div>
|
||
<div><dt>Last run</dt><dd>{{ confirmDeleteReport.last }}</dd></div>
|
||
</dl>
|
||
</div>
|
||
</template>
|
||
<template #footer>
|
||
<UiButton variant="ghost" @click="confirmDeleteId = null">Cancel</UiButton>
|
||
<UiButton variant="danger" @click="deleteReport">
|
||
<template #leading><UiIcon name="trash" :size="14" /></template>
|
||
Delete report
|
||
</UiButton>
|
||
</template>
|
||
</Modal>
|
||
|
||
<PartnerCustomerTaskPanel
|
||
:task="taskCtx"
|
||
@close="taskCtx = null"
|
||
@save="(t) => toast.ok(t.mode === 'escalate' ? 'Escalation created' : 'Check-in scheduled', t.customer.name)"
|
||
/>
|
||
<PartnerNewCustomReportModal
|
||
:open="newReportOpen"
|
||
@close="newReportOpen = false"
|
||
@created="onCreated"
|
||
/>
|
||
|
||
<!-- Export PDF Modal -->
|
||
<Modal
|
||
:open="exportOpen"
|
||
eyebrow="Partner reports · export"
|
||
title="Export reports to PDF"
|
||
size="md"
|
||
@close="exportOpen = false"
|
||
>
|
||
<p class="export-blurb">Select which tabs to include in the PDF, the period, cover style, and how you want it delivered.</p>
|
||
<div class="export-meta">
|
||
<Mono dim>// pdf preview · 3 sections · estimated 11 pages · NordicMSP cover + footer</Mono>
|
||
</div>
|
||
<template #footer>
|
||
<UiButton variant="ghost" @click="exportOpen = false">Cancel</UiButton>
|
||
<UiButton variant="primary" @click="exportOpen = false; toast.ok('PDF queued', 'You will be emailed when ready')">
|
||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||
Download PDF
|
||
</UiButton>
|
||
</template>
|
||
</Modal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.tabs-bar {
|
||
padding: 0 40px;
|
||
margin-top: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
}
|
||
.tabs-stretch { flex: 1; }
|
||
.period-chip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 0 10px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
}
|
||
.period-chip .seg-label {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: var(--text-mute);
|
||
}
|
||
.period-chip select {
|
||
border: none;
|
||
background: transparent;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
color: var(--text);
|
||
padding: 8px 4px;
|
||
cursor: pointer;
|
||
}
|
||
.period-chip select:focus { outline: none; }
|
||
|
||
.content { padding: 20px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
|
||
|
||
.stat-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||
|
||
.card-head {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.card-title {
|
||
font-family: var(--font-display);
|
||
font-weight: 600;
|
||
font-size: 17px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.big-chart { padding: 20px; }
|
||
.big-chart :deep(svg) { width: 100%; height: 160px; }
|
||
.chart-foot {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-top: 12px;
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--text-mute);
|
||
}
|
||
|
||
.dtable { width: 100%; border-collapse: collapse; }
|
||
.dtable th {
|
||
text-align: left;
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: var(--text-mute);
|
||
font-weight: 500;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.dtable th.num { text-align: right; }
|
||
.dtable th.action-col, .dtable td.action-col { width: 160px; text-align: right; }
|
||
.dtable td {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: 13px;
|
||
vertical-align: middle;
|
||
}
|
||
.dtable td.num { text-align: right; }
|
||
.dtable tbody tr:hover { background: var(--row-hover); }
|
||
|
||
.cust-cell { display: flex; align-items: center; gap: 12px; }
|
||
.cust-swatch { width: 22px; height: 22px; border-radius: 4px; flex-shrink: 0; }
|
||
.cust-name { font-size: 13px; font-weight: 500; }
|
||
|
||
.owner-cell { display: flex; align-items: center; gap: 8px; }
|
||
.owner-cell span { font-size: 12px; }
|
||
|
||
.health-cell { display: inline-flex; align-items: center; gap: 10px; }
|
||
.hbar {
|
||
width: 90px;
|
||
height: 5px;
|
||
background: var(--border);
|
||
border-radius: 999px;
|
||
overflow: hidden;
|
||
}
|
||
.hfill { height: 100%; }
|
||
|
||
/* Revenue · plan mix */
|
||
.plan-mix { display: flex; flex-direction: column; gap: 12px; margin-top: 4px; }
|
||
.mix-head { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
|
||
.mix-name { font-weight: 500; }
|
||
.mix-bar { height: 6px; background: var(--border); border-radius: 999px; overflow: hidden; }
|
||
.mix-fill { height: 100%; }
|
||
|
||
.top-list { display: flex; flex-direction: column; gap: 10px; margin-top: 4px; }
|
||
.top-row {
|
||
display: grid;
|
||
grid-template-columns: 20px 1fr 110px;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
.top-swatch { width: 14px; height: 14px; border-radius: 3px; }
|
||
.top-name { font-size: 13px; font-weight: 500; }
|
||
|
||
/* Cohort heatmap */
|
||
.cohort-wrap { overflow-x: auto; }
|
||
.cohort { width: 100%; border-collapse: collapse; min-width: 600px; }
|
||
.cohort th {
|
||
padding: 10px 16px;
|
||
text-align: center;
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--text-mute);
|
||
font-weight: 500;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.cohort th:first-child { text-align: left; padding-left: 20px; }
|
||
.cohort td { padding: 10px 16px; text-align: center; border-bottom: 1px solid var(--border); }
|
||
.cohort td:first-child { text-align: left; padding-left: 20px; }
|
||
.cohort-size { font-family: var(--font-mono); font-size: 12px; }
|
||
.cohort .cell { padding: 6px; }
|
||
.heat {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 38px;
|
||
height: 22px;
|
||
border-radius: 4px;
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.exit-empty { font-size: 13px; color: var(--text-mute); line-height: 1.6; margin: 8px 0 0; }
|
||
|
||
/* Custom reports */
|
||
.custom-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 0; }
|
||
.custom-blurb { font-size: 13px; color: var(--text-mute); margin: 0; max-width: 540px; line-height: 1.5; }
|
||
.actions-row { display: flex; gap: 4px; justify-content: flex-end; align-items: center; }
|
||
.kebab {
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-mute);
|
||
padding: 4px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
.kebab:hover { background: var(--row-hover); color: var(--text); }
|
||
|
||
/* Confirm delete + Export modals */
|
||
.danger-callout {
|
||
padding: 14px;
|
||
background: rgba(226, 48, 48, 0.06);
|
||
border: 1px solid rgba(226, 48, 48, 0.22);
|
||
border-radius: 6px;
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.danger-callout :deep(svg) { color: var(--bad); margin-top: 2px; flex-shrink: 0; }
|
||
.danger-callout p { font-size: 13px; color: var(--text-dim); line-height: 1.5; margin: 0; }
|
||
.del-summary {
|
||
padding: 12px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
}
|
||
.def { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; }
|
||
.def div { display: grid; grid-template-columns: 120px 1fr; gap: 12px; font-size: 13px; }
|
||
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
|
||
.def dd { margin: 0; }
|
||
|
||
.export-blurb { font-size: 13px; color: var(--text-dim); margin: 0 0 14px; line-height: 1.55; }
|
||
.export-meta {
|
||
padding: 12px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* Portaled menu */
|
||
.menu {
|
||
position: fixed;
|
||
min-width: 240px;
|
||
padding: 4px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||
z-index: 100;
|
||
}
|
||
.menu .menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
border-radius: 5px;
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
text-align: left;
|
||
color: var(--text);
|
||
}
|
||
.menu .menu-item:hover { background: var(--row-hover); }
|
||
.menu .menu-item.danger { color: var(--bad); }
|
||
.menu .menu-item svg { color: var(--text-mute); flex-shrink: 0; }
|
||
.menu .menu-item.danger svg { color: var(--bad); }
|
||
.menu .menu-item span { flex: 1; }
|
||
.menu .menu-sep { height: 1px; background: var(--border); margin: 4px 0; }
|
||
</style>
|