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>
215 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|