/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>
392 lines
15 KiB
TypeScript
392 lines
15 KiB
TypeScript
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 & 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>
|
||
)
|
||
}
|