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:
@@ -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>
|
||||
Reference in New Issue
Block a user