Files
Ronni Baslund 3288fde693 feat(portal): customer-admin surface on real data + Stripe billing + session resilience
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
  leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
  launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.

Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
  invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
  and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
  honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.

Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.

Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
  name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
  with YIQ auto-contrast (readableOn util).

Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
  pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
  drag-select-to-close).
2026-05-31 00:19:34 +02:00

935 lines
33 KiB
Vue
Raw Permalink 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
import type { CustomerOrg, CustomerStatus } from '~/types/partner'
import type { TaskContext } from '~/components/partner/CustomerTaskPanel.vue'
const toast = useToast()
const { request } = useApiFetch()
// Decorative MRR sparkline shape only — historical MRR isn't stored yet (a
// daily-snapshot job lands later; see useMrrTrendline). The live numbers
// below are all real.
const mrrTrend = useMrrTrendline()
// ── 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 request(`/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 request('/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="mrrTrend" :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>