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
+407
View File
@@ -0,0 +1,407 @@
<script setup lang="ts">
// Partner audit log. Strict port of PartnerAuditScreen
// (platform-partner-depth.jsx lines 1042-1098). Filter bar (search + Actor /
// Customer / Action / Last) + cross-customer audit log table + footer note.
// Click a row to open a detail SidePanel with before/after diff and origin.
const toast = useToast()
// AuditRow shape the template + side panel render against. Built from the
// real /api/partner/activity feed (see `rows` below).
interface AuditRow {
id: string
when: string // formatted timestamp for the table
whenIso: string // raw ISO for period filtering
actor: string // actor email
customer: string // tenant name resolved from tenantSlug, or '—'
customerColor: string // tile colour for the cust swatch
action: string // dotted verb e.g. 'partner.user_invited'
target: string // resourceName from the audit event
tone: 'info' | 'warn' | 'ok' | 'bad'
}
interface PartnerTenant { _id: string; slug: string; name: string }
interface ActivityEvent {
_id: string
at: string
action: string
resourceName?: string
tenantSlug?: string
outcome?: 'success' | 'failure' | 'pending'
actor?: { email?: string; userId?: string }
}
// Pull the partner's tenants (for slug→name + colour lookup) and recent
// audit events. Limit 200 — generous compared to the dashboard's 8 — so
// filters meaningfully shrink the visible set client-side. Pagination via
// `?before` lands when 200 is regularly hit.
const { data: tenants } = await useFetch<PartnerTenant[]>('/api/partner/tenants', {
key: 'partner-tenants',
default: () => [],
})
const { data: events } = await useFetch<ActivityEvent[]>('/api/partner/activity', {
key: 'partner-activity-full',
query: { limit: 200 },
default: () => [],
})
function tenantNameFromSlug(slug?: string): string {
if (!slug) return '—'
return tenants.value?.find((t) => t.slug === slug)?.name ?? slug
}
// Deterministic colour per tenant slug so the swatch stays stable across
// reloads even though we don't store brand colours on Tenant yet.
const PALETTE = ['#D4FF3A', '#4D8BE8', '#34C77B', '#F0B14A', '#F05858', '#A78BFA']
function tenantColor(slug?: string): string {
if (!slug) return 'var(--text-mute)'
let h = 0
for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) | 0
return PALETTE[Math.abs(h) % PALETTE.length]
}
function eventTone(e: ActivityEvent): AuditRow['tone'] {
if (e.outcome === 'failure') return 'bad'
if (e.outcome === 'pending') return 'warn'
if (/\.(deleted|suspended|terminated|removed)$/.test(e.action)) return 'bad'
return 'info'
}
function fmtTime(iso: string): string {
// Always full date + time. The "today shows time only" shortcut made it
// unclear whether a bare "08.35" was this morning or yesterday morning;
// for an audit log, consistency beats brevity.
return new Date(iso).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'short' })
}
const rows = computed<AuditRow[]>(() =>
(events.value ?? []).map((e) => ({
id: e._id,
when: fmtTime(e.at),
whenIso: e.at,
actor: e.actor?.email ?? 'system',
customer: tenantNameFromSlug(e.tenantSlug),
customerColor: tenantColor(e.tenantSlug),
action: e.action,
target: e.resourceName ?? '—',
tone: eventTone(e),
})),
)
const query = ref('')
const actorFilter = ref<string>('all')
const customerFilter = ref<string>('all')
const actionFilter = ref<string>('all')
const periodFilter = ref<'24h' | '7d' | '30d'>('7d')
const actors = computed(() => Array.from(new Set(rows.value.map((r) => r.actor))))
const actions = computed(() => Array.from(new Set(rows.value.map((r) => r.action))).sort())
// Distinct customer list for the dropdown — replaces the fixture customers
// array. '—' (partner-scoped events) shows up as a special "Partner-level"
// option so filtering to those is easy.
const customerOptions = computed(() => {
const names = new Set<string>()
for (const r of rows.value) names.add(r.customer)
return Array.from(names).sort()
})
const PERIOD_MS: Record<typeof periodFilter.value, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
}
const filtered = computed(() => {
const cutoff = Date.now() - PERIOD_MS[periodFilter.value]
return rows.value.filter((r) => {
if (new Date(r.whenIso).getTime() < cutoff) return false
if (actorFilter.value !== 'all' && r.actor !== actorFilter.value) return false
if (actionFilter.value !== 'all' && r.action !== actionFilter.value) return false
if (customerFilter.value !== 'all' && r.customer !== customerFilter.value) return false
if (query.value) {
const q = query.value.toLowerCase()
if (!(r.actor + ' ' + r.customer + ' ' + r.action + ' ' + r.target).toLowerCase().includes(q)) return false
}
return true
})
})
function customerColor(name: string) {
if (name === '—') return 'var(--text-mute)'
// Look up tenant by name to find the slug, then derive colour.
const t = tenants.value?.find((x) => x.name === name)
return tenantColor(t?.slug)
}
const detail = ref<AuditRow | null>(null)
</script>
<template>
<div>
<PageHeader
eyebrow="Compliance"
title="Partner audit log"
subtitle="Every action your team has taken across your customer portfolio. Customer admins see this in their own audit log too."
/>
<div class="content">
<div class="filters">
<div class="search">
<UiIcon name="search" :size="14" />
<input v-model="query" placeholder="actor, customer, action…" />
</div>
<div class="seg">
<span class="seg-label">Actor</span>
<select v-model="actorFilter">
<option value="all">Anyone</option>
<option v-for="a in actors" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Customer</span>
<select v-model="customerFilter">
<option value="all">All customers</option>
<option v-for="name in customerOptions" :key="name" :value="name">{{ name }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Action</span>
<select v-model="actionFilter">
<option value="all">All actions</option>
<option v-for="a in actions" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Last</span>
<select v-model="periodFilter">
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
<div class="spacer" />
<UiButton variant="secondary" @click="toast.ok('Exporting CSV', `${filtered.length} entries`)">
<template #leading><UiIcon name="download" :size="14" /></template>
Export CSV
</UiButton>
</div>
<Card :pad="0">
<table class="dtable">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Customer</th>
<th>Action</th>
<th>Target</th>
<th class="tone-col" />
</tr>
</thead>
<tbody>
<tr v-for="r in filtered" :key="r.id" @click="detail = r">
<td><Mono>{{ r.when }}</Mono></td>
<td>
<div class="actor-cell">
<Avatar :name="r.actor" :size="22" />
<div>
<div class="actor-name">{{ r.actor }}</div>
<Mono dim>partner</Mono>
</div>
</div>
</td>
<td>
<Mono v-if="r.customer === '—'" dim></Mono>
<div v-else class="cust-cell">
<div class="cust-swatch" :style="{ background: customerColor(r.customer) }" />
<span>{{ r.customer }}</span>
</div>
</td>
<td><Mono class="action-text">{{ r.action }}</Mono></td>
<td><span class="target-text">{{ r.target }}</span></td>
<td class="tone-col">
<Badge :tone="r.tone" dot>{{ r.tone === 'bad' ? 'fail' : r.tone }}</Badge>
</td>
</tr>
</tbody>
</table>
</Card>
<Mono dim class="footer-note">// retention 365 days · write-once · visible to customer admins on their own audit log</Mono>
</div>
<!-- Detail side panel -->
<SidePanel
:open="!!detail"
width="md"
eyebrow="Audit event"
:title="detail?.action || ''"
@close="detail = null"
>
<template v-if="detail">
<div class="detail-head">
<Mono dim>{{ detail.when }}</Mono>
<Badge :tone="detail.tone" dot>{{ detail.tone === 'bad' ? 'fail' : detail.tone }}</Badge>
</div>
<div class="detail-section">
<Eyebrow>Actor</Eyebrow>
<div class="actor-row">
<Avatar :name="detail.actor" :size="32" />
<div>
<div class="dn">{{ detail.actor }}</div>
<Mono dim>partner staff</Mono>
</div>
</div>
</div>
<div class="detail-section">
<Eyebrow>Target</Eyebrow>
<div class="target-row">
<div v-if="detail.customer !== '—'" class="cust-cell">
<div class="cust-swatch" :style="{ background: customerColor(detail.customer) }" />
<span>{{ detail.customer }}</span>
</div>
<Mono>{{ detail.target }}</Mono>
</div>
</div>
<div class="detail-section">
<Eyebrow>Event ID</Eyebrow>
<div class="eid"><Mono>{{ detail.id }}</Mono></div>
</div>
</template>
<template #footer>
<div class="audit-footer">
<UiIcon name="shield" :size="12" />
<Mono dim>tamper-evident · retention 365 days</Mono>
</div>
</template>
</SidePanel>
</div>
</template>
<style scoped>
.content { padding: 20px 40px 64px; display: flex; flex-direction: column; gap: 12px; }
.filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
width: 320px;
color: var(--text-mute);
}
.search input {
flex: 1;
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
padding: 9px 0;
color: var(--text);
}
.search input:focus { outline: none; }
.seg {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.seg-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
}
.seg select {
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
color: var(--text);
padding: 8px 4px;
cursor: pointer;
}
.seg select:focus { outline: none; }
.spacer { flex: 1; }
.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.tone-col, .dtable td.tone-col { width: 80px; text-align: right; }
.dtable td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
.dtable tbody tr:hover { background: var(--row-hover); }
.actor-cell { display: flex; align-items: center; gap: 8px; }
.actor-name { font-size: 12px; font-weight: 500; }
.cust-cell { display: flex; align-items: center; gap: 6px; }
.cust-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.action-text { font-weight: 500; }
.target-text { font-size: 12px; color: var(--text-dim); }
.footer-note { display: block; margin-top: 4px; }
/* Side panel detail */
.detail-head {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 14px;
margin-bottom: 18px;
border-bottom: 1px solid var(--border);
}
.detail-section { margin-bottom: 20px; }
.detail-section + .detail-section { padding-top: 20px; border-top: 1px solid var(--border); }
.actor-row { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
.dn { font-size: 14px; font-weight: 500; }
.target-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 13px; }
.eid { margin-top: 8px; }
.audit-footer {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
width: 100%;
}
.audit-footer :deep(svg) { color: var(--text-mute); }
</style>
+302
View File
@@ -0,0 +1,302 @@
<script setup lang="ts">
// Partner billing. Strict port of PartnerBillingScreen in partner-screens.jsx
// (lines 691-838). Four tabs: Overview / Customer invoices / Margin & revenue
// / Payouts. Each tab numbers seeded to match the source.
import { customers, partnerInvoices, partner } from '~/data/customers'
const toast = useToast()
const tab = ref<'overview' | 'invoices' | 'margin' | 'payouts'>('overview')
const tabs = [
{ value: 'overview', label: 'Overview' },
{ value: 'invoices', label: 'Customer invoices', count: 47 },
{ value: 'margin', label: 'Margin & revenue' },
{ value: 'payouts', label: 'Payouts', count: 12 },
]
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 invoiceTone(s: string): 'ok' | 'warn' | 'bad' | 'neutral' {
if (s === 'paid') return 'ok'
if (s === 'past_due') return 'bad'
if (s === 'sent') return 'warn'
return 'neutral'
}
// 52-week revenue series for Margin & revenue tab (deterministic).
const revenueSeries = Array.from({ length: 52 }, (_, i) => 8000 + i * 180 + Math.sin(i / 3) * 600)
const payouts = [
{ period: 'May 2026', amt: '11.150,00', paid: '—', ref: 'pending', status: 'pending' as const },
{ period: 'April 2026', amt: '10.520,00', paid: '03 May 2026', ref: 'TR-29841', status: 'paid' as const },
{ period: 'March 2026', amt: '9.840,00', paid: '03 Apr 2026', ref: 'TR-29402', status: 'paid' as const },
{ period: 'Feb 2026', amt: '9.180,00', paid: '03 Mar 2026', ref: 'TR-28977', status: 'paid' as const },
]
</script>
<template>
<div>
<PageHeader
eyebrow="Commercial"
title="Partner billing"
subtitle="Aggregate billing across your customer portfolio, margins, and payouts from Dezky."
>
<template #actions>
<UiButton variant="secondary" @click="toast.ok('Exporting', 'PDF compiled')">
<template #leading><UiIcon name="download" :size="14" /></template>
Export
</UiButton>
</template>
</PageHeader>
<div class="tabs-wrap">
<Tabs v-model="tab" :items="tabs" />
</div>
<!-- OVERVIEW -->
<div v-if="tab === 'overview'" class="content">
<div class="stat-strip">
<Card>
<Stat label="MRR · portfolio" value="55.750 DKK" delta="+18.2%" delta-tone="up" hint="vs. last month" />
</Card>
<Card>
<Stat :label="`Partner cut · ${partner.marginPct}%`" value="11.150 DKK" delta="+19.0%" delta-tone="up" />
</Card>
<Card>
<Stat label="Net to Dezky" value="44.600 DKK" hint="monthly" />
</Card>
<Card>
<Stat label="Open A/R" value="2.940 DKK" hint="1 customer past-due" delta-tone="down" />
</Card>
</div>
<Card :pad="0">
<div class="card-head">
<div>
<Eyebrow>Per customer · this month</Eyebrow>
<div class="card-title">Revenue breakdown</div>
</div>
</div>
<table class="dtable">
<thead>
<tr>
<th>Customer</th>
<th>Plan</th>
<th>Seats</th>
<th class="num">MRR</th>
<th class="num">Partner cut ({{ partner.marginPct }}%)</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="c in customers" :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="neutral">{{ c.planLabel }}</Badge></td>
<td><Mono>{{ c.seats.used }}</Mono></td>
<td class="num"><span class="mrr">{{ c.mrrDkk.toLocaleString('da-DK') }} DKK</span></td>
<td class="num"><span class="cut">{{ Math.round(c.mrrDkk * partner.marginPct / 100).toLocaleString('da-DK') }} DKK</span></td>
<td>
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- CUSTOMER INVOICES -->
<div v-else-if="tab === 'invoices'" class="content">
<Card :pad="0">
<table class="dtable">
<thead>
<tr>
<th>Invoice</th>
<th>Customer</th>
<th>Date</th>
<th class="num">Amount</th>
<th>Status</th>
<th class="action-col" />
</tr>
</thead>
<tbody>
<tr v-for="inv in partnerInvoices" :key="inv.id">
<td><Mono>{{ inv.number }}</Mono></td>
<td><span class="cust-name">{{ inv.customer }}</span></td>
<td><span class="text-13">{{ inv.date }}</span></td>
<td class="num"><Mono>{{ inv.amount.toLocaleString('da-DK') }} DKK</Mono></td>
<td>
<Badge :tone="invoiceTone(inv.status)" dot>{{ inv.status.replace('_', '-') }}</Badge>
</td>
<td class="action-col">
<UiButton size="sm" variant="ghost" @click="toast.info('Downloading PDF', inv.number)">
<template #leading><UiIcon name="download" :size="13" /></template>
PDF
</UiButton>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- MARGIN & REVENUE -->
<div v-else-if="tab === 'margin'" class="content">
<div class="grid-2">
<Card>
<Eyebrow>Margin</Eyebrow>
<div class="card-title">Your reseller margin</div>
<p class="sub">Per your agreement with Dezky · 20% gross on all customer revenue.</p>
<dl class="def">
<div><dt>Starter plan</dt><dd>20% · 9,80 DKK per seat / mo</dd></div>
<div><dt>Business plan</dt><dd>20% · 25,80 DKK per seat / mo</dd></div>
<div><dt>Enterprise plan</dt><dd>15% · negotiated per customer</dd></div>
<div><dt>Add-ons</dt><dd>Pass-through · 0%</dd></div>
<div><dt>Volume rebate</dt><dd>+2% over 200 active seats · qualifies</dd></div>
</dl>
</Card>
<Card>
<Eyebrow>Revenue · 12 months</Eyebrow>
<div class="card-title">Trailing twelve</div>
<div class="ttm-chart">
<PartnerSparkline :values="revenueSeries" :width="420" :height="120" stroke="var(--text)" fill="var(--row-hover)" />
</div>
<div class="ttm-foot">
<Mono dim>Jun 2025 · 8.180 DKK</Mono>
<Mono dim>May 2026 · 11.150 DKK</Mono>
</div>
<div class="ttm-total">
<Stat label="Total · 12 months" value="118.940 DKK" delta="+36% YoY" delta-tone="up" />
</div>
</Card>
</div>
</div>
<!-- PAYOUTS -->
<div v-else-if="tab === 'payouts'" class="content">
<Card :pad="0">
<table class="dtable">
<thead>
<tr>
<th>Period</th>
<th class="num">Amount</th>
<th>Paid on</th>
<th>Reference</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="p in payouts" :key="p.period">
<td><span class="cust-name">{{ p.period }}</span></td>
<td class="num"><Mono>{{ p.amt }} DKK</Mono></td>
<td><Mono>{{ p.paid }}</Mono></td>
<td><Mono dim>{{ p.ref }}</Mono></td>
<td>
<Badge :tone="p.status === 'paid' ? 'ok' : 'warn'" dot>{{ p.status }}</Badge>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
</div>
</template>
<style scoped>
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
.content { padding: 24px 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: 20px 24px;
border-bottom: 1px solid var(--border);
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
margin-top: 4px;
}
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; line-height: 1.5; }
.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, .dtable td.num { text-align: right; }
.dtable th.action-col, .dtable td.action-col { width: 80px; text-align: right; }
.dtable td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.dtable tbody tr:hover { background: var(--row-hover); }
.cust-cell { display: flex; align-items: center; gap: 12px; }
.cust-swatch { width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; }
.cust-name { font-size: 13px; font-weight: 500; }
.text-13 { font-size: 13px; }
.mrr {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
}
.cut {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ok);
}
/* TTM chart */
.def { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 0; padding: 0; }
.def div { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; }
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
.def dd { margin: 0; }
.ttm-chart { margin-top: 14px; }
.ttm-chart :deep(svg) { width: 100%; height: 120px; }
.ttm-foot {
display: flex;
justify-content: space-between;
margin-top: 16px;
}
.ttm-total {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
</style>
+250
View File
@@ -0,0 +1,250 @@
<script setup lang="ts">
// Partner branding. Strict port of PartnerBrandingScreen
// (partner-screens.jsx lines 839-942). Three cards:
// • Your brand · NordicMSP identity
// • Customer defaults · what gets pushed to new customers (7 toggles)
// • Email templates · 2-col grid of 5 templates
import type { EmailTemplate } from '~/components/partner/EmailTemplateEditor.vue'
const toast = useToast()
const identityOpen = ref(false)
const editing = ref<EmailTemplate | null>(null)
// Customer defaults · partner-screens.jsx line 872-878
const defaults = ref([
{ l: 'Accent color', d: 'Cobalt #3F6BFF', on: true },
{ l: 'Product name pattern', d: '"{Customer} Workspace" e.g. Acme Workspace', on: true },
{ l: 'Custom subdomain', d: 'workspace.{customer-domain}', on: true },
{ l: 'Login screen', d: 'NordicMSP co-brand + customer logo', on: true },
{ l: 'Email templates', d: '5 templates · NordicMSP voice', on: true },
{ l: 'Allow customer override', d: 'Business plans and above', on: true },
{ l: 'Lock typography', d: 'Inter Tight + JetBrains Mono · brand-locked', on: false },
])
// Source mustache literals. Constructed in JS to avoid Vue parser eating
// nested {{ }} (see CRITICAL note in task brief).
const TAG_WORKSPACE = '{' + '{workspace.name}' + '}'
const TAG_INVOICE = '{' + '{invoice.id}' + '}'
const TAG_PLAN = '{' + '{plan.name}' + '}'
const templates = ref<EmailTemplate[]>([
{ id: 'welcome', name: 'Customer welcome email', subject: `Welcome to ${TAG_WORKSPACE} — managed by NordicMSP`, body: '', edited: '5 days ago' },
{ id: 'invitation', name: 'User invitation', subject: `Youve been invited to ${TAG_WORKSPACE}`, body: '', edited: '3 days ago' },
{ id: 'reset', name: 'Password reset', subject: `Reset your ${TAG_WORKSPACE} password`, body: '', edited: 'default' },
{ id: 'plan', name: 'Plan change confirmation', subject: `Your plan changed to ${TAG_PLAN}`, body: '', edited: 'default' },
{ id: 'invoice', name: 'Invoice notification', subject: `Your NordicMSP invoice ${TAG_INVOICE}`, body: '', edited: '2 weeks ago' },
])
function saveTemplate(t: EmailTemplate) {
templates.value = templates.value.map((x) => (x.id === t.id ? { ...t, edited: 'just now' } : x))
editing.value = null
toast.ok('Template saved', t.name)
}
</script>
<template>
<div>
<PageHeader
eyebrow="Whitelabel"
title="Partner branding"
subtitle="Your own brand identity, plus the defaults pushed to every customer you provision."
/>
<div class="content">
<!-- Your brand · identity card -->
<Card>
<div class="card-head">
<div>
<Eyebrow>Your brand</Eyebrow>
<div class="card-title">NordicMSP identity</div>
<p class="sub">Shown in the partner console and on emails sent by your team.</p>
</div>
<UiButton size="sm" variant="ghost" @click="identityOpen = true">Edit</UiButton>
</div>
<div class="id-grid">
<dl class="def">
<div><dt>Display name</dt><dd>NordicMSP</dd></div>
<div><dt>Logo</dt><dd>nordic-logo.svg · 4:1 horizontal</dd></div>
<div><dt>Mark</dt><dd>nordic-mark.svg · 1:1</dd></div>
<div>
<dt>Primary color</dt>
<dd>
<div class="color-row">
<div class="color-swatch" style="background:#3F6BFF" />
<Mono>#3F6BFF</Mono>
</div>
</dd>
</div>
</dl>
<dl class="def">
<div><dt>Support email</dt><dd>support@nordicmsp.dk</dd></div>
<div><dt>Support phone</dt><dd>+45 70 70 12 34</dd></div>
<div><dt>Website</dt><dd>nordicmsp.dk</dd></div>
<div><dt>Reply-to</dt><dd>no-reply@nordicmsp.dk</dd></div>
</dl>
</div>
</Card>
<!-- Customer defaults · toggle list -->
<Card>
<div class="card-head">
<div>
<Eyebrow>Customer defaults</Eyebrow>
<div class="card-title">What gets pushed to new customers</div>
<p class="sub">Applied at provisioning. Customers can override per their tier entitlements.</p>
</div>
</div>
<div class="defaults-list">
<div
v-for="(row, i) in defaults"
:key="row.l"
class="def-row"
:class="{ last: i === defaults.length - 1 }"
>
<div class="dr-meta">
<div class="dr-label">{{ row.l }}</div>
<div class="dr-detail">{{ row.d }}</div>
</div>
<button class="switch" :class="{ on: row.on }" @click="row.on = !row.on">
<span class="thumb" />
</button>
</div>
</div>
</Card>
<!-- Email templates · 2-col grid -->
<Card>
<Eyebrow>Templates</Eyebrow>
<div class="card-title">Email templates · NordicMSP defaults</div>
<div class="tpl-grid">
<button
v-for="t in templates"
:key="t.id"
class="tpl-row"
@click="editing = t"
>
<UiIcon name="mail" :size="14" />
<div class="tpl-meta">
<div class="tpl-top">
<span class="tpl-name">{{ t.name }}</span>
<Badge :tone="t.edited === 'default' ? 'neutral' : 'info'">{{ t.edited === 'default' ? 'default' : 'edited' }}</Badge>
</div>
<Mono dim>edited {{ t.edited }}</Mono>
</div>
<UiIcon name="chevRight" :size="14" />
</button>
</div>
</Card>
</div>
<PartnerEditIdentityModal :open="identityOpen" @close="identityOpen = false" />
<PartnerEmailTemplateEditor
:template="editing"
brand-color="#3F6BFF"
brand-name="NordicMSP"
@close="editing = null"
@save="saveTemplate"
/>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; max-width: 1100px; }
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
margin-top: 4px;
}
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; max-width: 580px; line-height: 1.5; }
/* Identity DefList grid */
.id-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.def { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; }
.def div { display: grid; grid-template-columns: 140px 1fr; gap: 12px; font-size: 13px; align-items: center; }
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
.def dd { margin: 0; }
.color-row { display: flex; align-items: center; gap: 8px; }
.color-swatch { width: 14px; height: 14px; border-radius: 3px; }
/* Defaults toggle list */
.defaults-list { display: flex; flex-direction: column; }
.def-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.def-row.last { border-bottom: none; }
.dr-meta { flex: 1; min-width: 0; }
.dr-label { font-size: 13px; font-weight: 500; }
.dr-detail { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.switch {
width: 36px;
height: 20px;
border-radius: 999px;
background: var(--border);
border: none;
padding: 2px;
display: inline-flex;
align-items: center;
cursor: pointer;
transition: background 150ms;
flex-shrink: 0;
}
.switch.on { background: var(--text); }
.thumb {
width: 16px;
height: 16px;
border-radius: 999px;
background: var(--bg);
transition: transform 150ms;
}
.switch.on .thumb { transform: translateX(16px); }
/* Templates grid */
.tpl-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 12px;
}
.tpl-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 6px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
}
.tpl-row:hover { background: var(--row-hover); }
.tpl-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
.tpl-meta { flex: 1; min-width: 0; }
.tpl-top { display: flex; align-items: center; gap: 8px; }
.tpl-name { font-weight: 500; }
</style>
+530
View File
@@ -0,0 +1,530 @@
<script setup lang="ts">
// Full portfolio list. Strict port of CustomersScreen in partner-screens.jsx
// (lines 366-494). Table + cards view toggle, status/plan filters, search.
// Click a row → confirm enter customer modal → partnerMode.enter() + /admin.
import type { CustomerOrg, CustomerStatus } from '~/data/customers'
const toast = useToast()
const router = useRouter()
const partnerMode = usePartnerMode()
const view = ref<'table' | 'cards'>('table')
const query = ref('')
const statusFilter = ref<'all' | CustomerStatus>('all')
const planFilter = ref<'all' | 'starter' | 'business' | 'enterprise'>('all')
const wizardOpen = ref(false)
const entryCustomer = ref<CustomerOrg | null>(null)
// Real tenants attached to this partner (via /api/partner/tenants → platform-api
// /users/me/partner/tenants). The backend doesn't yet store health-score,
// MRR, or industry, so those render as placeholders. Plan + seats now come
// from real fields.
interface PartnerTenantDoc {
_id: string
slug: string
name: string
status: 'active' | 'pending' | 'suspended' | 'deleted'
plan?: 'mvp' | 'pro' | 'enterprise'
seats?: number
// Active User docs whose tenantIds include this tenant. Comes from
// /api/partner/tenants (server-side aggregation), so the column can show
// "used / total" without a second client round-trip.
userCount?: number
domains?: string[]
createdAt?: string
}
const { data: rawTenants, refresh: refreshTenants } = await useFetch<PartnerTenantDoc[]>(
'/api/partner/tenants',
{ key: 'partner-tenants', default: () => [] },
)
// Per-tenant MRR comes from the same aggregation that powers the dashboard
// MRR card. We reuse the cached response (same key) instead of issuing a
// second fetch; the customers page just reads the breakdown to fill the MRR
// column per row.
interface MrrBreakdownRow {
tenantId: string
currency: 'DKK' | 'EUR' | 'USD'
monthlyMinor: number
custom: boolean
}
interface MrrResponse {
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
breakdown: MrrBreakdownRow[]
}
const { data: mrr, refresh: refreshMrr } = await useFetch<MrrResponse>('/api/partner/mrr', {
key: 'partner-mrr',
default: () => ({ totals: [], breakdown: [] }),
})
const mrrByTenant = computed(() => {
const m = new Map<string, MrrBreakdownRow>()
for (const row of mrr.value?.breakdown ?? []) m.set(row.tenantId, row)
return m
})
function mapTenantStatus(s: PartnerTenantDoc['status']): CustomerStatus {
// Tenant.status values overlap partially with the fixture's CustomerStatus.
// Best-effort map: deleted/suspended → suspended badge, pending → trial,
// active stays active (rendered as 'healthy').
if (s === 'active') return 'healthy'
if (s === 'pending') return 'trial'
return 'suspended'
}
// Backend plan enum (mvp/pro/enterprise) → fixture-friendly slug + label.
// Same mapping mirrored in CustomerCreateWizard.vue; keep both in sync if
// the backend enum ever changes.
const PLAN_INFO: Record<
'mvp' | 'pro' | 'enterprise',
{ slug: CustomerOrg['plan']; label: CustomerOrg['planLabel'] }
> = {
mvp: { slug: 'starter', label: 'Starter' },
pro: { slug: 'business', label: 'Business' },
enterprise: { slug: 'enterprise', label: 'Enterprise' },
}
// Type extension on the row so the table can show the currency next to
// the amount (the original fixture only had a DKK number, but real data
// can be in DKK/EUR/USD).
interface CustomerRow extends CustomerOrg {
mrrCurrency: 'DKK' | 'EUR' | 'USD'
mrrCustom: boolean
}
const customers = computed<CustomerRow[]>(() =>
(rawTenants.value ?? []).map((t) => {
const info = PLAN_INFO[t.plan ?? 'pro']
const subMrr = mrrByTenant.value.get(t._id)
return {
id: t._id,
name: t.name,
domain: t.domains?.[0] ?? `${t.slug}.dezky.com`,
plan: info.slug,
planLabel: info.label,
// Real seat utilisation: count of active User docs attached to this
// tenant vs the contractual seat total from provisioning.
seats: { used: t.userCount ?? 0, total: t.seats ?? 0 },
health: 100,
status: mapTenantStatus(t.status),
// MRR for this tenant, in major units. From /api/partner/mrr; 0 when
// the sub has no priced amount (Enterprise / pre-catalog tenants).
mrrDkk: subMrr ? Math.round(subMrr.monthlyMinor / 100) : 0,
mrrCurrency: subMrr?.currency ?? 'DKK',
mrrCustom: subMrr?.custom ?? false,
brandColor: '#D4FF3A',
industry: '—',
createdOn: t.createdAt ?? '',
since: t.createdAt ?? '',
}
}),
)
const filtered = computed(() => {
return customers.value.filter((c) => {
if (statusFilter.value !== 'all' && c.status !== statusFilter.value) return false
if (planFilter.value !== 'all' && c.plan !== planFilter.value) return false
if (query.value) {
const q = query.value.toLowerCase()
if (!(c.name + ' ' + c.domain).toLowerCase().includes(q)) return false
}
return true
})
})
const statusOpts = [
{ value: 'all', label: 'All' },
{ value: 'healthy', label: 'Healthy' },
{ value: 'attention', label: 'Attention' },
{ value: 'past_due', label: 'Past due' },
{ value: 'trial', label: 'Trial' },
] as const
const planOpts = [
{ value: 'all', label: 'All' },
{ value: 'starter', label: 'Starter' },
{ value: 'business', label: 'Business' },
{ value: 'enterprise', label: 'Enterprise' },
] as const
function statusBadge(s: CustomerStatus): { 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' }
case 'suspended': return { tone: 'neutral', label: 'suspended' }
}
}
function startEnter(c: CustomerOrg) {
entryCustomer.value = c
}
async function onProvisioned() {
toast.ok('Customer provisioned', 'Tenant created — provisioning runs in the background')
// Refetch so the new tenant + its subscription's MRR show up without a
// full reload. refreshNuxtData also nudges the dashboard's cached
// MRR card the next time the user navigates back to /partner.
await Promise.all([
refreshTenants(),
refreshMrr(),
refreshNuxtData('partner-tenants'),
refreshNuxtData('partner-mrr'),
])
}
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')
}
</script>
<template>
<div>
<PageHeader
eyebrow="Portfolio"
title="Customer organizations"
subtitle="Every customer org under your reseller agreement. Click to manage as partner."
>
<template #actions>
<UiButton variant="secondary" @click="toast.ok('Exporting CSV', `${customers.length} customers`)">
<template #leading><UiIcon name="download" :size="14" /></template>
Export CSV
</UiButton>
<UiButton variant="primary" @click="wizardOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New customer
</UiButton>
</template>
</PageHeader>
<div class="content">
<!-- Filter bar -->
<div class="filters">
<div class="search">
<UiIcon name="search" :size="14" />
<input v-model="query" placeholder="Search customer or domain…" />
</div>
<div class="seg">
<span class="seg-label">Status</span>
<select v-model="statusFilter">
<option v-for="o in statusOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Plan</span>
<select v-model="planFilter">
<option v-for="o in planOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<div class="spacer" />
<Mono dim>{{ filtered.length }} of {{ customers.length }}</Mono>
<div class="view-toggle">
<button
v-for="v in (['table','cards'] as const)"
:key="v"
:class="{ active: view === v }"
@click="view = v"
>{{ v }}</button>
</div>
</div>
<!-- Table view -->
<Card v-if="view === 'table'" :pad="0">
<div class="table-wrap">
<table class="dtable">
<thead>
<tr>
<th class="sortable">Customer <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable">Plan <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable">Seats <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable num">MRR <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable">Status <UiIcon name="chevUpDown" :size="11" /></th>
<th>Customer since</th>
<th class="action-col" />
</tr>
</thead>
<tbody>
<tr v-for="c in filtered" :key="c.id" @click="startEnter(c)">
<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>
<Mono>{{ c.seats.used }}/{{ c.seats.total }}</Mono>
</td>
<td class="num">
<span class="mrr">{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' ' + c.mrrCurrency : (c.mrrCustom ? 'custom' : '—') }}</span>
</td>
<td>
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
</td>
<td><Mono dim>{{ c.since }}</Mono></td>
<td class="action-col" @click.stop>
<UiButton size="sm" variant="secondary" @click="startEnter(c)">
Manage
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
</UiButton>
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="7" class="empty">No customers match these filters.</td>
</tr>
</tbody>
</table>
</div>
</Card>
<!-- Cards view -->
<div v-else class="cards-grid">
<button
v-for="c in filtered"
:key="c.id"
class="ccard"
@click="startEnter(c)"
>
<div class="ccard-stripe" :style="{ background: c.brandColor }" />
<div class="ccard-body">
<div class="ccard-head">
<div class="ccard-id">
<div class="ccard-name">{{ c.name }}</div>
<Mono dim>{{ c.domain }}</Mono>
</div>
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
</div>
<div class="ccard-meta">
<div>
<Eyebrow>Plan</Eyebrow>
<div class="cm-val">{{ c.planLabel }}</div>
</div>
<div>
<Eyebrow>Seats</Eyebrow>
<div class="cm-val mono">{{ c.seats.used }} / {{ c.seats.total }}</div>
</div>
<div>
<Eyebrow>MRR</Eyebrow>
<div class="cm-val mono">{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' ' + c.mrrCurrency : (c.mrrCustom ? 'custom' : '—') }}</div>
</div>
<div>
<Eyebrow>Since</Eyebrow>
<div class="cm-val">{{ c.since }}</div>
</div>
</div>
</div>
</button>
</div>
</div>
<PartnerCustomerCreateWizard
:open="wizardOpen"
@close="wizardOpen = false"
@done="onProvisioned"
/>
<PartnerEnterCustomerConfirmModal
:customer="entryCustomer"
@close="entryCustomer = null"
@confirm="confirmEnter"
/>
</div>
</template>
<style scoped>
.content { padding: 16px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
.filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
width: 320px;
color: var(--text-mute);
}
.search input {
flex: 1;
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
padding: 9px 0;
color: var(--text);
}
.search input:focus { outline: none; }
.seg {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.seg-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
}
.seg select {
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
color: var(--text);
padding: 8px 4px;
cursor: pointer;
}
.seg select:focus { outline: none; }
.spacer { flex: 1; }
.view-toggle {
display: flex;
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
background: var(--surface);
}
.view-toggle button {
padding: 6px 12px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-mute);
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.view-toggle button.active { background: var(--text); color: var(--bg); }
/* Table */
.table-wrap { overflow-x: auto; }
.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.sortable :deep(svg) { opacity: 0.5; margin-left: 4px; vertical-align: middle; }
.dtable th.num, .dtable td.num { text-align: right; }
.dtable th.action-col, .dtable td.action-col { width: 120px; text-align: right; }
.dtable td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
color: var(--text);
vertical-align: middle;
}
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
.dtable tbody tr:hover { background: var(--row-hover); }
.cust-cell { display: flex; align-items: center; gap: 12px; }
.cust-swatch { width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0; }
.cust-name { font-size: 13px; font-weight: 500; }
.mrr {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
}
.empty {
text-align: center;
color: var(--text-mute);
font-size: 13px;
padding: 48px 0;
}
/* Cards */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.ccard {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
padding: 0;
transition: border-color 120ms;
display: flex;
flex-direction: column;
}
.ccard:hover { border-color: var(--text); }
.ccard-stripe { height: 6px; }
.ccard-body { padding: 16px; }
.ccard-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.ccard-id { min-width: 0; }
.ccard-name {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
letter-spacing: -0.015em;
}
.ccard-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.cm-val {
font-size: 13px;
font-weight: 500;
margin-top: 4px;
}
.cm-val.mono { font-family: var(--font-mono); }
</style>
+573
View File
@@ -0,0 +1,573 @@
<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>
+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>
+326
View File
@@ -0,0 +1,326 @@
<script setup lang="ts">
// Partner settings. Strict port of PartnerSettingsScreen
// (platform-partner-depth.jsx lines 858-1037). Four tabs:
// • Agreement — active reseller agreement + DefLists + documents
// • Contact info — NordicMSP company info form
// • Tax — tax/invoicing DefList + payout method card
// • Notifications — partner-level event rows with cadence + channels
const toast = useToast()
const tab = ref<'agreement' | 'contact' | 'tax' | 'notifications'>('agreement')
const tabs = [
{ value: 'agreement', label: 'Agreement' },
{ value: 'contact', label: 'Contact info' },
{ value: 'tax', label: 'Tax' },
{ value: 'notifications', label: 'Notifications' },
]
// Contact info (editable but kept simple — strict port focuses on layout)
const contact = reactive({
legalName: 'NordicMSP ApS',
tradingName: 'NordicMSP',
address: 'Vesterport 12, 1620 København V',
country: 'DK',
primaryEmail: 'partners@nordicmsp.dk',
primaryPhone: '+45 70 70 12 34',
supportHotline: '+45 70 70 12 35',
website: 'nordicmsp.dk',
})
// Documents · platform-partner-depth.jsx:922-927
const docs = [
{ n: 'Reseller agreement · v2025.11.pdf', size: '184 KB', date: '14 Nov 2025' },
{ n: 'DPA · Data Processing Addendum.pdf', size: '92 KB', date: '14 Jan 2024' },
{ n: 'Service Level Agreement.pdf', size: '64 KB', date: '14 Jan 2024' },
{ n: 'Margin schedule · v2025.11.xlsx', size: '24 KB', date: '14 Nov 2025' },
]
// Notifications · platform-partner-depth.jsx:1013-1020
const events = [
{ event: 'New customer signed up', when: 'immediate', channels: 'email · chat' },
{ event: 'Customer past-due invoice', when: 'immediate', channels: 'email · in-app' },
{ event: 'Customer approaching limit', when: 'daily', channels: 'email' },
{ event: 'Customer downgrade or churn', when: 'immediate', channels: 'email · chat · in-app' },
{ event: 'Payout processed', when: 'immediate', channels: 'email' },
{ event: 'New ticket from a customer', when: 'immediate', channels: 'chat' },
{ event: 'Dezky agreement change', when: 'immediate', channels: 'email' },
]
</script>
<template>
<div>
<PageHeader
eyebrow="Partner"
title="Partner settings"
subtitle="Agreement terms, business details, tax setup, and partner-level notifications."
/>
<div class="tabs-wrap">
<Tabs v-model="tab" :items="tabs" />
</div>
<div class="content">
<!-- AGREEMENT -->
<template v-if="tab === 'agreement'">
<Card>
<div class="card-head">
<div>
<Eyebrow>Reseller agreement</Eyebrow>
<div class="card-title">Active · v2025.11</div>
<p class="sub">Effective since 14 Jan 2024 · auto-renews 14 Jan 2027</p>
</div>
<div class="head-actions">
<UiButton size="sm" variant="secondary" @click="toast.ok('Downloading', 'Reseller agreement v2025.11')">
<template #leading><UiIcon name="download" :size="13" /></template>
Download PDF
</UiButton>
<UiButton size="sm" variant="ghost" @click="toast.info('Version history', 'Showing all 3 versions')">View history</UiButton>
</div>
</div>
<div class="agree-grid">
<dl class="def">
<div><dt>Tier</dt><dd>Tier 2 · Established</dd></div>
<div><dt>Default margin</dt><dd>20% on Starter &amp; Business</dd></div>
<div><dt>Enterprise margin</dt><dd>Negotiated · 15% baseline</dd></div>
<div><dt>Volume rebate</dt><dd>+2% over 200 active seats · qualifies</dd></div>
<div><dt>Payout cadence</dt><dd>Monthly · 3rd business day</dd></div>
<div><dt>Min commitment</dt><dd>5 active customers</dd></div>
</dl>
<dl class="def">
<div><dt>Effective</dt><dd>14 Jan 2024</dd></div>
<div><dt>Term</dt><dd>36 months · auto-renew</dd></div>
<div><dt>Notice period</dt><dd>90 days written</dd></div>
<div><dt>Liability cap</dt><dd>12 months of fees</dd></div>
<div><dt>Governing law</dt><dd>Denmark · Copenhagen</dd></div>
<div><dt>Signed by</dt><dd>Anne Baslund · NordicMSP</dd></div>
</dl>
</div>
</Card>
<Card>
<Eyebrow>Documents</Eyebrow>
<div class="card-title">Related files</div>
<div class="doc-list">
<button
v-for="d in docs"
:key="d.n"
class="doc-row"
@click="toast.info('Downloading', d.n)"
>
<UiIcon name="file" :size="14" />
<span class="dr-name">{{ d.n }}</span>
<Mono dim>{{ d.size }} · {{ d.date }}</Mono>
<UiIcon name="download" :size="13" />
</button>
</div>
</Card>
</template>
<!-- CONTACT INFO -->
<template v-if="tab === 'contact'">
<Card>
<div class="card-head">
<div>
<Eyebrow>Business</Eyebrow>
<div class="card-title">NordicMSP company info</div>
</div>
<UiButton size="sm" variant="ghost" @click="toast.ok('Saved', 'Contact info updated')">Edit</UiButton>
</div>
<div class="contact-grid">
<div class="col">
<label class="field"><Eyebrow>Legal name</Eyebrow><input v-model="contact.legalName" /></label>
<label class="field"><Eyebrow>Trading name</Eyebrow><input v-model="contact.tradingName" /></label>
<label class="field"><Eyebrow>Address</Eyebrow><input v-model="contact.address" /></label>
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="contact.country" /></label>
</div>
<div class="col">
<label class="field"><Eyebrow>Primary contact · email</Eyebrow><input v-model="contact.primaryEmail" /></label>
<label class="field"><Eyebrow>Primary contact · phone</Eyebrow><input v-model="contact.primaryPhone" /></label>
<label class="field"><Eyebrow>Support hotline</Eyebrow><input v-model="contact.supportHotline" /></label>
<label class="field"><Eyebrow>Public website</Eyebrow><input v-model="contact.website" /></label>
</div>
</div>
</Card>
</template>
<!-- TAX -->
<template v-if="tab === 'tax'">
<Card>
<Eyebrow>Identification</Eyebrow>
<div class="card-title">Tax &amp; invoicing</div>
<div class="tax-grid">
<dl class="def">
<div><dt>Country</dt><dd>Denmark</dd></div>
<div><dt>CVR</dt><dd>41 88 22 04</dd></div>
<div><dt>VAT number</dt><dd>DK 41 88 22 04</dd></div>
<div><dt>VAT rate</dt><dd>25% · standard DK</dd></div>
</dl>
<dl class="def">
<div><dt>Currency</dt><dd>DKK · EUR available</dd></div>
<div><dt>Invoicing</dt><dd>OIOUBL · NemHandel</dd></div>
<div><dt>EAN/GLN</dt><dd>5790000123456</dd></div>
<div><dt>Tax exempt</dt><dd>No</dd></div>
</dl>
</div>
</Card>
<Card>
<div class="card-head">
<div>
<Eyebrow>Payout method</Eyebrow>
<div class="card-title">Where Dezky pays your margin</div>
</div>
<UiButton size="sm" variant="ghost" @click="toast.info('Change payout method', 'Contact partner success to switch')">Change</UiButton>
</div>
<div class="payout-row">
<div class="payout-icon"><UiIcon name="card" :size="20" /></div>
<div class="payout-meta">
<div class="payout-bank">Danske Bank · ApS account</div>
<Mono dim>IBAN DK 1820 · BIC DABADKKK</Mono>
</div>
<Badge tone="ok" dot>verified</Badge>
</div>
</Card>
</template>
<!-- NOTIFICATIONS -->
<template v-if="tab === 'notifications'">
<Card :pad="0">
<div class="card-head pad">
<div>
<Eyebrow>Partner-level events</Eyebrow>
<div class="card-title">Where to send each event</div>
</div>
</div>
<div class="notif-list">
<div
v-for="(row, i) in events"
:key="row.event"
class="notif-row"
:class="{ last: i === events.length - 1 }"
>
<span class="notif-event">{{ row.event }}</span>
<Mono>{{ row.when }}</Mono>
<Mono dim>{{ row.channels }}</Mono>
<UiButton size="sm" variant="ghost" @click="toast.info('Edit notification', row.event)">Edit</UiButton>
</div>
</div>
</Card>
</template>
</div>
</div>
</template>
<style scoped>
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
.content { padding: 20px 40px 64px; display: flex; flex-direction: column; gap: 16px; max-width: 1000px; }
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.card-head.pad {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 0;
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
margin-top: 4px;
}
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; line-height: 1.5; }
.head-actions { display: flex; gap: 6px; flex-shrink: 0; }
.agree-grid, .tax-grid, .contact-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
/* DefList */
.def { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; }
.def div { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; align-items: center; }
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
.def dd { margin: 0; }
/* Documents */
.doc-list { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
.doc-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 6px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
}
.doc-row:hover { background: var(--row-hover); }
.doc-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
.dr-name { flex: 1; font-weight: 500; }
/* Contact form */
.contact-grid .col { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input {
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field input:focus { outline: none; border-color: var(--border-hi); }
/* Payout */
.payout-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
margin-top: 8px;
}
.payout-icon {
width: 44px;
height: 44px;
border-radius: 8px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.payout-meta { flex: 1; min-width: 0; }
.payout-bank { font-size: 14px; font-weight: 500; }
/* Notifications */
.notif-list { padding: 8px; }
.notif-row {
display: grid;
grid-template-columns: 1fr 120px 220px 80px;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.notif-row.last { border-bottom: none; }
.notif-event { font-size: 13px; font-weight: 500; }
</style>
+286
View File
@@ -0,0 +1,286 @@
<script setup lang="ts">
// Partner team. Strict port of PartnerTeamScreen + PartnerTeammateRowActions
// (partner-screens.jsx lines 1054-1099 + 1431-1524). Owner row (Anne Baslund)
// has destructive actions (Suspend, Remove) disabled with "owner" mono tag.
import { customers } from '~/data/customers'
import type { TeamMember } from '~/components/partner/TeammatePanel.vue'
const toast = useToast()
const inviteOpen = ref(false)
const openMember = ref<TeamMember | null>(null)
// Real partner team from platform-api (proxied via /api/partner/users).
// Falls back to an empty list while the request is in flight. Each row's
// access/mfa fields are placeholders until per-user access controls and
// Authentik MFA introspection land — the underlying User doc only stores
// identity + tenantIds + partnerId today.
interface PartnerUserDoc {
_id: string
authentikSubjectId: string
email: string
name: string
role: string
active: boolean
lastLoginAt?: string
createdAt?: string
}
const { data: rawTeam } = await useFetch<PartnerUserDoc[]>('/api/partner/users', {
default: () => [],
})
function lastSeenLabel(iso?: string): string {
if (!iso) return 'never'
const ms = Date.now() - new Date(iso).getTime()
if (ms < 60_000) return 'just now'
const m = Math.floor(ms / 60_000)
if (m < 60) return `${m} min ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h} h ago`
const d = Math.floor(h / 24)
return `${d} d ago`
}
const members = computed<TeamMember[]>(() =>
(rawTeam.value ?? []).map((u) => ({
id: u.authentikSubjectId,
name: u.name,
email: u.email,
role: u.role === 'admin' ? 'Partner admin' : u.role === 'owner' ? 'Owner' : 'Partner staff',
access: 'all',
mfa: '—',
lastSeen: lastSeenLabel(u.lastLoginAt),
isOwner: u.role === 'owner',
})),
)
function accessLabel(m: TeamMember) {
if (m.access === 'all') return `all (${customers.length})`
if (m.access === 'none') return 'no access'
// Specific count for fixtures: Mikkel = 6, Oliver = 3
if (m.email === 'mikkel@nordicmsp.dk') return `6 of ${customers.length}`
if (m.email === 'oliver@nordicmsp.dk') return `3 of ${customers.length}`
return `${customers.length - 5} of ${customers.length}`
}
function onSent(payload: { email: string; role: string }) {
toast.ok('Invitation sent', `${payload.role} invite to ${payload.email}`)
}
// Row actions popover · mirrors PartnerTeammateRowActions (lines 1431-1524).
const menuFor = ref<string | null>(null)
const menuPos = ref<{ top: number; right: number }>({ top: 0, right: 0 })
function openMenu(m: TeamMember, e: MouseEvent) {
e.stopPropagation()
const btn = (e.currentTarget as HTMLElement).getBoundingClientRect()
menuPos.value = { top: btn.bottom + 4, right: window.innerWidth - btn.right }
menuFor.value = menuFor.value === m.id ? null : m.id
}
function actionsFor(m: TeamMember) {
return [
{ i: 'users', l: 'View details', fn: () => openMember.value = m },
{ i: 'brush', l: 'Change role…', fn: () => openMember.value = m },
{ i: 'building', l: 'Customer access…', fn: () => openMember.value = m },
{ sep: true },
{ i: 'refresh', l: 'Resend invitation', fn: () => toast.info('Invitation resent', m.email) },
{ i: 'shield', l: 'Reset MFA', fn: () => toast.warn('MFA reset', `${m.name} must enrol again on next sign-in`) },
{ i: 'key', l: 'Reset password', fn: () => toast.info('Password reset', `Email sent to ${m.email}`) },
{ sep: true },
{ i: 'x', l: 'Suspend account', fn: () => toast.warn('Account suspended', m.name), disabled: m.isOwner },
{ i: 'trash', l: 'Remove from team', danger: true, fn: () => toast.bad('Removal pending', `${m.name} will be removed`), disabled: m.isOwner },
]
}
function closeMenu() { menuFor.value = null }
onMounted(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeMenu() }
const onScroll = () => closeMenu()
document.addEventListener('keydown', onKey)
document.addEventListener('click', closeMenu)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('click', closeMenu)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
})
})
</script>
<template>
<div>
<PageHeader
eyebrow="People"
title="Partner team"
subtitle="People at NordicMSP with access to the partner console and your customers."
>
<template #actions>
<UiButton variant="primary" @click="inviteOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
Invite teammate
</UiButton>
</template>
</PageHeader>
<div class="content">
<Card :pad="0">
<table class="dtable">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Customer access</th>
<th>MFA</th>
<th>Last seen</th>
<th class="action-col" />
</tr>
</thead>
<tbody>
<tr
v-for="m in members"
:key="m.id"
@click="openMember = m"
>
<td>
<div class="user-cell">
<Avatar :name="m.name" :size="28" />
<div>
<div class="user-name">{{ m.name }}</div>
<Mono dim>{{ m.email }}</Mono>
</div>
</div>
</td>
<td>
<Badge :tone="m.role === 'Partner admin' ? 'invert' : 'neutral'">{{ m.role }}</Badge>
</td>
<td><Mono>{{ accessLabel(m) }}</Mono></td>
<td><Badge tone="ok" dot>enabled</Badge></td>
<td><Mono dim>{{ m.lastSeen }}</Mono></td>
<td class="action-col" @click.stop>
<button class="kebab" @click="openMenu(m, $event)">
<UiIcon name="more" :size="14" />
</button>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- Portaled action menu -->
<Teleport to="body">
<div
v-if="menuFor"
class="menu"
:style="{ top: menuPos.top + 'px', right: menuPos.right + 'px' }"
@click.stop
>
<template v-for="(it, i) in actionsFor(members.find(m => m.id === menuFor)!)" :key="i">
<div v-if="it.sep" class="menu-sep" />
<button
v-else
class="menu-item"
:class="{ danger: it.danger, disabled: it.disabled }"
:disabled="it.disabled"
@click="(it.fn?.(), closeMenu())"
>
<UiIcon :name="(it.i as any)" :size="13" />
<span>{{ it.l }}</span>
<Mono
v-if="members.find(m => m.id === menuFor)?.isOwner && (it.l?.startsWith('Suspend') || it.l?.startsWith('Remove'))"
dim
class="owner-tag"
>owner</Mono>
</button>
</template>
</div>
</Teleport>
<PartnerInviteTeammateModal :open="inviteOpen" @close="inviteOpen = false" @sent="onSent" />
<PartnerTeammatePanel :member="openMember" @close="openMember = null" />
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px; }
.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.action-col { width: 40px; }
.dtable td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
.dtable tbody tr:hover { background: var(--row-hover); }
.user-cell { display: flex; align-items: center; gap: 12px; }
.user-name { font-size: 13px; font-weight: 500; }
.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); }
</style>
<style>
/* Portaled menu — global because Teleport-ed to body */
.menu {
position: fixed;
min-width: 220px;
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:not(:disabled) { background: var(--row-hover); }
.menu .menu-item.danger { color: var(--bad); }
.menu .menu-item.disabled, .menu .menu-item:disabled { color: var(--text-mute); cursor: not-allowed; opacity: 0.5; }
.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; }
.menu .owner-tag { font-size: 9px; }
</style>