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