Files
dezky/apps/portal/pages/partner/reports.vue
T
Ronni Baslund 6370e392cc 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.
2026-05-30 08:03:14 +02:00

932 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="0100 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>