import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { Sparkles } from 'lucide-react'; import { useQuery, useZino } from '../api/provider'; import { KIND_LABEL, RV, STATE_TONE } from '../api/config'; import type { Tone } from '../api/config'; import type { AiDecision, AuditEntry, Row, SimRow } from '../api/types'; import { Badge, Bar, Card, Empty, ErrorNote, Spinner } from './core'; import { date, dateTime, inr, mt, num, pct } from '../format'; export function StateBadge({ state }: { state: unknown }) { const s = String(state ?? ''); return {s || '—'}; } export function KindBadge({ kind }: { kind: unknown }) { const k = String(kind ?? ''); const tone: Tone = k === 'NOTHING_TO_DO' ? 'slate' : k === 'AT_RISK' ? 'amber' : k === 'COLLECTION' ? 'sky' : 'accent'; return {KIND_LABEL[k] ?? k}; } // --------------------------------------------------------------------------- // DataTable — a record view as a table, with optional paging and row click. // --------------------------------------------------------------------------- export interface Col { key: string; label: string; render?: (row: Row) => ReactNode; className?: string; align?: 'right' | 'left'; } export function DataTable({ rows, cols, onRow, empty = 'Nothing here yet.', loading, error, rowKey }: { rows: Row[] | null; cols: Col[]; onRow?: (r: Row) => void; empty?: string; loading?: boolean; error?: string | null; rowKey?: (r: Row, i: number) => string; }) { if (loading && !rows) return ; if (error) return
{error}
; if (!rows || rows.length === 0) return {empty}; return (
{cols.map((c) => )} {rows.map((r, i) => ( onRow(r) : undefined} className={`border-b border-line last:border-0 ${onRow ? 'cursor-pointer hover:bg-panel2' : ''}`}> {cols.map((c) => ( ))} ))}
{c.label}
{c.render ? c.render(r) : cell(r[c.key])}
); } export function cell(v: unknown): ReactNode { if (v === null || v === undefined || v === '') return —; if (typeof v === 'boolean') return v ? 'Yes' : 'No'; return String(v); } // --------------------------------------------------------------------------- // Recommendation card — what the Advisor told a dealer. // --------------------------------------------------------------------------- export function RecCard({ rec, compact = false }: { rec: Row; compact?: boolean }) { const inc = num(rec.incremental_payout); return (
{rec.scheme_name ? {String(rec.scheme_name)}{rec.component_name ? ` · ${String(rec.component_name)}` : ''} : null} {rec.deadline ? `by ${date(rec.deadline)}` : ''}

{String(rec.headline ?? '')}

{inc !== 0 &&
0 ? 'text-accent' : 'text-bad'}`}>{inr(inc, { sign: true })}
}
{!compact && rec.rationale_md ?

{String(rec.rationale_md)}

: null} {!compact && (
{rec.delta_mt ? Add {mt(rec.delta_mt)} : null} {rec.delta_pp_mt ? Premium {mt(rec.delta_pp_mt)} : null} {rec.clear_outstanding_amt ? Clear {inr(rec.clear_outstanding_amt)} : null} {rec.payout_now != null ? Now {inr(rec.payout_now)} → if acted {inr(rec.payout_if)} : null} {rec.confidence ? Confidence {pct(num(rec.confidence) * 100, 0)} : null} Scheme Advisor
)}
); } // --------------------------------------------------------------------------- // The engine, live: simulate() for a dealer with an optional what-if. // --------------------------------------------------------------------------- export function useSimulate(customerNo: string | null | undefined, hypo: Record | null, debounceMs = 500) { const { client } = useZino(); const [key, setKey] = useState(JSON.stringify(hypo ?? {})); useEffect(() => { const t = setTimeout(() => setKey(JSON.stringify(hypo ?? {})), debounceMs); return () => clearTimeout(t); }, [hypo, debounceMs]); return useQuery( () => client.rows(RV.SIMULATE, { limit: 100, params: { customer_no: customerNo, hypo_json: key } }).then((r) => r as unknown as SimRow[]), [customerNo, key], !!customerNo, ); } export function SimSummary({ rows, hasHypo }: { rows: SimRow[] | null; hasHypo: boolean }) { if (!rows) return null; const now = rows.reduce((s, r) => s + num(r.payout_now), 0); const if_ = rows.reduce((s, r) => s + num(r.payout_if), 0); const inc = if_ - now; return (
Earning now
{inr(now)}
{hasHypo ? 'With this order' : 'If nothing changes'}
{inr(if_)}
0 ? 'border-accent-edge bg-accent-soft' : inc < 0 ? 'border-bad-edge bg-bad-soft' : 'border-line bg-panel2'}`}>
Difference
0 ? 'text-accent' : inc < 0 ? 'text-bad' : 'text-ink'}`}>{inr(inc, { sign: true })}
); } /** One row per eligible scheme component: slab, progress to the next slab, payout now → if. */ export function SimTable({ rows, hasHypo, loading, error }: { rows: SimRow[] | null; hasHypo: boolean; loading: boolean; error: string | null }) { if (loading && !rows) return ; if (error) return {error}; if (!rows || rows.length === 0) return No live scheme applies to this dealer right now.; const sorted = [...rows].sort((a, b) => num(b.incremental) - num(a.incremental) || num(b.payout_now) - num(a.payout_now)); return (
{sorted.map((r) => { const inc = num(r.incremental); const toNext = r.mt_to_next_slab != null ? num(r.mt_to_next_slab) : null; const basis = num(r.basis_value); const target = toNext != null ? basis + toNext : null; return (
{r.scheme_name} {r.component_name} · {r.days_left} day{r.days_left === 1 ? '' : 's'} left {r.in_kind_now ?? inr(r.payout_now)} {hasHypo && <>→ 0 ? 'font-semibold text-accent' : inc < 0 ? 'font-semibold text-bad' : 'text-muted'}>{r.in_kind_if ?? inr(r.payout_if)}}
{r.slab_label && {hasHypo && r.slab_label_if && r.slab_label_if !== r.slab_label ? `${r.slab_label} → ${r.slab_label_if}` : r.slab_label}} {!r.gate_passed && r.gate_reason && {r.gate_reason}} {r.gate_passed && r.gate_reason && {r.gate_reason}} {r.growth_pct != null && growth {pct(r.growth_pct)}} {r.achievement_pct != null && achievement {pct(r.achievement_pct)}}
{toNext != null && target != null && toNext > 0 && (
{mt(basis)} now{mt(toNext)} more → {r.next_slab_label}{r.next_slab_rate ? ` @ ₹${num(r.next_slab_rate)}` : ''}
)} {r.next_growth_label && r.growth_pts_to_next != null && num(r.growth_pts_to_next) > 0 && (
{pct(r.growth_pts_to_next)} points of growth to {r.next_growth_label}
)}
); })}
); } // --------------------------------------------------------------------------- // What happened on an instance: the audit trail + the AI's own reasoning. // --------------------------------------------------------------------------- export function InstanceTrace({ instanceId }: { instanceId: number | string }) { const { client } = useZino(); const audit = useQuery(() => client.audit(instanceId), [instanceId], true, 15000); const ai = useQuery(() => client.aiDecisions(instanceId).catch(() => [] as AiDecision[]), [instanceId], true, 15000); return ( {audit.loading && !audit.data && } {audit.error &&
{audit.error}
}
    {(audit.data ?? []).map((a: AuditEntry) => { const dec = (ai.data ?? []).find((d) => d.action === a.activity_id && Math.abs(new Date(d.created_at ?? 0).getTime() - new Date(a.performed_at ?? a.created_at ?? 0).getTime()) < 120000); const isAi = (a.user_roles ?? []).some((r) => r.startsWith('ai_')); return (
  1. {a.activity_name ?? a.activity_id} {isAi && }{a.user_name || a.user_email} {dateTime(a.performed_at ?? a.created_at)}
    {a.fields && a.fields.length > 0 && (
    {a.fields.filter((f) => f.value !== '' && f.value != null).slice(0, 12).map((f) => (
    {f.label}:
    {String(f.value).slice(0, 300)}
    ))}
    )} {(a.ai_reasoning || dec?.reasoning) && (
    Reasoning{dec?.model ? ` · ${dec.model}` : ''}{dec?.cost ? ` · $${num(dec.cost).toFixed(3)}` : ''}

    {String(a.ai_reasoning ?? dec?.reasoning)}

    )}
  2. ); })}
{audit.data && audit.data.length === 0 && No steps recorded yet.}
); }