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
@@ -0,0 +1,246 @@
<script setup lang="ts">
// Side panel for Escalate / Check in tasks raised from a customer health row.
// Pre-fills notes from the health drivers and lets the partner tweak before
// creating the task.
import type { CustomerOrg } from '~/data/customers'
export interface TaskContext {
customer: CustomerOrg
score: number
mode: 'escalate' | 'checkin'
}
const props = defineProps<{ task: TaskContext | null }>()
const emit = defineEmits<{ close: []; save: [t: TaskContext] }>()
const assignee = ref('Anders Bjerregaard')
const due = ref('')
const severity = ref<'low' | 'medium' | 'high'>('high')
const snapshot = ref(true)
const notes = ref('')
watch(
() => props.task,
(t) => {
if (!t) return
assignee.value = 'Anders Bjerregaard'
due.value = t.mode === 'escalate' ? '2026-05-26' : '2026-05-31'
severity.value = t.mode === 'escalate' ? 'high' : 'medium'
snapshot.value = true
const drivers: string[] = []
if (t.customer.status === 'past_due') drivers.push('Invoice past-due — billing follow-up needed.')
if (t.customer.status === 'attention') drivers.push('Account flagged "attention" — investigate root cause.')
if (t.customer.seats.used / t.customer.seats.total > 0.85) {
drivers.push(`Seat usage at ${Math.round(t.customer.seats.used/t.customer.seats.total*100)}% — upsell opportunity.`)
}
if (t.mode === 'escalate') {
drivers.unshift(`${t.customer.name} dropped below 50 health. Suggested action: schedule a 30-min review with their primary contact this week.`)
} else {
drivers.unshift(`${t.customer.name} is on the watch list. Suggested action: a brief check-in to renew the relationship.`)
}
notes.value = drivers.join('\n\n')
},
{ immediate: true },
)
const isEscalate = computed(() => props.task?.mode === 'escalate')
function healthColor(h: number) {
if (h >= 75) return 'var(--ok)'
if (h >= 50) return 'var(--warn)'
return 'var(--bad)'
}
function drivers() {
if (!props.task) return []
const c = props.task.customer
return [
c.status === 'past_due' && { l: 'Invoice past-due', d: 'INV-2026-04204 · 21 days overdue', tone: 'bad' as const },
c.status === 'attention' && { l: 'Status flagged attention', d: 'manual flag · open support ticket', tone: 'warn' as const },
c.seats.used / c.seats.total > 0.85 && { l: 'Seat usage high', d: `${c.seats.used}/${c.seats.total} seats — approaching limit`, tone: 'warn' as const },
c.plan === 'starter' && { l: 'Plan trending low', d: 'Starter plan · no upgrade in 6 mo', tone: 'info' as const },
].filter(Boolean) as Array<{ l: string; d: string; tone: 'bad' | 'warn' | 'info' }>
}
</script>
<template>
<SidePanel
:open="!!task"
width="md"
:eyebrow="isEscalate ? 'Customer health · escalate' : 'Customer health · check in'"
:title="task ? (isEscalate ? `Escalate ${task.customer.name}` : `Check in with ${task.customer.name}`) : ''"
@close="emit('close')"
>
<div v-if="task">
<div class="head-card">
<div class="hc-row">
<div class="cust-swatch" :style="{ background: task.customer.brandColor }" />
<div class="hc-meta">
<div class="hc-name">{{ task.customer.name }}</div>
<Mono dim>{{ task.customer.domain }} · {{ task.customer.planLabel }}</Mono>
</div>
<div class="hc-score">
<Eyebrow>Health score</Eyebrow>
<div class="score-val" :style="{ color: healthColor(task.score) }">{{ task.score }}</div>
</div>
</div>
<div class="drivers-card">
<Eyebrow>Drivers · what pulled the score down</Eyebrow>
<div class="drivers-list">
<div v-for="d in drivers()" :key="d.l" class="driver-row">
<Badge :tone="d.tone" dot>{{ d.tone }}</Badge>
<span class="dr-label">{{ d.l }}</span>
<Mono dim>{{ d.d }}</Mono>
</div>
</div>
</div>
</div>
<div class="form">
<label class="field">
<Eyebrow>Assigned to</Eyebrow>
<div class="assignee">
<Avatar :name="assignee" :size="24" />
<span>{{ assignee }}</span>
<UiButton size="sm" variant="ghost">Change</UiButton>
</div>
</label>
<div class="row-2">
<label class="field">
<Eyebrow>Due date</Eyebrow>
<input v-model="due" type="date" />
</label>
<label class="field">
<Eyebrow>Severity</Eyebrow>
<div class="seg">
<button
v-for="s in (['low', 'medium', 'high'] as const)"
:key="s"
type="button"
:class="{ active: severity === s }"
@click="severity = s"
>{{ s }}</button>
</div>
</label>
</div>
<label class="field">
<Eyebrow>{{ isEscalate ? 'Escalation notes' : 'Check-in talking points' }}</Eyebrow>
<textarea v-model="notes" rows="8" />
<Mono dim>pre-filled from the health drivers edit before saving</Mono>
</label>
<label class="cb-row">
<input v-model="snapshot" type="checkbox" />
Attach a health snapshot to the task
</label>
<div v-if="isEscalate" class="warn">
<UiIcon name="shield" :size="14" />
<p>
Escalations notify the account owner immediately and appear at the top of their queue. Use sparingly.
</p>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
<div style="flex:1" />
<UiButton variant="secondary">
<template #leading><UiIcon name="mail" :size="14" /></template>
Save as draft
</UiButton>
<UiButton variant="primary" @click="task && emit('save', task); emit('close')">
<template #leading><UiIcon name="check" :size="14" /></template>
{{ isEscalate ? 'Create escalation' : 'Schedule check-in' }}
</UiButton>
</template>
</SidePanel>
</template>
<style scoped>
.head-card { margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
.hc-row { display: flex; align-items: center; gap: 14px; }
.cust-swatch { width: 44px; height: 44px; border-radius: 8px; flex-shrink: 0; }
.hc-meta { flex: 1; min-width: 0; }
.hc-name { font-family: var(--font-display); font-weight: 600; font-size: 17px; letter-spacing: -0.015em; }
.hc-score { text-align: right; }
.score-val { font-family: var(--font-display); font-weight: 600; font-size: 24px; margin-top: 4px; line-height: 1; }
.drivers-card {
margin-top: 14px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.drivers-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.driver-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.dr-label { flex: 1; }
.form { display: flex; flex-direction: column; gap: 14px; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input, .field textarea {
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field textarea { resize: vertical; line-height: 1.55; }
.field input:focus, .field textarea:focus { outline: none; border-color: var(--border-hi); }
.assignee {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
}
.assignee span { flex: 1; }
.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); }
.cb-row { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.cb-row input[type='checkbox'] { width: 14px; height: 14px; accent-color: var(--text); }
.warn {
padding: 12px;
background: rgba(226, 48, 48, 0.06);
border: 1px solid rgba(226, 48, 48, 0.22);
border-radius: 6px;
display: flex;
gap: 10px;
}
.warn :deep(svg) { color: var(--bad); flex-shrink: 0; margin-top: 2px; }
.warn p { font-size: 12px; color: var(--text-dim); line-height: 1.55; margin: 0; }
</style>