a51dc9a732
Move partner domain types out of data/customers.ts into types/partner.ts so the fixture data exports can be removed later without breaking type imports. Add usePartnerTenants / usePartnerMrr composables wrapping the shared-key partner fetches.
247 lines
8.5 KiB
Vue
247 lines
8.5 KiB
Vue
<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 '~/types/partner'
|
|
|
|
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>
|