feat(demo): one borrower on both surfaces, and a lead-time dashboard

/demo puts the officer console and the borrower's phone side by side on one
screen. The right column is a real borrower sign-in — selecting a row calls
/auth/app-login and every panel beside it is fetched with that borrower's own
token. Nothing is mirrored across, which is the point: if the two sides agree
it is because they read one record, and the page checks that rather than
asserting it.

It uses its own axios instance. The shared one picks its JWT from
window.location.pathname, which is right for the two portals and wrong here —
it would send the officer's token to /app/*, and its 401 handler would then
clear both sessions and bounce to sign-in, during a live demo.

The officer dashboard is reorganised around when a borrower's downside stops
covering the instalment, rather than by composite score. Lead time is the
thing this platform can show that a bureau score cannot, so the triage funnel
counts act-now / in-window / covered and the work list ranks by it. The
previous score-ranked view is preserved as a tab; they answer different
questions and sort by different keys, and merging them would mean losing one
ordering.

StatusPill no longer white-screens on a missing status. It renders wire data
on fifteen call sites; a row that arrives without one is a data gap, not
grounds to take the page down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-25 13:45:48 +05:30
co-authored by Claude Opus 5
parent 13c628fa4b
commit 1ffc1cbd4c
10 changed files with 2046 additions and 268 deletions
+346 -203
View File
@@ -1,248 +1,391 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { CalendarClock, CloudRain, Database, Siren, TrendingDown } from 'lucide-react'
import { get } from '../../lib/api'
import type { Enterprise, PortfolioSummary } from '../../lib/api'
import {
DriverBar,
GhostBtn,
KpiTile,
PrimaryBtn,
RiskBadge,
ScoreDial,
Sparkline,
} from '../../components/ui/bits'
import {
DRIVER_META,
SECTORS,
breachPoint,
forecastFor,
reasonCodesFor,
subScoresFor,
} from '../../lib/demo/enterprises'
import { portfolioSummary } from '../../lib/demo/ops'
import { inr, monthRef } from '../../lib/demo/seed'
import type { ClimateSignal, Enterprise, MarketSignal, PortfolioSummary } from '../../lib/api'
import { Unavailable } from '../../components/ui/bits'
import { SECTORS } from '../../lib/demo/enterprises'
import { demoClimateSignals, demoMarketSignals } from '../../lib/demo/feeds'
import { monthRef } from '../../lib/demo/seed'
import { exportCsv } from '../../lib/csv'
import { useScope } from './officerScope'
import { ScheduleVisitModal } from './ScheduleVisitModal'
import { DashboardTabs } from './DashboardTabs'
/**
* The console's home screen and the deck's headline wireframe: KPI tiles, the
* "Enterprises by risk" register, and a risk detail panel that explains the
* selected enterprise in the officer's own language.
* The officer's morning screen, organised by **when trouble arrives**.
*
* <p>The previous version ranked by score: five tiles counting bands, then forty
* rows sorted by composite score. That is what a bureau score already shows. The
* thing this platform can do that a bureau cannot is say <em>when</em> — trouble
* four months out, while intervention is still cheap — and nothing on that screen
* expressed time at all.
*
* <p>So the primary axis here is the horizon at which the downside case first
* fails to cover the instalment. The triage strip is a funnel, the chart draws
* the intervention window, and the list is a day's work rather than a register.
* The full register still lives at <code>/officer/enterprises</code>; this screen
* deliberately does not duplicate it.
*
* <p>The old layout is kept at <code>/officer/classic</code> — it is verified and
* this one is new, and on a demo day that switch is worth having.
*/
type Bucket = 'NOW' | 'WINDOW' | 'LATER' | 'CLEAR'
const BUCKETS: { key: Bucket; label: string; caption: string; tone: string }[] = [
{ key: 'NOW', label: 'Act now', caption: 'breaching this month', tone: 'gf-now' },
{ key: 'WINDOW', label: 'Month 2–4', caption: 'the window that matters', tone: 'gf-window' },
{ key: 'LATER', label: 'Month 5–6', caption: 'on the horizon', tone: 'gf-watch' },
{ key: 'CLEAR', label: 'Downside covered', caption: 'no breach forecast', tone: 'gf-clear' },
]
/**
* Which bucket a borrower falls in — **on breach horizon alone**.
*
* <p>Deliberately one dimension. An earlier version mixed band into the last two
* segments, which put every WATCH-band borrower with no forecast breach into
* "Watching" and left "Healthy" reading zero — a funnel that bulged in the middle
* and looked broken.
*
* <p>Horizon also has to win over score on its own merits. A STANDARD-band
* enterprise whose downside stops covering the instalment in four months is the
* entire point of this product; ranking by current score would bury it under
* borrowers already in arrears, who are already known to everyone.
*/
function bucketOf(e: Enterprise): Bucket {
const h = e.firstBreachHorizon
if (h == null) return 'CLEAR'
if (h <= 1) return 'NOW'
if (h <= 4) return 'WINDOW'
return 'LATER'
}
export function OfficerDashboard() {
const navigate = useNavigate()
const { block, month } = useScope()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [filter, setFilter] = useState<Bucket | 'ALL'>('ALL')
const [scheduling, setScheduling] = useState<Enterprise | null>(null)
const { data } = useQuery({
const { data, isError, refetch } = useQuery({
queryKey: ['portfolio', block, month],
queryFn: () => get<PortfolioSummary>('/officer/portfolio', { block, month }),
})
// Demo fallback: with the backend down, the screen still shows the full book.
const summary = data ?? portfolioSummary(block, month)
const rows = summary.rows
// No demo fallback: the demo portfolio carries no owner, so substituting it
// would show this bank every other bank's borrowers.
const rows = useMemo(() => data?.rows ?? [], [data])
const selected = useMemo(
() => rows.find((e) => e.enterpriseId === selectedId) ?? rows[0],
[rows, selectedId],
)
const counts = useMemo(() => {
const c: Record<Bucket, number> = { NOW: 0, WINDOW: 0, LATER: 0, CLEAR: 0 }
rows.forEach((e) => { c[bucketOf(e)] += 1 })
return c
}, [rows])
function handleExport() {
exportCsv(
`udyamlinc-portfolio-${block}-${month}.csv`,
['Enterprise ID', 'Name', 'Sector', 'Block', 'Score', 'Band', 'Loan outstanding', 'Instalment'],
rows.map((e) => [
e.enterpriseId,
e.name,
SECTORS[e.sector].label,
e.block,
e.score,
e.band,
e.loanOutstanding,
e.monthlyInstalment,
]),
)
}
// How many borrowers first breach at each horizon — the intervention window, drawn.
const horizon = useMemo(() => {
const h = [0, 0, 0, 0, 0, 0]
rows.forEach((e) => {
const n = e.firstBreachHorizon
if (n != null && n >= 1 && n <= 6) h[n - 1] += 1
})
return h
}, [rows])
// The day's work: soonest breach first, then worst score. Capped, because an
// officer visits nine borrowers a week and a list of forty is a list of none.
const worklist = useMemo(() => {
const pool = filter === 'ALL'
? rows.filter((e) => bucketOf(e) !== 'CLEAR')
: rows.filter((e) => bucketOf(e) === filter)
return [...pool]
.sort((a, b) =>
(a.firstBreachHorizon ?? 99) - (b.firstBreachHorizon ?? 99) || b.score - a.score)
.slice(0, 9)
}, [rows, filter])
if (isError || !data) return <Unavailable what="Portfolio" onRetry={() => void refetch()} />
const maxH = Math.max(1, ...horizon)
return (
<div>
{/* ── KPI tiles ─────────────────────────────────────── */}
<div className="flex flex-wrap gap-3 mb-4">
<KpiTile
label="Total enterprises"
value={summary.total}
delta={`${summary.totalDelta >= 0 ? '+' : ''}${summary.totalDelta} this month`}
/>
<KpiTile
label="High risk"
value={summary.red}
delta={`${summary.redDelta >= 0 ? '+' : ''}${summary.redDelta} since last week`}
tone="red"
/>
<KpiTile
label="Watch"
value={summary.watch}
delta={`${summary.watchDelta >= 0 ? '+' : ''}${summary.watchDelta} since last week`}
tone="watch"
/>
<KpiTile
label="Standard"
value={summary.standard}
delta={summary.standardDelta === 0 ? 'stable' : `${summary.standardDelta > 0 ? '+' : ''}${summary.standardDelta} since last week`}
tone="good"
/>
<DashboardTabs />
<div className="gf">
{/* ── triage funnel ─────────────────────────────────────
One strip, four segments, urgency decreasing left to right. A row of
separate tiles counting bands reads as a census; this reads as a queue. */}
<div className="gf-triage">
{BUCKETS.map((b) => {
const active = filter === b.key
return (
<button
key={b.key}
onClick={() => setFilter(active ? 'ALL' : b.key)}
className={`gf-seg ${b.tone} ${active ? 'is-on' : ''}`}
>
<span className="gf-seg-label">{b.label}</span>
<span className="gf-seg-val num">{counts[b.key]}</span>
<span className="gf-seg-cap">{b.caption}</span>
</button>
)
})}
</div>
<div className="flex gap-4 items-start flex-col xl:flex-row">
{/* ── enterprises by risk ─────────────────────────── */}
<div className="flex-1 min-w-0 bg-white border border-slate-200 rounded-[11px] overflow-hidden">
<div className="px-4 py-2.5 border-b border-slate-200 flex items-center">
<h3 className="text-[12.5px] font-bold text-forest">Enterprises by risk</h3>
<span className="text-[10px] text-slate-400 ml-2">updated {summary.updatedMinutesAgo} minutes ago</span>
<GhostBtn className="ml-auto" onClick={handleExport}>
Export CSV
</GhostBtn>
<div className="gf-row2">
{/* ── lead-time chart ───────────────────────────────── */}
<section className="gf-card">
<header className="gf-head">
<h2>When the book breaches</h2>
<span>Borrowers whose downside first misses the instalment, by month</span>
</header>
<div className="gf-bars">
{horizon.map((n, i) => {
const m = monthRef(new Date(new Date().getFullYear(), new Date().getMonth() + i + 1, 1))
const inWindow = i >= 1 && i <= 3
return (
<div key={m.key} className="gf-bar">
<span className="gf-bar-n num">{n || ''}</span>
<span className="gf-bar-track">
<span
className={`gf-bar-fill ${i === 0 ? 'is-now' : inWindow ? 'is-window' : ''}`}
style={{ height: n === 0 ? "3px" : `${Math.max(14, (n / maxH) * 100)}%` }}
/>
</span>
<span className="gf-bar-x">{m.label}</span>
</div>
)
})}
</div>
<p className="gf-note">
<TrendingDown size={13} />
The middle months are where a visit still changes the outcome. {counts.WINDOW} borrowers
sit there now — none of them has missed a payment yet.
</p>
</section>
<div className="overflow-x-auto max-h-[560px] overflow-y-auto">
<table className="ul-table">
<thead className="sticky top-0 z-10">
<tr>
<th>Enterprise</th>
<th>Sector</th>
<th>Block</th>
<th className="text-right">Score</th>
<th>Status</th>
<th>6-month trend</th>
<th />
</tr>
</thead>
<tbody>
{rows.slice(0, 40).map((e) => (
<tr
key={e.enterpriseId}
onClick={() => setSelectedId(e.enterpriseId)}
className={`cursor-pointer ${e.enterpriseId === selected?.enterpriseId ? '!bg-green-soft' : ''}`}
>
<td>
<div className="flex items-center gap-2.5">
<span
className={`w-6 h-6 rounded-full grid place-items-center text-[10px] font-bold ${
e.band === 'RED' ? 'band-red' : e.band === 'WATCH' ? 'band-watch' : 'band-standard'
}`}
>
{e.name[0]}
</span>
<span className="leading-tight">
<span className="block font-semibold text-slate-700">{e.name}</span>
<span className="block font-mono text-[10px] text-slate-400">{e.enterpriseId}</span>
</span>
</div>
</td>
<td className="text-slate-600">{SECTORS[e.sector].short}</td>
<td className="text-slate-600">{e.block}</td>
<td
className={`text-right font-bold ${
e.band === 'RED' ? 'text-risk-red' : e.band === 'WATCH' ? 'text-amber' : 'text-green'
}`}
>
{e.score}
</td>
<td>
<RiskBadge band={e.band} />
</td>
<td>
<Sparkline values={e.trend} band={e.band} />
</td>
<td className="text-right">
<button
onClick={(ev) => {
ev.stopPropagation()
navigate(`/officer/enterprises/${e.enterpriseId}`)
}}
className="text-slate-400 hover:text-forest px-1"
aria-label={`Open ${e.name}`}
>
⋯
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{rows.length > 40 && (
<div className="px-4 py-2 border-t border-slate-100 text-[10.5px] text-slate-400">
Showing the 40 highest-risk of {rows.length} — the full register is under Enterprises.
</div>
)}
{/* ── context ───────────────────────────────────────── */}
<div className="gf-side">
<FreshnessCard />
<MarketCard />
<EngineCard scored={rows.length} />
</div>
{/* ── risk detail ─────────────────────────────────── */}
{selected && <RiskDetail enterprise={selected} onSchedule={() => setScheduling(selected)} />}
</div>
{/* ── the day's work ────────────────────────────────────── */}
<section className="gf-card">
<header className="gf-head">
<h2>Today’s list</h2>
<span>
{filter === 'ALL'
? 'Soonest breach first'
: `Filtered · ${BUCKETS.find((b) => b.key === filter)?.label}`}
{' · '}{worklist.length} of {rows.length}
</span>
<button
className="gf-link"
onClick={() =>
exportCsv(
`udyam-worklist-${block}-${month}.csv`,
['Enterprise', 'Sector', 'Score', 'Band', 'Breaches', 'Top driver'],
worklist.map((e) => [
e.name, SECTORS[e.sector]?.label ?? e.sector, e.score, e.band,
e.firstBreachMonth ?? 'never', e.topDriver ?? '',
]),
)
}
>
Export
</button>
</header>
{worklist.length === 0 && <p className="gf-empty">Nothing outstanding in this filter.</p>}
<ul className="gf-list">
{worklist.map((e) => {
const h = e.firstBreachHorizon
const when = e.firstBreachMonth
? monthRef(new Date(`${e.firstBreachMonth}-01T00:00:00`)).fullEn
: null
return (
<li key={e.enterpriseId} className="gf-item">
<button
className="gf-item-main"
onClick={() => navigate(`/officer/enterprises/${e.enterpriseId}`)}
>
<span
className={`gf-score ${
e.band === 'RED' ? 'is-red' : e.band === 'WATCH' ? 'is-watch' : 'is-ok'
}`}
>
<b className="num">{e.score}</b>
</span>
<span className="gf-item-body">
<span className="gf-item-top">
<b>{e.name}</b>
<span className="gf-meta">
{SECTORS[e.sector]?.short ?? e.sector} · {e.block}
</span>
{when ? (
<span className={`gf-when ${h != null && h <= 1 ? 'is-now' : ''}`}>
<CalendarClock size={12} />
breaches {when}
</span>
) : (
<span className="gf-when is-clear">downside covered</span>
)}
</span>
{/* The reason the model gave, in the officer's own words. */}
<span className="gf-why">
{e.topDriver ?? 'No reason code stored for this run.'}
</span>
</span>
</button>
<button className="gf-act" onClick={() => setScheduling(e)}>
Schedule visit
</button>
</li>
)
})}
</ul>
</section>
{scheduling && <ScheduleVisitModal enterprise={scheduling} onClose={() => setScheduling(null)} />}
</div>
</div>
)
}
/** Right-hand panel: score dial, top drivers, the reason sentence and two actions. */
function RiskDetail({ enterprise, onSchedule }: { enterprise: Enterprise; onSchedule: () => void }) {
const navigate = useNavigate()
const sub = subScoresFor(enterprise)
const reason = reasonCodesFor(enterprise)[0]
const points = forecastFor(enterprise)
const breach = breachPoint(points)
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
/* ------------------------------------------------------------------ */
/* Context cards — small, and each answers one question */
/* ------------------------------------------------------------------ */
function FreshnessCard() {
const { data } = useQuery({
queryKey: ['freshness'],
queryFn: () =>
get<{ districtName: string; marketStalenessDays: number | null; climateStalenessDays: number | null }[]>(
'/admin/ingestion/freshness',
),
})
const rows = data ?? []
const worst = (k: 'marketStalenessDays' | 'climateStalenessDays') => {
const ages = rows.map((r) => r[k]).filter((n): n is number => n != null)
return ages.length ? Math.max(...ages) : null
}
const mandi = worst('marketStalenessDays')
const climate = worst('climateStalenessDays')
return (
<div className="w-full xl:w-[320px] shrink-0 bg-white border border-slate-200 rounded-[11px] overflow-hidden">
<div className="px-4 py-2.5 border-b border-slate-200">
<h3 className="text-[12.5px] font-bold text-forest">Risk detail</h3>
<div className="text-[10.5px] text-slate-500">
{enterprise.name} · {enterprise.enterpriseId}
<section className="gf-card gf-mini">
<header className="gf-head">
<h2><Database size={13} /> Data freshness</h2>
</header>
<dl className="gf-kv">
<div>
<dt>Mandi prices</dt>
<dd className={mandi != null && mandi > 10 ? 'is-stale' : ''}>{mandi ?? '—'}d old</dd>
</div>
</div>
<div className="p-4">
<div className="grid place-items-center mb-4">
<ScoreDial score={enterprise.score} band={enterprise.band} size={116} />
<div>
<dt>Climate</dt>
<dd className={climate != null && climate > 10 ? 'is-stale' : ''}>{climate ?? '—'}d old</dd>
</div>
<div><dt>Districts</dt><dd>{rows.length}</dd></div>
</dl>
<p className="gf-note gf-note-sm">A trip-wire refuses to fire on data older than its tolerance.</p>
</section>
)
}
<div className="font-mono text-[9.5px] tracking-[0.12em] text-slate-400 mb-2">TOP DRIVERS</div>
<div className="space-y-2 mb-4">
{DRIVER_META.map((d) => (
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
))}
</div>
function MarketCard() {
const marketQ = useQuery({
queryKey: ['market-signals'],
queryFn: () => get<MarketSignal[]>('/admin/ingestion/signals/market', { weeks: 8 }),
})
const climateQ = useQuery({
queryKey: ['climate-signals'],
queryFn: () => get<ClimateSignal[]>('/admin/ingestion/signals/climate', { weeks: 8 }),
})
const market = marketQ.data ?? demoMarketSignals()
const climate = climateQ.data ?? demoClimateSignals()
<div className="bg-risk-soft border border-risk-red/15 rounded-lg px-3 py-2.5 mb-4">
<p className="text-[11.5px] text-slate-700 leading-relaxed">
{reason.sentenceEn}
{breachRef && ` Shortfall expected by ${breachRef.fullEn}.`}
</p>
{breach && (
<p className="text-[10.5px] text-risk-red font-semibold mt-1.5">
Projected gap {inr(breach.obligation - breach.p10)} at P10
</p>
)}
</div>
const latest = new Map<string, MarketSignal>()
for (const r of market) {
const held = latest.get(r.commodityName)
if (!held || r.weekStart > held.weekStart) latest.set(r.commodityName, r)
}
const movers = Array.from(latest.values())
.map((m) => {
const hist = market
.filter((r) => r.commodityName === m.commodityName && r.weekStart < m.weekStart)
.map((r) => r.modalPrice)
.filter((p): p is number => p != null)
.slice(0, 8)
const price = m.modalPrice ?? 0
const mean = hist.length ? hist.reduce((s, p) => s + p, 0) / hist.length : price
return { name: m.commodityName, price, pct: mean ? ((price - mean) / mean) * 100 : 0 }
})
.sort((a, b) => Math.abs(b.pct) - Math.abs(a.pct))
.slice(0, 2)
<div className="flex gap-2">
<PrimaryBtn className="flex-1" onClick={onSchedule}>
Schedule visit
</PrimaryBtn>
<GhostBtn className="flex-1" onClick={() => navigate(`/officer/enterprises/${enterprise.enterpriseId}`)}>
Full forecast
</GhostBtn>
</div>
</div>
</div>
const c = climate.length ? climate.reduce((a, b) => (a.weekStart > b.weekStart ? a : b)) : null
return (
<section className="gf-card gf-mini">
<header className="gf-head">
<h2><CloudRain size={13} /> Market &amp; climate</h2>
</header>
<dl className="gf-kv">
{c && (
<div>
<dt>Rain vs normal</dt>
<dd className={Math.abs(c.rainfallAnomalyPct ?? 0) > 25 ? 'is-stale' : ''}>
{(c.rainfallAnomalyPct ?? 0) > 0 ? '+' : ''}
{Math.round(c.rainfallAnomalyPct ?? 0)}%
</dd>
</div>
)}
{movers.map((m) => (
<div key={m.name}>
<dt>{m.name.toLowerCase()}</dt>
<dd className={Math.abs(m.pct) > 12 ? 'is-stale' : ''}>
₹{Math.round(m.price).toLocaleString('en-IN')}
<small>{m.pct > 0 ? ' +' : ' '}{m.pct.toFixed(0)}%</small>
</dd>
</div>
))}
</dl>
</section>
)
}
function EngineCard({ scored }: { scored: number }) {
const { data } = useQuery({
queryKey: ['model-metrics'],
queryFn: () =>
get<{ available: boolean; modelVersion?: string; arms?: Record<string, { baseline: boolean; auc_roc?: number }> }>(
'/admin/model/metrics',
),
})
const auc = data?.arms
? Object.entries(data.arms)
.filter(([, a]) => !a.baseline)
.map(([, a]) => a.auc_roc ?? 0)
.sort((a, b) => b - a)[0]
: undefined
return (
<section className="gf-card gf-mini">
<header className="gf-head">
<h2><Siren size={13} /> Scoring engine</h2>
</header>
<dl className="gf-kv">
<div><dt>Engine</dt><dd className="is-live">{data?.modelVersion ?? 'model'}</dd></div>
<div><dt>Scored</dt><dd>{scored}</dd></div>
<div><dt>Measured AUC</dt><dd>{auc ? auc.toFixed(3) : '—'}</dd></div>
</dl>
<p className="gf-note gf-note-sm">Labels are generated history — calibration pending.</p>
</section>
)
}