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
+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>