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,297 @@
|
||||
<script setup lang="ts">
|
||||
// Two-column modal for building a partner custom report. Left: name +
|
||||
// description + metric picker + filters + group-by. Right: schedule cards +
|
||||
// recipients + format + live summary.
|
||||
|
||||
defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ close: []; created: [name: string] }>()
|
||||
|
||||
const METRICS = [
|
||||
{ id: 'mrr', label: 'MRR', group: 'Revenue' },
|
||||
{ id: 'arr', label: 'ARR', group: 'Revenue' },
|
||||
{ id: 'margin', label: 'Partner margin', group: 'Revenue' },
|
||||
{ id: 'arpu', label: 'ARPU', group: 'Revenue' },
|
||||
{ id: 'health', label: 'Avg health score', group: 'Health' },
|
||||
{ id: 'nps', label: 'NPS', group: 'Health' },
|
||||
{ id: 'seats', label: 'Seats used', group: 'Usage' },
|
||||
{ id: 'storage', label: 'Storage used', group: 'Usage' },
|
||||
{ id: 'tickets', label: 'Tickets opened', group: 'Usage' },
|
||||
{ id: 'churn', label: 'Churn rate', group: 'Retention' },
|
||||
{ id: 'retention', label: 'Net retention', group: 'Retention' },
|
||||
{ id: 'tenure', label: 'Avg tenure', group: 'Retention' },
|
||||
] as const
|
||||
|
||||
const SCHEDULES = [
|
||||
{ v: 'weekly', l: 'Weekly', d: 'Mondays · 09:00 CET' },
|
||||
{ v: 'monthly', l: 'Monthly', d: '1st of the month · 09:00 CET' },
|
||||
{ v: 'quarterly', l: 'Quarterly', d: '1st of Jan / Apr / Jul / Oct' },
|
||||
{ v: 'ondemand', l: 'On-demand', d: 'No automatic schedule' },
|
||||
] as const
|
||||
|
||||
const name = ref('Quarterly board — Q3 2026')
|
||||
const description = ref('')
|
||||
const metrics = ref<string[]>(['mrr', 'margin', 'churn', 'health'])
|
||||
const filterPlan = ref('all')
|
||||
const filterStatus = ref('all')
|
||||
const groupBy = ref<'plan' | 'region' | 'owner' | 'none'>('plan')
|
||||
const schedule = ref<'weekly' | 'monthly' | 'quarterly' | 'ondemand'>('quarterly')
|
||||
const recipients = ref('board@dezky.com')
|
||||
const format = ref<'pdf' | 'csv' | 'xlsx'>('pdf')
|
||||
|
||||
const grouped = computed(() => {
|
||||
const out: Record<string, typeof METRICS[number][]> = {}
|
||||
for (const m of METRICS) {
|
||||
out[m.group] = out[m.group] || []
|
||||
out[m.group].push(m)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
function toggle(id: string) {
|
||||
if (metrics.value.includes(id)) metrics.value = metrics.value.filter((x) => x !== id)
|
||||
else metrics.value = [...metrics.value, id]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
eyebrow="Partner reports · custom"
|
||||
title="New custom report"
|
||||
size="lg"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="grid">
|
||||
<!-- Left -->
|
||||
<div class="col">
|
||||
<label class="field">
|
||||
<Eyebrow>Report name</Eyebrow>
|
||||
<input v-model="name" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<Eyebrow>Description · optional</Eyebrow>
|
||||
<input v-model="description" placeholder="What's this report for?" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Metrics · pick what to include</Eyebrow>
|
||||
<div class="metric-card">
|
||||
<div v-for="(items, group) in grouped" :key="group" class="metric-group">
|
||||
<Mono dim>{{ group }}</Mono>
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="m in items"
|
||||
:key="m.id"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ on: metrics.includes(m.id) }"
|
||||
@click="toggle(m.id)"
|
||||
>
|
||||
<UiIcon v-if="metrics.includes(m.id)" name="check" :size="11" :stroke-width="2.6" />
|
||||
{{ m.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row-2">
|
||||
<label class="field">
|
||||
<Eyebrow>Filter · plan</Eyebrow>
|
||||
<select v-model="filterPlan">
|
||||
<option value="all">All plans</option>
|
||||
<option value="starter">Starter</option>
|
||||
<option value="business">Business</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<Eyebrow>Filter · status</Eyebrow>
|
||||
<select v-model="filterStatus">
|
||||
<option value="all">All statuses</option>
|
||||
<option value="healthy">Healthy</option>
|
||||
<option value="attention">Attention</option>
|
||||
<option value="past_due">Past-due</option>
|
||||
<option value="trial">Trial</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Group by</Eyebrow>
|
||||
<div class="seg">
|
||||
<button v-for="o in ['plan','region','owner','none'] as const" :key="o" :class="{ active: groupBy === o }" @click="groupBy = o">
|
||||
{{ o === 'owner' ? 'Account owner' : o }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right -->
|
||||
<div class="col">
|
||||
<div>
|
||||
<Eyebrow>Schedule</Eyebrow>
|
||||
<div class="schedule-list">
|
||||
<button
|
||||
v-for="o in SCHEDULES"
|
||||
:key="o.v"
|
||||
type="button"
|
||||
class="sched-card"
|
||||
:class="{ selected: schedule === o.v }"
|
||||
@click="schedule = o.v as any"
|
||||
>
|
||||
<span class="radio" :class="{ on: schedule === o.v }" />
|
||||
<div>
|
||||
<div class="sc-label">{{ o.l }}</div>
|
||||
<Mono dim>{{ o.d }}</Mono>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label v-if="schedule !== 'ondemand'" class="field">
|
||||
<Eyebrow>Email to</Eyebrow>
|
||||
<input v-model="recipients" placeholder="email, email, …" />
|
||||
<Mono dim>comma-separated</Mono>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Format</Eyebrow>
|
||||
<div class="seg">
|
||||
<button v-for="f in ['pdf','csv','xlsx'] as const" :key="f" :class="{ active: format === f }" @click="format = f">
|
||||
{{ f.toUpperCase() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<Eyebrow>Summary</Eyebrow>
|
||||
<dl>
|
||||
<div><Mono dim>name</Mono><span>{{ name || '—' }}</span></div>
|
||||
<div><Mono dim>metrics</Mono><span>{{ metrics.length }} selected</span></div>
|
||||
<div><Mono dim>grouped by</Mono><span>{{ groupBy }}</span></div>
|
||||
<div><Mono dim>delivery</Mono><span>{{ schedule }} · {{ format.toUpperCase() }}</span></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||
<div style="flex:1" />
|
||||
<UiButton variant="secondary">Save as draft</UiButton>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
:disabled="!name || metrics.length === 0"
|
||||
@click="emit('created', name); emit('close')"
|
||||
>
|
||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||
Create report
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 20px; }
|
||||
.col { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field input, .field select {
|
||||
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, .field select:focus { outline: none; border-color: var(--border-hi); }
|
||||
|
||||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
|
||||
.metric-card {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.metric-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.seg button {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.seg button.active { background: var(--text); color: var(--bg); }
|
||||
|
||||
.schedule-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||
.sched-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.sched-card.selected { border-color: var(--text); background: var(--bg); }
|
||||
.radio {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--border-hi);
|
||||
background: var(--bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.radio.on { border: 4px solid var(--text); }
|
||||
.sc-label { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.summary {
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.summary dl { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 0; }
|
||||
.summary dl div { display: flex; justify-content: space-between; font-size: 12px; }
|
||||
.summary dl span { color: var(--text); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user