461 lines
18 KiB
Vue
461 lines
18 KiB
Vue
<script setup lang="ts">
|
|
definePageMeta({ ssr: false })
|
|
usePageTitle('Customer Attention · Settings')
|
|
|
|
type Tab = 'tiers' | 'rules' | 'preview'
|
|
const activeTab = ref<Tab>('tiers')
|
|
const tabItems: { id: Tab; label: string }[] = [
|
|
{ id: 'tiers', label: 'Service Tiers' },
|
|
{ id: 'rules', label: 'Classification Rules' },
|
|
{ id: 'preview', label: 'Preview' },
|
|
]
|
|
|
|
const operatorLabel: Record<string, string> = {
|
|
gte: '>=',
|
|
gt: '>',
|
|
lte: '<=',
|
|
lt: '<',
|
|
eq: '=',
|
|
}
|
|
|
|
const fieldLabel: Record<string, string> = {
|
|
premium: 'Annual Premium',
|
|
policy_count: 'Policy Count',
|
|
commission: 'Annual Commission',
|
|
collectivo_member: 'Collectivo Member',
|
|
multi_line: 'Unique Lines',
|
|
tenure_years: 'Tenure (years)',
|
|
has_private_policies: 'Has Private Policies',
|
|
}
|
|
|
|
interface Tier {
|
|
id: string
|
|
name: string
|
|
minScore: number
|
|
description: string
|
|
color: string
|
|
icon: string
|
|
benefits: string[]
|
|
}
|
|
|
|
interface Rule {
|
|
id: string
|
|
field: string
|
|
operator: string
|
|
value: number | boolean
|
|
points: number
|
|
label: string
|
|
}
|
|
|
|
interface Config {
|
|
autoClassify: boolean
|
|
}
|
|
|
|
const config = ref<Config>({ autoClassify: true })
|
|
const tiers = ref<Tier[]>([
|
|
{
|
|
id: 'platinum',
|
|
name: 'Platinum',
|
|
minScore: 80,
|
|
description: 'Highest value customers with premium service',
|
|
color: '#7c3aed',
|
|
icon: 'i-heroicons-star',
|
|
benefits: ['Priority support', 'Dedicated agent', 'Same-day responses']
|
|
},
|
|
{
|
|
id: 'gold',
|
|
name: 'Gold',
|
|
minScore: 60,
|
|
description: 'High-value customers with enhanced service',
|
|
color: '#ea580c',
|
|
icon: 'i-heroicons-award',
|
|
benefits: ['Priority support', '24h response time']
|
|
},
|
|
{
|
|
id: 'silver',
|
|
name: 'Silver',
|
|
minScore: 40,
|
|
description: 'Standard service level',
|
|
color: '#64748b',
|
|
icon: 'i-heroicons-shield-check',
|
|
benefits: ['Standard support', '48h response time']
|
|
},
|
|
{
|
|
id: 'bronze',
|
|
name: 'Bronze',
|
|
minScore: 0,
|
|
description: 'Basic service level',
|
|
color: '#78716c',
|
|
icon: 'i-heroicons-user',
|
|
benefits: ['Email support', '72h response time']
|
|
}
|
|
])
|
|
|
|
const rules = ref<Rule[]>([
|
|
{ id: '1', field: 'premium', operator: 'gte', value: 100000, points: 20, label: 'High premium' },
|
|
{ id: '2', field: 'policy_count', operator: 'gte', value: 5, points: 15, label: 'Multiple policies' },
|
|
{ id: '3', field: 'commission', operator: 'gte', value: 15000, points: 15, label: 'High commission' },
|
|
{ id: '4', field: 'collectivo_member', operator: 'eq', value: true, points: 10, label: 'Collectivo member' },
|
|
{ id: '5', field: 'multi_line', operator: 'gte', value: 3, points: 10, label: 'Multi-line customer' },
|
|
{ id: '6', field: 'tenure_years', operator: 'gte', value: 5, points: 10, label: 'Long-term customer' },
|
|
{ id: '7', field: 'has_private_policies', operator: 'eq', value: true, points: 5, label: 'Private policies' },
|
|
])
|
|
|
|
function getScoreForCustomer(input: {
|
|
totalPremium: number
|
|
policyCount: number
|
|
lineCount: number
|
|
tenureYears: number
|
|
isCollectivoMember: boolean
|
|
hasPrivatePolicies: boolean
|
|
estimatedCommission: number
|
|
}): number {
|
|
let score = 0
|
|
for (const rule of rules.value) {
|
|
let matches = false
|
|
if (rule.field === 'premium' && typeof rule.value === 'number') {
|
|
if (rule.operator === 'gte' && input.totalPremium >= rule.value) matches = true
|
|
else if (rule.operator === 'gt' && input.totalPremium > rule.value) matches = true
|
|
else if (rule.operator === 'lte' && input.totalPremium <= rule.value) matches = true
|
|
else if (rule.operator === 'lt' && input.totalPremium < rule.value) matches = true
|
|
else if (rule.operator === 'eq' && input.totalPremium === rule.value) matches = true
|
|
} else if (rule.field === 'policy_count' && typeof rule.value === 'number') {
|
|
if (rule.operator === 'gte' && input.policyCount >= rule.value) matches = true
|
|
else if (rule.operator === 'gt' && input.policyCount > rule.value) matches = true
|
|
else if (rule.operator === 'lte' && input.policyCount <= rule.value) matches = true
|
|
else if (rule.operator === 'lt' && input.policyCount < rule.value) matches = true
|
|
else if (rule.operator === 'eq' && input.policyCount === rule.value) matches = true
|
|
} else if (rule.field === 'commission' && typeof rule.value === 'number') {
|
|
if (rule.operator === 'gte' && input.estimatedCommission >= rule.value) matches = true
|
|
else if (rule.operator === 'gt' && input.estimatedCommission > rule.value) matches = true
|
|
else if (rule.operator === 'lte' && input.estimatedCommission <= rule.value) matches = true
|
|
else if (rule.operator === 'lt' && input.estimatedCommission < rule.value) matches = true
|
|
else if (rule.operator === 'eq' && input.estimatedCommission === rule.value) matches = true
|
|
} else if (rule.field === 'collectivo_member' && typeof rule.value === 'boolean') {
|
|
if (rule.operator === 'eq' && input.isCollectivoMember === rule.value) matches = true
|
|
} else if (rule.field === 'multi_line' && typeof rule.value === 'number') {
|
|
if (rule.operator === 'gte' && input.lineCount >= rule.value) matches = true
|
|
else if (rule.operator === 'gt' && input.lineCount > rule.value) matches = true
|
|
else if (rule.operator === 'lte' && input.lineCount <= rule.value) matches = true
|
|
else if (rule.operator === 'lt' && input.lineCount < rule.value) matches = true
|
|
else if (rule.operator === 'eq' && input.lineCount === rule.value) matches = true
|
|
} else if (rule.field === 'tenure_years' && typeof rule.value === 'number') {
|
|
if (rule.operator === 'gte' && input.tenureYears >= rule.value) matches = true
|
|
else if (rule.operator === 'gt' && input.tenureYears > rule.value) matches = true
|
|
else if (rule.operator === 'lte' && input.tenureYears <= rule.value) matches = true
|
|
else if (rule.operator === 'lt' && input.tenureYears < rule.value) matches = true
|
|
else if (rule.operator === 'eq' && input.tenureYears === rule.value) matches = true
|
|
} else if (rule.field === 'has_private_policies' && typeof rule.value === 'boolean') {
|
|
if (rule.operator === 'eq' && input.hasPrivatePolicies === rule.value) matches = true
|
|
}
|
|
if (matches) score += rule.points
|
|
}
|
|
return score
|
|
}
|
|
|
|
function getTierForCustomer(input: {
|
|
totalPremium: number
|
|
policyCount: number
|
|
lineCount: number
|
|
tenureYears: number
|
|
isCollectivoMember: boolean
|
|
hasPrivatePolicies: boolean
|
|
estimatedCommission: number
|
|
}): Tier {
|
|
const score = getScoreForCustomer(input)
|
|
for (const tier of tiers.value) {
|
|
if (score >= tier.minScore) return tier
|
|
}
|
|
return tiers.value[tiers.value.length - 1]
|
|
}
|
|
|
|
function fmtMoney(n: number) {
|
|
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`
|
|
if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`
|
|
return `$${n.toLocaleString()}`
|
|
}
|
|
|
|
function formatRuleValue(rule: { field: string; value: number | boolean }): string {
|
|
if (typeof rule.value === 'boolean') return rule.value ? 'Yes' : 'No'
|
|
if (rule.field === 'premium' || rule.field === 'commission') return fmtMoney(rule.value)
|
|
return String(rule.value)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="ca-page">
|
|
<!-- Back -->
|
|
<NuxtLink to="/settings" class="inline-flex">
|
|
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Settings</UButton>
|
|
</NuxtLink>
|
|
|
|
<!-- Header -->
|
|
<div class="max-w-xl">
|
|
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Customer Attention & Service Levels</h1>
|
|
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
|
|
Define service tiers and scoring rules to automatically classify customers by value and engagement.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Dev note -->
|
|
<div class="ca-dev-note">
|
|
<UIcon name="i-heroicons-beaker" style="width: 16px; height: 16px; flex-shrink: 0;" />
|
|
<div>
|
|
<p class="ca-dev-note-title">In development</p>
|
|
<p class="ca-dev-note-text">
|
|
This feature uses local configuration only. Once the API is ready, tiers and rules will be persisted server-side and apply across the organization.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="ca-filter-tabs">
|
|
<button
|
|
v-for="t in tabItems"
|
|
:key="t.id"
|
|
type="button"
|
|
class="ca-filter-tab"
|
|
:class="activeTab === t.id ? 'ca-filter-on' : 'ca-filter-off'"
|
|
@click="activeTab = t.id"
|
|
>
|
|
{{ t.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ═══════════════ Service Tiers ═══════════════ -->
|
|
<div v-if="activeTab === 'tiers'" class="ca-section-list">
|
|
<div v-for="tier in tiers" :key="tier.id" class="ca-card">
|
|
<div class="ca-card-top">
|
|
<div class="ca-tier-icon" :style="{ background: tier.color + '14', color: tier.color }">
|
|
<UIcon :name="tier.icon" style="width: 20px; height: 20px;" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<p class="text-[14px] font-semibold text-[var(--text-primary)]">{{ tier.name }}</p>
|
|
<span class="ca-score-badge" :style="{ background: tier.color + '14', color: tier.color }">
|
|
{{ tier.minScore }}+ pts
|
|
</span>
|
|
</div>
|
|
<p class="mt-0.5 text-[12px] text-[var(--text-muted)]">{{ tier.description }}</p>
|
|
</div>
|
|
<div class="ca-card-actions">
|
|
<button type="button" class="ca-action-btn" title="Edit">
|
|
<UIcon name="i-heroicons-pencil-square" style="width: 14px; height: 14px;" />
|
|
</button>
|
|
<button type="button" class="ca-action-btn ca-action-delete" title="Delete">
|
|
<UIcon name="i-heroicons-trash" style="width: 14px; height: 14px;" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="ca-benefits">
|
|
<p class="ca-label">Benefits</p>
|
|
<div class="ca-benefit-list">
|
|
<span v-for="b in tier.benefits" :key="b" class="ca-benefit-tag">{{ b }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="button" class="ca-btn-primary" style="align-self: flex-start;">
|
|
<UIcon name="i-heroicons-plus" style="width: 14px; height: 14px;" />
|
|
Add Tier
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ═══════════════ Classification Rules ═══════════════ -->
|
|
<div v-if="activeTab === 'rules'" class="ca-section-list">
|
|
<!-- Auto-classify toggle -->
|
|
<div class="ca-card" style="flex-direction: row; align-items: center; justify-content: space-between;">
|
|
<div>
|
|
<p class="text-[13px] font-semibold text-[var(--text-primary)]">Auto-classify customers</p>
|
|
<p class="text-[12px] text-[var(--text-muted)] mt-0.5">Automatically assign tiers based on scoring rules</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="ca-toggle"
|
|
:class="config.autoClassify ? 'ca-toggle-on' : 'ca-toggle-off'"
|
|
@click="config.autoClassify = !config.autoClassify"
|
|
>
|
|
<span class="ca-toggle-dot" :class="config.autoClassify ? 'ca-dot-on' : 'ca-dot-off'" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Rules list -->
|
|
<div v-for="rule in rules" :key="rule.id" class="ca-rule-row">
|
|
<div class="ca-rule-field">{{ fieldLabel[rule.field] ?? rule.field }}</div>
|
|
<span class="ca-rule-op">{{ operatorLabel[rule.operator] ?? rule.operator }}</span>
|
|
<span class="ca-rule-value">{{ formatRuleValue(rule) }}</span>
|
|
<span class="ca-rule-arrow">→</span>
|
|
<span class="ca-rule-points">+{{ rule.points }} pts</span>
|
|
<span class="ca-rule-label">{{ rule.label }}</span>
|
|
</div>
|
|
|
|
<button type="button" class="ca-btn-primary" style="align-self: flex-start;">
|
|
<UIcon name="i-heroicons-plus" style="width: 14px; height: 14px;" />
|
|
Add Rule
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ═══════════════ Preview ═══════════════ -->
|
|
<div v-if="activeTab === 'preview'" class="ca-section-list">
|
|
<div class="ca-table-wrap">
|
|
<table class="ca-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Customer</th>
|
|
<th>Premium</th>
|
|
<th>Policies</th>
|
|
<th>Lines</th>
|
|
<th>Tenure</th>
|
|
<th>Score</th>
|
|
<th>Tier</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td colspan="7" class="text-center py-8 text-[var(--text-muted)]">
|
|
No preview data available. Connect to API to see customer classification.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="text-[11px] text-[var(--text-muted)]">
|
|
Preview will show customer scores and tiers once API is connected.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.ca-page {
|
|
max-width: 48rem; margin: 0 auto;
|
|
display: flex; flex-direction: column; gap: 20px; padding-bottom: 3rem;
|
|
}
|
|
|
|
/* ── Dev note ── */
|
|
.ca-dev-note {
|
|
display: flex; align-items: flex-start; gap: 10px;
|
|
padding: 14px 16px; border-radius: 12px;
|
|
background: rgba(124, 58, 237, 0.06); border: 1px solid rgba(124, 58, 237, 0.12);
|
|
color: #6d28d9;
|
|
}
|
|
.ca-dev-note-title { font-size: 13px; font-weight: 600; }
|
|
.ca-dev-note-text { font-size: 12px; margin-top: 2px; line-height: 1.5; opacity: 0.85; }
|
|
|
|
/* ── Tabs ── */
|
|
.ca-filter-tabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: rgba(0,0,0,0.04); }
|
|
.ca-filter-tab {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
padding: 6px 14px; border-radius: 8px;
|
|
font-size: 12px; font-weight: 500; border: none;
|
|
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
|
}
|
|
.ca-filter-on { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
|
.ca-filter-off { background: transparent; color: var(--text-muted); }
|
|
.ca-filter-off:hover { color: var(--text-primary); }
|
|
|
|
/* ── Section list ── */
|
|
.ca-section-list { display: flex; flex-direction: column; gap: 10px; }
|
|
|
|
/* ── Cards ── */
|
|
.ca-card {
|
|
display: flex; flex-direction: column; gap: 12px;
|
|
padding: 16px; border-radius: 12px;
|
|
border: 1px solid rgba(0,0,0,0.06); background: #fff;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
|
transition: all 150ms ease;
|
|
}
|
|
.ca-card:hover { border-color: rgba(1,105,111,0.15); }
|
|
.ca-card-top { display: flex; align-items: center; gap: 12px; }
|
|
.ca-tier-icon {
|
|
width: 40px; height: 40px; border-radius: 12px;
|
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
}
|
|
.ca-score-badge {
|
|
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; white-space: nowrap;
|
|
}
|
|
.ca-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 150ms ease; flex-shrink: 0; }
|
|
.ca-card:hover .ca-card-actions { opacity: 1; }
|
|
.ca-action-btn {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 28px; height: 28px; border-radius: 6px; border: none; cursor: pointer;
|
|
background: rgba(0,0,0,0.03); color: #8a8a86; transition: all 150ms ease;
|
|
}
|
|
.ca-action-btn:hover { background: rgba(0,0,0,0.06); color: var(--text-primary); }
|
|
.ca-action-delete:hover { background: rgba(193,56,56,0.08); color: #c13838; }
|
|
|
|
/* ── Benefits ── */
|
|
.ca-benefits { padding-left: 52px; }
|
|
.ca-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #8a8a86; margin-bottom: 6px; }
|
|
.ca-benefit-list { display: flex; flex-wrap: wrap; gap: 5px; }
|
|
.ca-benefit-tag {
|
|
font-size: 11px; font-weight: 500; padding: 3px 9px; border-radius: 6px;
|
|
background: rgba(0,0,0,0.035); color: var(--text-muted); white-space: nowrap;
|
|
}
|
|
|
|
/* ── Buttons ── */
|
|
.ca-btn-primary {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 8px 16px; border-radius: 8px;
|
|
background: #01696f; color: #fff;
|
|
font-size: 13px; font-weight: 500; border: none;
|
|
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
|
}
|
|
.ca-btn-primary:hover { background: #015458; }
|
|
|
|
/* ── Toggle ── */
|
|
.ca-toggle {
|
|
width: 36px; height: 20px; border-radius: 10px; border: none;
|
|
cursor: pointer; position: relative; transition: background 150ms ease; flex-shrink: 0;
|
|
}
|
|
.ca-toggle-on { background: #01696f; }
|
|
.ca-toggle-off { background: rgba(0,0,0,0.12); }
|
|
.ca-toggle-dot {
|
|
display: block; width: 16px; height: 16px; border-radius: 8px;
|
|
background: #fff; position: absolute; top: 2px; transition: left 150ms ease;
|
|
}
|
|
.ca-dot-on { left: 18px; }
|
|
.ca-dot-off { left: 2px; }
|
|
|
|
/* ── Rule rows ── */
|
|
.ca-rule-row {
|
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
padding: 12px 16px; border-radius: 10px;
|
|
border: 1px solid rgba(0,0,0,0.06); background: #fff;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
|
font-size: 13px;
|
|
}
|
|
.ca-rule-field { font-weight: 600; color: var(--text-primary); min-width: 120px; }
|
|
.ca-rule-op { font-size: 12px; font-weight: 600; color: #8a8a86; font-family: monospace; }
|
|
.ca-rule-value { font-weight: 600; color: var(--text-primary); }
|
|
.ca-rule-arrow { color: #8a8a86; font-size: 14px; }
|
|
.ca-rule-points {
|
|
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px;
|
|
background: rgba(1,105,111,0.08); color: #01696f; white-space: nowrap;
|
|
}
|
|
.ca-rule-label { font-size: 11px; color: var(--text-muted); margin-left: auto; }
|
|
|
|
/* ── Preview table ── */
|
|
.ca-table-wrap {
|
|
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
|
|
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.03); overflow: auto;
|
|
}
|
|
.ca-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.ca-table th {
|
|
text-align: left; padding: 10px 14px;
|
|
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
|
color: #8a8a86; border-bottom: 1px solid rgba(0,0,0,0.06); white-space: nowrap;
|
|
}
|
|
.ca-table td {
|
|
padding: 10px 14px; border-bottom: 1px solid rgba(0,0,0,0.04);
|
|
color: var(--text-primary); white-space: nowrap;
|
|
}
|
|
.ca-table tbody tr:last-child td { border-bottom: none; }
|
|
.ca-table tbody tr:hover { background: rgba(0,0,0,0.015); }
|
|
.ca-cell-name { font-weight: 600; white-space: normal; min-width: 160px; }
|
|
.ca-cell-score { font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
.ca-tier-badge {
|
|
font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 9999px; white-space: nowrap;
|
|
}
|
|
</style>
|