Files
udyamlinc/src/features/officer/OfficerDashboard.tsx
T
LincPayandClaude Opus 5 1ffc1cbd4c 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>
2026-08-25 13:45:48 +05:30

392 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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 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 [filter, setFilter] = useState<Bucket | 'ALL'>('ALL')
const [scheduling, setScheduling] = useState<Enterprise | null>(null)
const { data, isError, refetch } = useQuery({
queryKey: ['portfolio', block, month],
queryFn: () => get<PortfolioSummary>('/officer/portfolio', { block, month }),
})
// 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 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])
// 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>
<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="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>
{/* ── context ───────────────────────────────────────── */}
<div className="gf-side">
<FreshnessCard />
<MarketCard />
<EngineCard scored={rows.length} />
</div>
</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>
)
}
/* ------------------------------------------------------------------ */
/* 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 (
<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>
<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>
)
}
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()
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)
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>
)
}