Files
dezky/apps/portal/pages/partner/index.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

574 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 { customers, partnerMrrSparkline, partner as fixturePartner } from '~/data/customers'
import type { CustomerOrg } from '~/data/customers'
const toast = useToast()
const router = useRouter()
const partnerMode = usePartnerMode()
// Real partner identity from /api/me; falls back to the fixture if the user
// somehow lands here without partner data (middleware should've redirected
// them, but defending the read keeps the page from crashing).
const { partner: realPartner } = useMe()
const partner = computed(() => realPartner.value ?? fixturePartner)
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` : ''))
// Sparkline is still fixture-driven — historical MRR isn't stored yet, so
// the chart shape is decorative. Keep it for the design until we wire a
// daily MRR snapshot job.
const sparkline = partnerMrrSparkline
const sparkLast = sparkline[sparkline.length - 1]
const sparkTrendPct = '18.2' // matches source label
// Attention list · partner-screens.jsx line 207-212
const alerts = [
{ id: 'a-bygherre', tone: 'bad' as const, cust: 'Bygherre Cloud', msg: 'Invoice 21 days past due · 2.940 DKK', action: 'Review', custId: 'c-bygherre' },
{ id: 'a-henriksen', tone: 'warn' as const, cust: 'Henriksen Revision', msg: 'SPF record missing on h-revision.dk', action: 'Fix DNS', custId: 'c-henriksen' },
{ id: 'a-aalborg', tone: 'warn' as const, cust: 'Aalborg Logistik', msg: 'Approaching seat limit · 87/100 used', action: 'Upsell', custId: 'c-aalborg' },
{ id: 'a-norrebro', tone: 'info' as const, cust: 'Nørrebro Studio', msg: 'Trial ends in 7 days', action: 'Follow up', custId: 'c-norrebro' },
]
// Recent activity · partner-screens.jsx line 332-336
const activity = [
{ when: '14:02', cust: 'Acme Workspace', who: 'Anne Baslund', action: 'invited 3 users', tone: 'info' as const },
{ when: '12:18', cust: 'Bygherre Cloud', who: 'system', action: 'invoice marked past-due', tone: 'bad' as const },
{ when: '11:44', cust: 'Aalborg Logistik', who: 'Sofie Lindberg', action: 'upgraded to Enterprise', tone: 'ok' as const },
{ when: '10:08', cust: 'Nørrebro Studio', who: 'NordicMSP', action: 'created new customer org', tone: 'info' as const },
{ when: '09:34', cust: 'Henriksen Revision', who: 'system', action: 'DNS health alert · SPF', tone: 'warn' as const },
]
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: typeof alerts[number]) {
toast.ok(`${a.action}: ${a.cust}`, 'Workflow stub fired')
}
function activitySwatch(name: string) {
return customers.find((c) => c.name === name)?.brandColor || 'var(--text-mute)'
}
// ── 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="`${partner.name} · 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="alerts.length" hint="1 critical · 2 warning" />
</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 class="attn-list">
<div
v-for="a in alerts"
: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>