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