287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
// 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<string, Channel> = {
|
||
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<string, { action: string; tone: Tone }> = {
|
||
'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,
|
||
);
|
||
}
|