// Map live API shapes onto the Aria design-system domain types. import type { Channel, Decision, Lead, Segment, Tone } from '../types'; import { ACTIVITY_NAME_BY_UID, AI_EMPLOYEES, AI_RECOMMENDATION, STATE_NAME_BY_UID, SUPER_ROLES, aiEmployeeForState, stageOfState, } from './config'; import type { AiDecision, AuditEntry, LeadRecord } from './types'; const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; export function formatDate(iso?: string | null): string { if (!iso) return '—'; const d = new Date(iso); if (Number.isNaN(d.getTime())) return '—'; return `${d.getDate().toString().padStart(2, '0')} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; } export function formatTime(iso?: string | null): string { if (!iso) return ''; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true }); } function ageFrom(iso?: string | null): number { if (!iso) return 0; const d = new Date(iso); if (Number.isNaN(d.getTime())) return 0; const now = new Date(); let age = now.getFullYear() - d.getFullYear(); const m = now.getMonth() - d.getMonth(); if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--; return age; } function toSegment(raw?: string | null): Segment { switch ((raw ?? '').toLowerCase()) { case 'hot': return 'Hot'; case 'cold': return 'Cold'; default: return 'Warm'; } } const CHANNELS: Record = { whatsapp: 'WhatsApp', web: 'Web', referral: 'Referral', agency: 'Agency', bancassurance: 'Bancassurance', direct: 'Direct', event: 'Event', }; function toChannel(raw?: string | null): Channel { return CHANNELS[(raw ?? '').toLowerCase()] ?? 'Web'; } // Several recordview fields arrive as structured objects, not scalars. These // flatten each to a display string; screens must route reads through them // rather than rendering the raw value (a raw object crashes React). // Phone from the phone-input widget: {dial_code, phone, phone_with_dial_code}. function toPhone(raw: unknown): string { if (typeof raw === 'string') return raw; if (raw && typeof raw === 'object') { const o = raw as { phone_with_dial_code?: string; phone?: string }; return o.phone_with_dial_code ?? o.phone ?? '—'; } return '—'; } // assigned_region is a tbl_branches lookup: {region}. Returns undefined when // absent so it composes with `??` fallback chains. export function regionOf(raw: unknown): string | undefined { if (typeof raw === 'string') return raw || undefined; if (raw && typeof raw === 'object') { return (raw as { region?: string }).region || undefined; } return undefined; } // recommended_product is a product-catalog lookup; plan_name is its display // column (older rows may carry a {name} or a plain string). export function productOf(raw: unknown): string | undefined { if (typeof raw === 'string') return raw || undefined; if (raw && typeof raw === 'object') { const o = raw as { plan_name?: string; name?: string }; return o.plan_name ?? o.name ?? undefined; } return undefined; } // state -> a friendly "last action" line + tone for the pipeline list. const STATE_ACTION: Record = { 'New Lead': { action: 'Awaiting qualification', tone: 'neutral' }, Qualified: { action: 'Qualified — ready to assign', tone: 'success' }, Assigned: { action: 'Assigned to agent', tone: 'success' }, Contacted: { action: 'First contact logged', tone: 'info' }, 'Meeting Scheduled': { action: 'Meeting scheduled', tone: 'accent' }, 'Product Recommended': { action: 'Product recommended', tone: 'accent' }, 'Documents Collected': { action: 'KYC documents collected', tone: 'info' }, 'Eligibility Assessed': { action: 'Eligibility assessed', tone: 'accent' }, Underwriting: { action: 'Underwriting in progress', tone: 'accent' }, 'Policy Issued': { action: 'Policy issued', tone: 'success' }, 'Customer 360': { action: 'Onboarded — Customer 360', tone: 'success' }, Disqualified: { action: 'Disqualified', tone: 'warning' }, }; export function recordToLead(r: LeadRecord): Lead { const state = r.current_state_name ?? 'New Lead'; const ai = aiEmployeeForState(state); const human = r.owner_agent?.name; const owner = human ?? ai?.name ?? 'Unassigned'; const meta = STATE_ACTION[state] ?? { action: state, tone: 'neutral' as Tone }; return { id: String(r.instance_id), name: r.lead_name ?? `Lead ${r.instance_id}`, city: r.city ?? r.state_region ?? regionOf(r.assigned_region) ?? '—', phone: toPhone(r.phone), email: r.email ?? '—', dob: formatDate(r.date_of_birth), age: ageFrom(r.date_of_birth), income: r.annual_income ?? 0, occupation: r.occupation ?? '—', language: r.preferred_language ?? '—', interest: r.product_interest ?? '—', score: r.lead_score ?? 0, segment: toSegment(r.lead_segment), channel: toChannel(r.channel_source), owner, ownerAi: !human && !!ai, stage: Math.max(0, stageOfState(state)), lastAction: meta.action, lastTone: meta.tone, }; } // --- AI decision stream --- function asStringArray(v: unknown): string[] { if (!v) return []; if (Array.isArray(v)) { return v .map((x) => (typeof x === 'string' ? x : (x as { name?: string; title?: string })?.name ?? (x as { title?: string })?.title ?? '')) .filter(Boolean); } return []; } function firstSentence(text: string): string { const t = text.trim(); const m = t.match(/^.*?[.!?](\s|$)/); return (m ? m[0] : t).trim(); } export function decisionToCard(d: AiDecision): Decision { const known = AI_EMPLOYEES[d.ai_user_id]; const emp = known ?? { name: d.employee_name?.trim() || `AI ${d.ai_user_id}`, role: 'AI Employee' }; const reasoning = (d.reasoning ?? '').trim(); const activityName = ACTIVITY_NAME_BY_UID[d.activity_id] ?? (d.activity_name && !d.activity_name.includes('-') ? d.activity_name : null) ?? d.instance_state ?? 'Decision'; return { employee: emp.name, role: emp.role, activity: activityName, time: formatTime(d.created_at), confidence: typeof d.confidence === 'number' ? d.confidence : 0, summary: reasoning ? firstSentence(reasoning) : 'Decision recorded.', reasoning, citations: asStringArray(d.knowledge_sources), }; } // --- Aria Advisor product recommendation --- export interface AiRecommendation { /** Product name as the AI named it, e.g. "ABSLI DigiShield Term Plan". */ product: string; sumAssured: number | null; rationale: string; /** 0–1 (the decision confidence, or the ai_* field / 100). */ confidence: number; employee: string; role: string; time: string | null; } function num(v: unknown): number | null { if (v == null || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null; } /** Pull Aria Advisor's pre-activity product recommendation out of the decision * stream (it fires after Schedule Meeting). Returns null if she hasn't run. */ export function recommendationFromDecisions(decisions: AiDecision[]): AiRecommendation | null { const F = AI_RECOMMENDATION.fields; // A recommendation decision = the right activity/employee, carrying a product // name, and actually *submitted* (skip failed retries, which other employees // log at low confidence). Prefer the real Aria Advisor over any stand-in. const candidates = decisions.filter( (x) => x.status === 'submitted' && typeof x.data?.[F.product] === 'string' && (x.data[F.product] as string).trim() !== '' && (x.activity_id === AI_RECOMMENDATION.activityUid || x.ai_user_id === AI_RECOMMENDATION.aiUserId), ); const d = candidates.find((x) => x.ai_user_id === AI_RECOMMENDATION.aiUserId) ?? candidates[0]; const product = d?.data?.[F.product]; if (!d || typeof product !== 'string' || !product.trim()) return null; const emp = AI_EMPLOYEES[d.ai_user_id] ?? { name: d.employee_name?.trim() || 'Aria Advisor', role: 'Product Recommendation', }; const pct = num(d.data?.[F.confidence]); return { product: product.trim(), sumAssured: num(d.data?.[F.sumAssured]), rationale: (typeof d.data?.[F.rationale] === 'string' ? (d.data[F.rationale] as string) : d.reasoning ?? '').trim(), confidence: typeof d.confidence === 'number' && d.confidence > 0 ? d.confidence : pct != null ? pct / 100 : 0, employee: emp.name, role: emp.role, time: d.created_at ? formatTime(d.created_at) : null, }; } // --- audit timeline --- export interface TimelineItem { actor: string; ai: boolean; action: string; time: string; tone: Tone; } // Audit entries carry no username — only user_id + human-readable roles. Show // the person's role ("Sales Agent", "Underwriter") instead of a raw "User 5"; // admins surface as "Admin". function humanActor(userId: string, roles: string[] | undefined): string { const r = roles ?? []; const sup = r.find((role) => SUPER_ROLES.includes(role)); if (sup) return 'Admin'; if (r[0]) return r[0]; return userId === '1' ? 'Admin' : 'Team member'; } export function auditToTimeline(entries: AuditEntry[]): TimelineItem[] { const items = [...entries] .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .map((e) => { const ai = AI_EMPLOYEES[e.user_id]; const activity = ACTIVITY_NAME_BY_UID[e.activity_id]; const stateName = STATE_NAME_BY_UID[e.execution_state ?? '']; const action = activity ? `performed ${activity}` : stateName ? `advanced to ${stateName}` : 'performed an activity'; return { actor: ai?.name ?? humanActor(e.user_id, e.user_roles), ai: !!ai, action, time: formatTime(e.created_at), tone: (ai ? 'accent' : 'neutral') as Tone, }; }); // Collapse consecutive identical (actor + action) entries from trigger fan-out. return items.filter( (it, i) => i === 0 || it.actor !== items[i - 1].actor || it.action !== items[i - 1].action, ); }