Files
Ronni Baslund 9c08973e46 refactor(portal): delete data/customers.ts fixture entirely
Replace the last two holdouts: the dashboard partner-identity fallback now uses the real useMe().partner name, and the decorative MRR sparkline (dashboard + reports) moves to a generated useMrrTrendline() — deterministic, clearly placeholder-only, until a daily MRR-snapshot job exists. Removes the dead sparkLast/sparkTrendPct vars. The data/customers.ts fixture module is now fully deleted; the partner portal carries no mock business data.
2026-05-30 14:57:17 +02:00

596 lines
20 KiB
Vue

<script setup lang="ts">
// Partner dashboard. Landing page for partner-admin role. Mirrors
// partner-screens.jsx PartnerDashboard (lines 196-365) line-for-line:
// MRR-card-plus-3-stat strip, customer-health 4-col grid, attention list,
// recent activity table.
import type { CustomerOrg } from '~/types/partner'
const toast = useToast()
const router = useRouter()
const partnerMode = usePartnerMode()
// Real partner identity from /api/me. Middleware redirects non-partner users
// away, but we default the name defensively so the header never crashes.
const { partner: realPartner } = useMe()
const partnerName = computed(() => realPartner.value?.name ?? 'Partner')
const wizardOpen = ref(false)
const entryCustomer = ref<CustomerOrg | null>(null)
// Real MRR from platform-api. Subscriptions are grouped by currency so a
// partner with mixed-currency customers (e.g. some DKK, some EUR) sees a
// per-currency total rather than an FX-fudged single number.
type Currency = 'DKK' | 'EUR' | 'USD'
interface MrrResponse {
totals: Array<{ currency: Currency; monthlyMinor: number }>
breakdown: Array<{
tenantId: string
tenantName: string
currency: Currency
monthlyMinor: number
custom: boolean
}>
}
const { data: mrr } = await useFetch<MrrResponse>('/api/partner/mrr', {
key: 'partner-mrr',
default: () => ({ totals: [], breakdown: [] }),
})
const totalsDisplay = computed(() =>
(mrr.value?.totals ?? []).map((t) => ({
currency: t.currency,
majorAmount: Math.round(t.monthlyMinor / 100),
})),
)
const hasCustomPriced = computed(() => (mrr.value?.breakdown ?? []).some((b) => b.custom))
// Compact one-line summary used in the page subtitle.
const totalsLine = computed(() => {
const parts = totalsDisplay.value.map(
(t) => `${t.majorAmount.toLocaleString('da-DK')} ${t.currency}`,
)
if (parts.length === 0) return '0 DKK / mo'
return parts.join(' + ') + ' / mo'
})
// Real customer count from the breakdown.
const totalCustomers = computed(() => (mrr.value?.breakdown ?? []).length)
// Real end-user count = sum of active User docs across this partner's
// tenants. Reuses the cached /api/partner/tenants response (same key as
// the customers page) so this dashboard doesn't issue a second fetch.
interface PartnerTenant {
_id: string
slug: string
name: string
status: 'active' | 'pending' | 'suspended' | 'deleted'
plan?: 'mvp' | 'pro' | 'enterprise'
seats?: number
userCount?: number
newUserCount30d?: number // active users created in the last 30 days
createdAt?: string // tenant creation timestamp, for the customers delta
provisioningStatus?: {
authentik?: 'pending' | 'ok' | 'error' | 'skipped'
stalwart?: 'pending' | 'ok' | 'error' | 'skipped'
ocis?: 'pending' | 'ok' | 'error' | 'skipped'
}
}
const { data: tenants } = await useFetch<PartnerTenant[]>('/api/partner/tenants', {
key: 'partner-tenants',
default: () => [],
})
const totalUsers = computed(() =>
(tenants.value ?? []).reduce((s, t) => s + (t.userCount ?? 0), 0),
)
// 30-day deltas. Customers delta is derived from tenant.createdAt (already
// on the doc); end-user delta uses the aggregated newUserCount30d. Both
// render as "+N / 30d" — or hide when 0 to keep the card clean on a quiet
// month.
const SINCE_30D = Date.now() - 30 * 24 * 60 * 60 * 1000
const newCustomers30d = computed(
() => (tenants.value ?? []).filter((t) => t.createdAt && new Date(t.createdAt).getTime() >= SINCE_30D).length,
)
const newUsers30d = computed(
() => (tenants.value ?? []).reduce((s, t) => s + (t.newUserCount30d ?? 0), 0),
)
const customersDelta = computed(() => (newCustomers30d.value > 0 ? `+${newCustomers30d.value} / 30d` : ''))
const usersDelta = computed(() => (newUsers30d.value > 0 ? `+${newUsers30d.value} / 30d` : ''))
// Decorative MRR sparkline shape — historical MRR isn't stored yet (see
// useMrrTrendline). Purely visual until a daily MRR-snapshot job exists.
const sparkline = useMrrTrendline()
// Attention list — derived from real tenant state (no fixtures). Surfaces
// suspended customers, provisioning errors, seat pressure, and pending/trial
// tenants. Each links to /partner/customers.
interface DashAlert {
id: string
tone: 'bad' | 'warn' | 'info'
cust: string
msg: string
action: string
slug: string
}
const derivedAlerts = computed<DashAlert[]>(() => {
const out: DashAlert[] = []
for (const t of tenants.value ?? []) {
if (t.status === 'suspended') {
out.push({ id: `susp-${t._id}`, tone: 'bad', cust: t.name, msg: 'Customer suspended', action: 'Review', slug: t.slug })
continue
}
const errored = Object.entries(t.provisioningStatus ?? {})
.filter(([, s]) => s === 'error')
.map(([k]) => k)
if (errored.length) {
out.push({ id: `prov-${t._id}`, tone: 'bad', cust: t.name, msg: `Provisioning error · ${errored.join(', ')}`, action: 'Reconcile', slug: t.slug })
}
const seats = t.seats ?? 0
const used = t.userCount ?? 0
if (seats > 0 && used / seats > 0.85) {
out.push({ id: `seat-${t._id}`, tone: 'warn', cust: t.name, msg: `Approaching seat limit · ${used}/${seats} used`, action: 'Upsell', slug: t.slug })
}
if (t.status === 'pending') {
out.push({ id: `pend-${t._id}`, tone: 'info', cust: t.name, msg: 'Awaiting provisioning', action: 'Follow up', slug: t.slug })
}
}
return out
})
const alertCounts = computed(() => ({
bad: derivedAlerts.value.filter((a) => a.tone === 'bad').length,
warn: derivedAlerts.value.filter((a) => a.tone === 'warn').length,
}))
const issuesHint = computed(() => {
const { bad, warn } = alertCounts.value
if (bad === 0 && warn === 0) return 'all clear'
return `${bad} critical · ${warn} warning`
})
function statusBadge(s: string): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
switch (s) {
case 'healthy': return { tone: 'ok', label: 'healthy' }
case 'attention': return { tone: 'warn', label: 'attention' }
case 'past_due': return { tone: 'bad', label: 'past-due' }
case 'trial': return { tone: 'info', label: 'trial' }
default: return { tone: 'neutral', label: s }
}
}
function startEnter(c: CustomerOrg) {
entryCustomer.value = c
}
function confirmEnter(reason: string) {
if (!entryCustomer.value) return
const c = entryCustomer.value
partnerMode.enter(c.id)
entryCustomer.value = null
toast.info(`Entered ${c.name}`, reason ? `Reason: ${reason}` : 'No reason captured')
router.push('/admin')
}
function onAlert(_a: DashAlert) {
router.push('/partner/customers')
}
// ── Real health + activity (replace fixture cards) ───────────────────────
// Health badge derived from real tenant state. We have:
// - Tenant.status: active / pending / suspended / deleted
// - provisioningStatus per integration: ok / pending / error / skipped
// Map: any provisioning error or suspended/deleted → bad. Pending or
// awaiting provisioning → warn. Active + all integrations ok|skipped → ok.
function tenantHealth(t: PartnerTenant): 'ok' | 'warn' | 'bad' {
if (t.status === 'suspended' || t.status === 'deleted') return 'bad'
const states = Object.values(t.provisioningStatus ?? {}) as Array<string | undefined>
if (states.some((s) => s === 'error')) return 'bad'
if (t.status === 'pending' || states.some((s) => s === 'pending')) return 'warn'
return 'ok'
}
const PLAN_LABEL: Record<'mvp' | 'pro' | 'enterprise', string> = {
mvp: 'Starter',
pro: 'Business',
enterprise: 'Enterprise',
}
const healthTiles = computed(() =>
(tenants.value ?? []).map((t) => ({
id: t._id,
slug: t.slug,
name: t.name,
planLabel: PLAN_LABEL[t.plan ?? 'pro'],
usedSeats: t.userCount ?? 0,
totalSeats: t.seats ?? 0,
tone: tenantHealth(t),
})),
)
const healthyCount = computed(() => healthTiles.value.filter((t) => t.tone === 'ok').length)
// Real audit feed. Each event has resourceName + actor.email + at +
// outcome ('success'|'failure'|'pending'). We render the verb in
// dotted form (e.g. tenant.created) since the dashboard is a glance
// view — clickthrough to /partner/audit can show the full row context.
interface ActivityEvent {
_id: string
at: string
action: string
resourceName?: string
tenantSlug?: string
outcome?: 'success' | 'failure' | 'pending'
actor?: { email?: string; userId?: string }
}
const { data: activityRaw } = await useFetch<ActivityEvent[]>('/api/partner/activity', {
key: 'partner-activity',
query: { limit: 8 },
default: () => [],
})
function eventTone(e: ActivityEvent): 'ok' | 'warn' | 'bad' | 'info' {
if (e.outcome === 'failure') return 'bad'
if (e.outcome === 'pending') return 'warn'
// Heuristic: actions ending in .deleted / .suspended / .terminated read as bad
if (/\.(deleted|suspended|terminated|removed)$/.test(e.action)) return 'bad'
return 'info'
}
function fmtTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })
}
function tenantNameFromSlug(slug?: string): string {
if (!slug) return '—'
return tenants.value?.find((t) => t.slug === slug)?.name ?? slug
}
const realActivity = computed(() =>
(activityRaw.value ?? []).map((e) => ({
id: e._id,
when: fmtTime(e.at),
cust: tenantNameFromSlug(e.tenantSlug),
who: e.actor?.email ?? 'system',
action: e.action,
tone: eventTone(e),
})),
)
function provisioned() {
toast.ok('Customer provisioned', 'Welcome email is on its way to the first admin')
}
</script>
<template>
<div>
<PageHeader
:eyebrow="`${partnerName} · Partner console`"
title="Portfolio overview"
:subtitle="`${totalCustomers} customer organizations · ${totalUsers} end users · ${totalsLine} MRR${hasCustomPriced ? ' (+ custom-priced)' : ''}`"
>
<template #actions>
<UiButton variant="secondary" @click="toast.ok('Exporting', 'Portfolio PDF · sent to your inbox')">
<template #leading><UiIcon name="download" :size="14" /></template>
Export report
</UiButton>
<UiButton variant="primary" @click="wizardOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New customer
</UiButton>
</template>
</PageHeader>
<div class="content">
<!-- Top strip: MRR card (1.4fr) + 3 stat cards -->
<div class="top-strip">
<Card :pad="0" class="mrr-card">
<div class="mrr-head">
<Eyebrow>Current MRR</Eyebrow>
<div class="mrr-totals">
<template v-if="totalsDisplay.length === 0">
<div class="mrr-value">0 <span class="dkk">DKK / mo</span></div>
</template>
<template v-else>
<div v-for="t in totalsDisplay" :key="t.currency" class="mrr-line">
<span class="mrr-amount">{{ t.majorAmount.toLocaleString('da-DK') }}</span>
<span class="mrr-cur">{{ t.currency }} / mo</span>
</div>
</template>
<Mono v-if="hasCustomPriced" dim>+ custom-priced</Mono>
</div>
</div>
<div class="mrr-chart">
<!-- Sparkline values are placeholder until a daily MRR-snapshot
job exists. Multi-currency makes a single sparkline less
meaningful anyway; the chart is decorative for now. -->
<PartnerSparkline :values="sparkline" :width="420" :height="64" stroke="var(--text)" fill="var(--row-hover)" />
</div>
</Card>
<Card>
<Stat label="Customers" :value="totalCustomers" :delta="customersDelta" delta-tone="up" />
</Card>
<Card>
<Stat label="End users" :value="totalUsers" :delta="usersDelta" delta-tone="up" />
</Card>
<Card>
<Stat label="Issues" :value="derivedAlerts.length" :hint="issuesHint" />
</Card>
</div>
<!-- Health grid + Attention -->
<div class="grid-2">
<Card :pad="0">
<div class="card-head">
<div>
<Eyebrow>Health</Eyebrow>
<div class="card-title">Customer status</div>
</div>
<Mono dim>{{ healthyCount }} healthy of {{ totalCustomers }}</Mono>
</div>
<div v-if="healthTiles.length === 0" class="empty-state">
<Mono dim>// no customers yet — provision your first from the New customer button</Mono>
</div>
<div v-else class="health-grid">
<NuxtLink
v-for="t in healthTiles"
:key="t.id"
:to="`/partner/customers`"
class="health-tile"
>
<div class="tile-head">
<span class="tile-name">{{ t.name }}</span>
<StatusDot :color="`var(--${t.tone})`" :size="6" :glow="false" />
</div>
<div class="tile-meta">{{ t.planLabel }} · {{ t.usedSeats }}/{{ t.totalSeats }}</div>
<Mono dim class="tile-slug">{{ t.slug }}</Mono>
</NuxtLink>
</div>
</Card>
<Card :pad="0">
<div class="card-head">
<div>
<Eyebrow>Attention</Eyebrow>
<div class="card-title">What needs your attention</div>
</div>
</div>
<div v-if="derivedAlerts.length === 0" class="empty-state">
<Mono dim>// nothing needs attention right now</Mono>
</div>
<div v-else class="attn-list">
<div
v-for="a in derivedAlerts"
:key="a.id"
class="attn-row"
:style="{ borderLeftColor: `var(--${a.tone})` }"
>
<div class="attn-meta">
<div class="attn-top">
<span class="attn-cust">{{ a.cust }}</span>
<Mono dim>{{ a.tone }}</Mono>
</div>
<div class="attn-msg">{{ a.msg }}</div>
</div>
<UiButton size="sm" variant="secondary" @click="onAlert(a)">{{ a.action }}</UiButton>
</div>
</div>
</Card>
</div>
<!-- Recent activity -->
<Card :pad="0">
<div class="card-head">
<div>
<Eyebrow>Activity</Eyebrow>
<div class="card-title">Recent across portfolio</div>
</div>
<UiButton size="sm" variant="ghost" @click="router.push('/partner/audit')">
View all
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
</UiButton>
</div>
<div v-if="realActivity.length === 0" class="empty-state">
<Mono dim>// no recent events yet</Mono>
</div>
<div v-else class="activity-list">
<div
v-for="(a, i) in realActivity"
:key="a.id"
class="activity-row"
:class="{ last: i === realActivity.length - 1 }"
>
<Mono>{{ a.when }}</Mono>
<div class="activity-cust">
<span class="activity-cust-name">{{ a.cust }}</span>
</div>
<div class="activity-text">
<span class="activity-who">{{ a.who }}</span> <Mono dim>{{ a.action }}</Mono>
</div>
<div class="activity-tone">
<Badge :tone="a.tone" dot>{{ a.tone }}</Badge>
</div>
</div>
</div>
</Card>
</div>
<PartnerCustomerCreateWizard
:open="wizardOpen"
@close="wizardOpen = false"
@done="provisioned"
/>
<PartnerEnterCustomerConfirmModal
:customer="entryCustomer"
@close="entryCustomer = null"
@confirm="confirmEnter"
/>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
/* Top strip: MRR card (1.4fr) + 3 stat cards */
.top-strip {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1fr;
gap: 12px;
}
.mrr-card { overflow: hidden; display: flex; flex-direction: column; }
.mrr-head { padding: 20px 24px 12px; }
.mrr-value-row {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 8px;
}
.mrr-totals {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
}
.mrr-line {
display: flex;
align-items: baseline;
gap: 8px;
}
.mrr-amount {
font-family: var(--font-display);
font-weight: 600;
font-size: 28px;
letter-spacing: -0.025em;
line-height: 1.05;
}
.mrr-cur { font-size: 14px; color: var(--text-mute); font-weight: 500; }
.mrr-value {
font-family: var(--font-display);
font-weight: 600;
font-size: 32px;
letter-spacing: -0.025em;
line-height: 1;
}
.dkk { font-size: 18px; color: var(--text-mute); font-weight: 500; }
.trend {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ok);
font-weight: 500;
}
.mrr-chart { padding: 0 12px 12px; }
.mrr-chart :deep(svg) { width: 100%; height: 64px; }
/* 2-up grid */
.grid-2 {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 16px;
}
.card-head {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
margin-top: 4px;
}
/* Health grid: 4 columns of tiles */
.health-grid {
padding: 12px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.health-tile {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
text-align: left;
cursor: pointer;
color: var(--text);
font: inherit;
transition: border-color 120ms;
}
.health-tile:hover { border-color: var(--text); }
.health-tile { text-decoration: none; display: block; }
.tile-slug { display: block; margin-top: 6px; font-size: 10px; }
.empty-state {
padding: 32px 24px;
text-align: center;
color: var(--text-mute);
}
.tile-head { display: flex; align-items: center; gap: 8px; }
.tile-swatch { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; }
.tile-name {
flex: 1;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile-meta {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-mute);
margin-top: 8px;
}
.tile-mrr {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
margin-top: 6px;
}
/* Attention list */
.attn-list { padding: 8px 8px 12px; display: flex; flex-direction: column; gap: 4px; }
.attn-row {
padding: 10px 14px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
border-left: 2px solid var(--border);
}
.attn-meta { flex: 1; min-width: 0; }
.attn-top { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
.attn-cust { font-size: 12px; font-weight: 600; }
.attn-msg { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
/* Recent activity */
.activity-list { display: flex; flex-direction: column; }
.activity-row {
display: grid;
grid-template-columns: 60px 200px 1fr 100px;
align-items: center;
gap: 12px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
}
.activity-row.last { border-bottom: none; }
.activity-cust { display: flex; align-items: center; gap: 8px; }
.activity-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.activity-cust-name { font-size: 13px; font-weight: 500; }
.activity-text { font-size: 13px; color: var(--text-dim); }
.activity-who { color: var(--text); }
.activity-tone { text-align: right; }
@media (max-width: 1280px) {
.top-strip { grid-template-columns: 1fr 1fr; }
.grid-2 { grid-template-columns: 1fr; }
.health-grid { grid-template-columns: repeat(3, 1fr); }
}
</style>