0bd4e5498e
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
765 lines
27 KiB
Vue
765 lines
27 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
|
||
|
||
|
||
|
||
import { customers, partnerMrrSparkline } from '~/data/customers'
|
||
import type { CustomerOrg } from '~/data/customers'
|
||
import type { TaskContext } from '~/components/partner/CustomerTaskPanel.vue'
|
||
|
||
const toast = useToast()
|
||
|
||
const tab = ref<'health' | 'revenue' | 'churn' | 'custom'>('health')
|
||
const period = ref<'30d' | '90d' | '12mo' | 'ytd'>('90d')
|
||
|
||
const tabs = [
|
||
{ value: 'health', label: 'Customer health' },
|
||
{ value: 'revenue', label: 'Revenue' },
|
||
{ value: 'churn', label: 'Churn' },
|
||
{ value: 'custom', label: 'Custom reports', count: 3 },
|
||
]
|
||
|
||
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 ─────────────────────────────────────────────────────────────────────
|
||
// Health scoring exactly mirrors platform-partner-depth.jsx:73-80.
|
||
const scored = computed(() => customers.map((c) => {
|
||
let score = 100
|
||
if (c.status === 'past_due') score -= 50
|
||
else if (c.status === 'attention') score -= 30
|
||
else if (c.status === 'trial') score -= 10
|
||
if (c.seats.used / c.seats.total > 0.85) score -= 10
|
||
return { ...c, score }
|
||
}))
|
||
|
||
const cohort = computed(() => ({
|
||
healthy: scored.value.filter((c) => c.score >= 75).length,
|
||
watch: scored.value.filter((c) => c.score >= 50 && c.score < 75).length,
|
||
risk: scored.value.filter((c) => c.score < 50).length,
|
||
}))
|
||
|
||
function healthColor(h: number) {
|
||
if (h >= 75) return 'var(--ok)'
|
||
if (h >= 50) 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 ────────────────────────────────────────────────────────────────────
|
||
const totalMrr = computed(() => customers.reduce((s, c) => s + c.mrrDkk, 0))
|
||
|
||
// Top 5 by MRR
|
||
const topByMrr = computed(() => [...customers].sort((a, b) => b.mrrDkk - a.mrrDkk).slice(0, 5))
|
||
|
||
// By-plan revenue mix · platform-partner-depth.jsx:176-180
|
||
const revenueMix = [
|
||
{ n: 'Enterprise', v: 42900, p: 77, c: 'var(--text)' },
|
||
{ n: 'Business', v: 11340, p: 20, c: 'var(--info)' },
|
||
{ n: 'Starter', v: 1510, p: 3, c: 'var(--text-mute)' },
|
||
]
|
||
|
||
// CHURN cohort heatmap · platform-partner-depth.jsx:237-243
|
||
const cohorts: Array<[string, number, Array<number | '—'>]> = [
|
||
['Nov 2024', 1, [100, 100, 100, 100, 100, 100]],
|
||
['Aug 2025', 1, [100, 100, 100, 100, 100, '—']],
|
||
['Sep 2025', 1, [100, 100, 100, 100, 100, '—']],
|
||
['Feb 2026', 3, [100, 100, 100, '—', '—', '—']],
|
||
['Mar 2026', 2, [100, 100, '—', '—', '—', '—']],
|
||
['May 2026', 1, [100, '—', '—', '—', '—', '—']],
|
||
]
|
||
const cohortHeaders = ['M+0', 'M+1', 'M+2', 'M+3', 'M+6', 'M+12']
|
||
|
||
// CUSTOM REPORTS · platform-partner-depth.jsx:280-283
|
||
const savedReports = ref([
|
||
{ id: 'r1', name: 'Quarterly board · Q1 2026', owner: 'Anne Baslund', schedule: 'Quarterly · 1st', last: '03 Apr 2026', recipients: 4, format: 'PDF' },
|
||
{ id: 'r2', name: 'Customer Health · weekly digest', owner: 'Anne Baslund', schedule: 'Mondays 09:00 CET', last: '13 May 2026', recipients: 2, format: 'PDF' },
|
||
{ id: 'r3', name: 'Margin breakdown by partner cut', owner: 'Mikkel Nørgaard', schedule: 'On-demand', last: '08 May 2026', recipients: 1, format: 'CSV' },
|
||
])
|
||
|
||
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))
|
||
|
||
function deleteReport() {
|
||
const r = savedReports.value.find((x) => x.id === confirmDeleteId.value)
|
||
if (r) {
|
||
savedReports.value = savedReports.value.filter((x) => x.id !== confirmDeleteId.value)
|
||
toast.bad('Report deleted', r.name)
|
||
}
|
||
confirmDeleteId.value = null
|
||
}
|
||
|
||
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" :delta="`${Math.round(cohort.healthy / scored.length * 100)}% of portfolio`" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="Watch" :value="cohort.watch" delta="2 customers · check in" delta-tone="up" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="At risk" :value="cohort.risk" delta="1 customer · escalate" delta-tone="down" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="NPS · est" value="58" delta="+6 from last period" delta-tone="up" hint="based on 12 responses" />
|
||
</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="55.750 DKK" delta="+18.2%" delta-tone="up" hint="vs. 90d ago" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="Partner margin" value="11.150 DKK" delta="+19.0%" delta-tone="up" hint="20% of MRR" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="ARR · projected" value="669.000 DKK" delta="+24% YoY" delta-tone="up" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="ARPU" value="6.969 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>Feb 14 · 38.180 DKK</span>
|
||
<span>May 14 · 55.750 DKK</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="Gross churn · 90d" value="0%" delta="0 customers" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="Net churn · MRR" value="−2.1%" delta="contracted from upgrades" delta-tone="up" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="At-risk MRR" value="2.940 DKK" hint="1 customer · past-due" delta-tone="down" />
|
||
</Card>
|
||
<Card>
|
||
<Stat label="Avg tenure" value="14 mo" delta="trending up" delta-tone="up" />
|
||
</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>Size</th>
|
||
<th v-for="h in cohortHeaders" :key="h">{{ h }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(c, i) in cohorts" :key="i">
|
||
<td><Mono>{{ c[0] }}</Mono></td>
|
||
<td class="cohort-size"><Mono>{{ c[1] }}</Mono></td>
|
||
<td v-for="(v, j) in c[2]" :key="j" class="cell">
|
||
<Mono v-if="v === '—'" dim>—</Mono>
|
||
<span
|
||
v-else
|
||
class="heat"
|
||
:style="{
|
||
background: (v as number) >= 100 ? 'rgba(31,138,91,0.16)' : (v as number) >= 80 ? 'rgba(232,154,31,0.16)' : 'rgba(226,48,48,0.16)',
|
||
color: (v as number) >= 100 ? 'var(--ok)' : (v as number) >= 80 ? 'var(--warn)' : 'var(--bad)',
|
||
}"
|
||
>{{ v }}%</span>
|
||
</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="(n) => toast.ok('Report created', n)"
|
||
/>
|
||
|
||
<!-- 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>
|