Files
dezky/apps/portal/pages/partner/reports.vue
T
Ronni Baslund 0bd4e5498e feat: portal redesign, pricing catalog, partner-staff invites
- 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
2026-05-28 20:00:33 +02:00

765 lines
27 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
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>