dealer-scheme-advisor/src/components/widgets.tsx
Yashas 134db9a06c Dealer Scheme Advisor console: dealer portal + supplier console over the Zino runtime
Vite + React + TS + Tailwind v4, runtime config.js and base-href contract
(same shape as HDFC-Loan-Desk). Dealer: home, place order with the scheme
engine live beside the form (query-kind record views over simulate()), orders
with the Advisor's advice and one-click confirm-as-advised, schemes with
progress to the next slab, incentives. Supplier: clusters + near-slab sweeps,
dealers and dealer 360, advisories, scheme catalogue with draft → Analyst
costing → approval, settlement runs with release/hold.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 16:38:05 +05:30

215 lines
13 KiB
TypeScript

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 <Badge tone={STATE_TONE[s] ?? 'slate'}>{s || '—'}</Badge>;
}
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 <Badge tone={tone}>{KIND_LABEL[k] ?? k}</Badge>;
}
// ---------------------------------------------------------------------------
// 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 <Spinner />;
if (error) return <div className="p-4"><ErrorNote>{error}</ErrorNote></div>;
if (!rows || rows.length === 0) return <Empty>{empty}</Empty>;
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line text-left text-[11px] font-semibold tracking-wide text-faint uppercase">
{cols.map((c) => <th key={c.key} className={`px-3 py-2 ${c.align === 'right' ? 'text-right' : ''}`}>{c.label}</th>)}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={rowKey ? rowKey(r, i) : i} onClick={onRow ? () => onRow(r) : undefined}
className={`border-b border-line last:border-0 ${onRow ? 'cursor-pointer hover:bg-panel2' : ''}`}>
{cols.map((c) => (
<td key={c.key} className={`tnum px-3 py-2 align-top ${c.align === 'right' ? 'text-right' : ''} ${c.className ?? ''}`}>
{c.render ? c.render(r) : cell(r[c.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function cell(v: unknown): ReactNode {
if (v === null || v === undefined || v === '') return <span className="text-faint">—</span>;
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 (
<div className="rounded-lg border border-line bg-panel p-4">
<div className="flex flex-wrap items-center gap-2">
<KindBadge kind={rec.kind} />
{rec.scheme_name ? <span className="text-xs text-faint">{String(rec.scheme_name)}{rec.component_name ? ` · ${String(rec.component_name)}` : ''}</span> : null}
<span className="ml-auto text-xs text-faint">{rec.deadline ? `by ${date(rec.deadline)}` : ''}</span>
</div>
<div className="mt-2 flex items-start justify-between gap-4">
<p className="text-sm font-semibold text-ink">{String(rec.headline ?? '')}</p>
{inc !== 0 && <div className={`tnum shrink-0 text-lg font-semibold ${inc > 0 ? 'text-accent' : 'text-bad'}`}>{inr(inc, { sign: true })}</div>}
</div>
{!compact && rec.rationale_md ? <p className="mt-2 text-sm whitespace-pre-line text-muted">{String(rec.rationale_md)}</p> : null}
{!compact && (
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-faint">
{rec.delta_mt ? <span>Add <b className="text-ink">{mt(rec.delta_mt)}</b></span> : null}
{rec.delta_pp_mt ? <span>Premium <b className="text-ink">{mt(rec.delta_pp_mt)}</b></span> : null}
{rec.clear_outstanding_amt ? <span>Clear <b className="text-ink">{inr(rec.clear_outstanding_amt)}</b></span> : null}
{rec.payout_now != null ? <span>Now <b className="text-ink">{inr(rec.payout_now)}</b> → if acted <b className="text-ink">{inr(rec.payout_if)}</b></span> : null}
{rec.confidence ? <span>Confidence {pct(num(rec.confidence) * 100, 0)}</span> : null}
<span className="ml-auto"><Sparkles className="mr-1 inline h-3 w-3 text-ai" />Scheme Advisor</span>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// The engine, live: simulate() for a dealer with an optional what-if.
// ---------------------------------------------------------------------------
export function useSimulate(customerNo: string | null | undefined, hypo: Record<string, unknown> | 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 (
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg border border-line bg-panel2 px-3 py-2"><div className="text-[11px] font-semibold tracking-wide text-faint uppercase">Earning now</div><div className="tnum text-lg font-semibold text-ink">{inr(now)}</div></div>
<div className="rounded-lg border border-line bg-panel2 px-3 py-2"><div className="text-[11px] font-semibold tracking-wide text-faint uppercase">{hasHypo ? 'With this order' : 'If nothing changes'}</div><div className="tnum text-lg font-semibold text-ink">{inr(if_)}</div></div>
<div className={`rounded-lg border px-3 py-2 ${inc > 0 ? 'border-accent-edge bg-accent-soft' : inc < 0 ? 'border-bad-edge bg-bad-soft' : 'border-line bg-panel2'}`}>
<div className="text-[11px] font-semibold tracking-wide text-faint uppercase">Difference</div>
<div className={`tnum text-lg font-semibold ${inc > 0 ? 'text-accent' : inc < 0 ? 'text-bad' : 'text-ink'}`}>{inr(inc, { sign: true })}</div>
</div>
</div>
);
}
/** 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 <Spinner label="Asking the scheme engine…" />;
if (error) return <ErrorNote>{error}</ErrorNote>;
if (!rows || rows.length === 0) return <Empty>No live scheme applies to this dealer right now.</Empty>;
const sorted = [...rows].sort((a, b) => num(b.incremental) - num(a.incremental) || num(b.payout_now) - num(a.payout_now));
return (
<div className="divide-y divide-line">
{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 (
<div key={r.component_id} className="px-4 py-3">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-sm font-semibold text-ink">{r.scheme_name}</span>
<span className="text-xs text-faint">{r.component_name} · {r.days_left} day{r.days_left === 1 ? '' : 's'} left</span>
<span className="ml-auto tnum text-sm">
<span className="text-muted">{r.in_kind_now ?? inr(r.payout_now)}</span>
{hasHypo && <><span className="mx-1 text-faint">→</span><span className={inc > 0 ? 'font-semibold text-accent' : inc < 0 ? 'font-semibold text-bad' : 'text-muted'}>{r.in_kind_if ?? inr(r.payout_if)}</span></>}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
{r.slab_label && <Badge tone={r.gate_passed ? 'emerald' : 'slate'}>{hasHypo && r.slab_label_if && r.slab_label_if !== r.slab_label ? `${r.slab_label} → ${r.slab_label_if}` : r.slab_label}</Badge>}
{!r.gate_passed && r.gate_reason && <span className="text-warn">{r.gate_reason}</span>}
{r.gate_passed && r.gate_reason && <span className="text-faint">{r.gate_reason}</span>}
{r.growth_pct != null && <span>growth {pct(r.growth_pct)}</span>}
{r.achievement_pct != null && <span>achievement {pct(r.achievement_pct)}</span>}
</div>
{toNext != null && target != null && toNext > 0 && (
<div className="mt-2">
<div className="flex justify-between text-[11px] text-faint"><span>{mt(basis)} now</span><span><b className="text-ink">{mt(toNext)}</b> more → {r.next_slab_label}{r.next_slab_rate ? ` @ ₹${num(r.next_slab_rate)}` : ''}</span></div>
<Bar value={basis} max={target} className="mt-1" />
</div>
)}
{r.next_growth_label && r.growth_pts_to_next != null && num(r.growth_pts_to_next) > 0 && (
<div className="mt-1 text-[11px] text-faint">{pct(r.growth_pts_to_next)} points of growth to {r.next_growth_label}</div>
)}
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<Card title="Trail" subtitle="Every step on this record, and what the AI reasoned.">
{audit.loading && !audit.data && <Spinner />}
{audit.error && <div className="p-4"><ErrorNote>{audit.error}</ErrorNote></div>}
<ol className="divide-y divide-line">
{(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 (
<li key={a.id} className="px-4 py-3">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<span className="font-semibold text-ink">{a.activity_name ?? a.activity_id}</span>
<span className={`text-xs ${isAi ? 'text-ai' : 'text-faint'}`}>{isAi && <Sparkles className="mr-1 inline h-3 w-3" />}{a.user_name || a.user_email}</span>
<span className="ml-auto text-xs text-faint">{dateTime(a.performed_at ?? a.created_at)}</span>
</div>
{a.fields && a.fields.length > 0 && (
<dl className="mt-1.5 grid grid-cols-1 gap-x-4 gap-y-1 text-xs sm:grid-cols-2">
{a.fields.filter((f) => f.value !== '' && f.value != null).slice(0, 12).map((f) => (
<div key={f.field_id} className="min-w-0"><dt className="inline text-faint">{f.label}: </dt><dd className="inline break-words text-ink">{String(f.value).slice(0, 300)}</dd></div>
))}
</dl>
)}
{(a.ai_reasoning || dec?.reasoning) && (
<details className="mt-2 rounded-md border border-ai-edge bg-ai-soft px-3 py-2 text-xs text-ink">
<summary className="cursor-pointer font-semibold text-ai">Reasoning{dec?.model ? ` · ${dec.model}` : ''}{dec?.cost ? ` · $${num(dec.cost).toFixed(3)}` : ''}</summary>
<p className="mt-2 whitespace-pre-line">{String(a.ai_reasoning ?? dec?.reasoning)}</p>
</details>
)}
</li>
);
})}
</ol>
{audit.data && audit.data.length === 0 && <Empty>No steps recorded yet.</Empty>}
</Card>
);
}