WIP jordan
This commit is contained in:
228
app/composables/useCustomerAttention.ts
Normal file
228
app/composables/useCustomerAttention.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { computed } from 'vue'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export type ServiceTierId = string
|
||||
|
||||
export interface ServiceTier {
|
||||
id: ServiceTierId
|
||||
name: string
|
||||
color: string
|
||||
icon: string
|
||||
description: string
|
||||
minScore: number
|
||||
benefits: string[]
|
||||
}
|
||||
|
||||
export interface AttentionRule {
|
||||
id: string
|
||||
field: 'premium' | 'policy_count' | 'commission' | 'collectivo_member' | 'multi_line' | 'tenure_years' | 'has_private_policies'
|
||||
operator: 'gte' | 'lte' | 'eq' | 'gt' | 'lt'
|
||||
value: number | boolean
|
||||
points: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface CustomerAttentionConfig {
|
||||
tiers: ServiceTier[]
|
||||
rules: AttentionRule[]
|
||||
autoClassify: boolean
|
||||
}
|
||||
|
||||
/* ── Defaults ── */
|
||||
|
||||
function defaultTiers(): ServiceTier[] {
|
||||
return [
|
||||
{
|
||||
id: 'platinum',
|
||||
name: 'Platinum',
|
||||
color: '#7c3aed',
|
||||
icon: 'i-heroicons-star',
|
||||
description: 'VIP multi-line clients with highest lifetime value',
|
||||
minScore: 80,
|
||||
benefits: ['Priority claims handling', 'Dedicated account manager', 'Annual review', 'Renewal negotiation priority'],
|
||||
},
|
||||
{
|
||||
id: 'gold',
|
||||
name: 'Gold',
|
||||
color: '#d4a017',
|
||||
icon: 'i-heroicons-trophy',
|
||||
description: 'Established clients with strong portfolio',
|
||||
minScore: 55,
|
||||
benefits: ['Priority support', 'Proactive renewal outreach', 'Cross-sell consultation'],
|
||||
},
|
||||
{
|
||||
id: 'silver',
|
||||
name: 'Silver',
|
||||
color: '#6b7280',
|
||||
icon: 'i-heroicons-shield-check',
|
||||
description: 'Active clients with growth potential',
|
||||
minScore: 30,
|
||||
benefits: ['Standard support', 'Regular check-ins'],
|
||||
},
|
||||
{
|
||||
id: 'standard',
|
||||
name: 'Standard',
|
||||
color: '#01696f',
|
||||
icon: 'i-heroicons-user',
|
||||
description: 'All active customers',
|
||||
minScore: 0,
|
||||
benefits: ['Standard service'],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function defaultRules(): AttentionRule[] {
|
||||
return [
|
||||
{ id: 'r1', field: 'premium', operator: 'gte', value: 5000, points: 25, label: 'Annual premium >= $5,000' },
|
||||
{ id: 'r2', field: 'premium', operator: 'gte', value: 10000, points: 40, label: 'Annual premium >= $10,000' },
|
||||
{ id: 'r3', field: 'policy_count', operator: 'gte', value: 3, points: 15, label: '3+ active policies' },
|
||||
{ id: 'r4', field: 'policy_count', operator: 'gte', value: 5, points: 25, label: '5+ active policies' },
|
||||
{ id: 'r5', field: 'multi_line', operator: 'gte', value: 3, points: 20, label: 'Multi-line (3+ different lines)' },
|
||||
{ id: 'r6', field: 'tenure_years', operator: 'gte', value: 3, points: 10, label: 'Client for 3+ years' },
|
||||
{ id: 'r7', field: 'tenure_years', operator: 'gte', value: 5, points: 20, label: 'Client for 5+ years' },
|
||||
{ id: 'r8', field: 'collectivo_member', operator: 'eq', value: true, points: 15, label: 'Collectivo member with private policies' },
|
||||
{ id: 'r9', field: 'commission', operator: 'gte', value: 1000, points: 10, label: 'Annual commission >= $1,000' },
|
||||
]
|
||||
}
|
||||
|
||||
function defaultConfig(): CustomerAttentionConfig {
|
||||
return {
|
||||
tiers: defaultTiers(),
|
||||
rules: defaultRules(),
|
||||
autoClassify: true,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Customer input shape ── */
|
||||
|
||||
export interface CustomerAttentionInput {
|
||||
totalPremium: number
|
||||
policyCount: number
|
||||
lineCount: number
|
||||
tenureYears: number
|
||||
isCollectivoMember: boolean
|
||||
hasPrivatePolicies: boolean
|
||||
estimatedCommission: number
|
||||
}
|
||||
|
||||
/* ── Rule evaluation ── */
|
||||
|
||||
function evaluateRule(rule: AttentionRule, customer: CustomerAttentionInput): boolean {
|
||||
let fieldValue: number | boolean
|
||||
|
||||
switch (rule.field) {
|
||||
case 'premium':
|
||||
fieldValue = customer.totalPremium
|
||||
break
|
||||
case 'policy_count':
|
||||
fieldValue = customer.policyCount
|
||||
break
|
||||
case 'commission':
|
||||
fieldValue = customer.estimatedCommission
|
||||
break
|
||||
case 'multi_line':
|
||||
fieldValue = customer.lineCount
|
||||
break
|
||||
case 'tenure_years':
|
||||
fieldValue = customer.tenureYears
|
||||
break
|
||||
case 'collectivo_member':
|
||||
// Special: collectivo member rule only matches if also has private policies
|
||||
return rule.value === true && customer.isCollectivoMember && customer.hasPrivatePolicies
|
||||
case 'has_private_policies':
|
||||
return typeof rule.value === 'boolean' ? customer.hasPrivatePolicies === rule.value : false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof fieldValue === 'boolean' || typeof rule.value === 'boolean') return false
|
||||
|
||||
switch (rule.operator) {
|
||||
case 'gte': return (fieldValue as number) >= (rule.value as number)
|
||||
case 'gt': return (fieldValue as number) > (rule.value as number)
|
||||
case 'lte': return (fieldValue as number) <= (rule.value as number)
|
||||
case 'lt': return (fieldValue as number) < (rule.value as number)
|
||||
case 'eq': return (fieldValue as number) === (rule.value as number)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Composable ── */
|
||||
|
||||
export function useCustomerAttention() {
|
||||
const config = useLocalStorageRef<CustomerAttentionConfig>('policy-ui-customer-attention-v1', defaultConfig)
|
||||
|
||||
const tiers = computed(() => [...config.value.tiers].sort((a, b) => b.minScore - a.minScore))
|
||||
|
||||
const rules = computed(() => config.value.rules)
|
||||
|
||||
function getScoreForCustomer(customer: CustomerAttentionInput): number {
|
||||
let score = 0
|
||||
for (const rule of config.value.rules) {
|
||||
if (evaluateRule(rule, customer)) {
|
||||
score += rule.points
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
function getTierForCustomer(customer: CustomerAttentionInput): ServiceTier {
|
||||
const score = getScoreForCustomer(customer)
|
||||
const sorted = [...config.value.tiers].sort((a, b) => b.minScore - a.minScore)
|
||||
for (const tier of sorted) {
|
||||
if (score >= tier.minScore) return tier
|
||||
}
|
||||
// Fallback to lowest tier
|
||||
return sorted[sorted.length - 1] ?? config.value.tiers[0]
|
||||
}
|
||||
|
||||
/* ── Tier CRUD ── */
|
||||
|
||||
function addTier(tier: ServiceTier) {
|
||||
config.value.tiers.push(tier)
|
||||
}
|
||||
|
||||
function updateTier(id: string, patch: Partial<ServiceTier>) {
|
||||
const idx = config.value.tiers.findIndex(t => t.id === id)
|
||||
if (idx !== -1) {
|
||||
config.value.tiers[idx] = { ...config.value.tiers[idx], ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
function removeTier(id: string) {
|
||||
config.value.tiers = config.value.tiers.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
/* ── Rule CRUD ── */
|
||||
|
||||
function addRule(rule: AttentionRule) {
|
||||
config.value.rules.push(rule)
|
||||
}
|
||||
|
||||
function updateRule(id: string, patch: Partial<AttentionRule>) {
|
||||
const idx = config.value.rules.findIndex(r => r.id === id)
|
||||
if (idx !== -1) {
|
||||
config.value.rules[idx] = { ...config.value.rules[idx], ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
function removeRule(id: string) {
|
||||
config.value.rules = config.value.rules.filter(r => r.id !== id)
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
tiers,
|
||||
rules,
|
||||
getScoreForCustomer,
|
||||
getTierForCustomer,
|
||||
addTier,
|
||||
updateTier,
|
||||
removeTier,
|
||||
addRule,
|
||||
updateRule,
|
||||
removeRule,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user