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
This commit is contained in:
Ronni Baslund
2026-05-28 20:00:33 +02:00
parent be430179d9
commit 0bd4e5498e
144 changed files with 22110 additions and 209 deletions
+764
View File
@@ -0,0 +1,764 @@
<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>