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) => | {c.label} | )}
{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.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 (
{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 (
-
{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)}
)}
);
})}
{audit.data && audit.data.length === 0 && No steps recorded yet.}
);
}