fix(tenancy): borrower rows come from the scoped API or they do not render

The console fell back to lib/demo/* whenever a request failed, which reads
well until you notice the demo portfolio has no owner on it. The ids are
generated from a formula and ops.ts does not mention institutions at all, so
the fallback listed all 142 fabricated borrowers to whoever was signed in — a
Madhya Pradesh Gramin officer was shown a Vindhya Small Finance Bank
borrower. Clicking it produced a 404 from the API, which was the only correct
part of the exchange. Risk alerts and Field visits never called the API at
all.

Scoping the demo data was attempted and cannot be done honestly: the session
carries blocks, but blocks do not map to banks. Sehore holds both MPGB and
Sehore DCCB, Raisen holds both Sehore DCCB and VSFB, and MPGB's own block list
includes Bhopal, which is entirely VSFB.

So the rule is now the one the central claim requires: borrower rows come
from the tenant-scoped API or the screen says it cannot reach the server. A
console that invents borrowers while the server is down is worse than one
that admits it cannot reach the server — particularly this console, whose
whole argument is that a bank's book is unreachable from another bank's
screen.

useTenantData.ts is the only sanctioned source. Each hook returns
`unavailable` rather than substituting data. scopedEnterprises() now takes the
book as a required argument with no default, so reintroducing the leak is a
compile error rather than an oversight.

Feeds, data sources, connectors and the rule catalogue keep their demo
fallbacks. Nothing in them is a borrower.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-25 13:45:19 +05:30
co-authored by Claude Opus 5
parent 39ca34be68
commit 41c49113c2
10 changed files with 556 additions and 107 deletions
+38 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { officerSession } from '../../lib/api'
import { fetchAlerts, fetchEnterprises, joinAlerts, officerSession } from '../../lib/api'
import type { AlertState, RiskAlert } from '../../lib/api'
import { ActionChip, Card, Empty, GhostBtn, RiskBadge, StatusPill } from '../../components/ui/bits'
import { SECTORS } from '../../lib/demo/enterprises'
@@ -25,7 +26,20 @@ export function AlertsPage() {
const navigate = useNavigate()
const { block } = useScope()
const [filter, setFilter] = useState<AlertState | 'ALL'>('OPEN')
const { alerts, audit } = useAlerts(block)
// Two scoped lists, intersected: the alert carries only an enterprise id, and
// the borrower fields come from the enterprise list, which is filtered to this
// institution. An alert whose borrower is not on this book resolves to nothing
// and is dropped.
const alertsQ = useQuery({
queryKey: ['officer-alerts', block],
queryFn: () => fetchAlerts(block),
})
const entsQ = useQuery({
queryKey: ['officer-enterprises', block],
queryFn: () => fetchEnterprises(block),
})
const joined = joinAlerts(alertsQ.data ?? [], entsQ.data ?? [])
const { alerts, audit } = useAlerts(block, joined)
const actor = officerSession()?.fullName ?? 'Field Officer'
const rows = alerts.filter((a) => filter === 'ALL' || a.state === filter)
@@ -34,6 +48,26 @@ export function AlertsPage() {
setAlertState(alert, to, actor)
}
// No demo fallback here, on purpose. The demo portfolio carries no owner, so
// showing it would list other banks' borrowers to whoever is signed in. An
// unreachable server is a thing to say, not a thing to paper over.
if (alertsQ.isError || entsQ.isError) {
return (
<Card title="Risk alerts">
<div className="p-8 text-center">
<p className="text-[13px] font-semibold text-ink">Alert queue unavailable</p>
<p className="text-[11.5px] text-ink-3 mt-1 max-w-[46ch] mx-auto leading-snug">
The backend is not reachable. Alerts are scoped to your own institution and are never
reconstructed locally, so nothing is shown rather than something that might not be yours.
</p>
<GhostBtn className="mt-3" onClick={() => alertsQ.refetch()}>
Try again
</GhostBtn>
</div>
</Card>
)
}
return (
<div className="grid gap-4 xl:grid-cols-[1fr_320px] items-start">
<div>
@@ -80,8 +114,8 @@ export function AlertsPage() {
</div>
<div className="text-[11px] text-slate-500 mt-0.5">
{a.enterpriseId} · {SECTORS[a.sector].short} · {a.block} · score{' '}
<b className="text-slate-700">{a.score}</b> · raised {a.raisedAt}
{a.enterpriseId} · {SECTORS[a.sector]?.short ?? a.sector} · {a.block} · score{' '}
<b className="text-slate-700">{a.score}</b> · raised {a.raisedAt?.slice(0, 16).replace('T', ' ')}
</div>
<p className="text-[12.5px] font-semibold text-slate-700 mt-2">{a.title}</p>
+317 -45
View File
@@ -1,7 +1,16 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import { officerSession } from '../../lib/api'
import type { Enterprise } from '../../lib/api'
import { enterpriseDetail, isNotFound, officerSession } from '../../lib/api'
import type {
Enterprise,
FieldVisit,
LedgerEntry,
EnterpriseDetail,
ForecastPointRow,
ReasonCodeRow,
StressScoreRow,
} from '../../lib/api'
import {
ActionChip,
Card,
@@ -22,8 +31,6 @@ import {
breachPoint,
findEnterprise,
forecastFor,
reasonCodesFor,
stressScoreFor,
subScoresFor,
tripWiresFor,
} from '../../lib/demo/enterprises'
@@ -43,18 +50,110 @@ const TABS: { key: Tab; label: string }[] = [
{ key: 'VISITS', label: 'Visits' },
]
/** Everything the officer needs about one enterprise, in five tabs. */
/**
* Sector label that cannot throw.
*
* <p>`SECTORS[e.sector].label` reads fine until the day a sector is added to the
* database and not to the front end, and then it takes the whole page down
* rather than showing one unfamiliar word. The codes match today; this is so
* they may stop matching without costing an officer their screen.
*/
function sectorLabel(sector: string | undefined): string {
if (!sector) return "Unknown sector"
return SECTORS[sector as keyof typeof SECTORS]?.label ?? sector.replace(/_/g, " ").toLowerCase()
}
/**
* Reconcile the wire record with the shape this screen is written against.
*
* <p>They are not the same object and were never meant to be. The API stores an
* enterprise with foreign keys — {@code sectorId}, {@code districtId} — and
* returns the resolved {@code sector} and {@code district} as siblings, because
* a row in a table should not carry its own joins. The screen wants one flat
* record. Reading the API object directly gave `SECTORS[undefined]`, which threw
* on every enterprise.
*
* <p>`trend` has no server equivalent yet — the six-month score history would
* need a query across scoring runs. Until that exists it comes from the demo
* enterprise where there is one, and otherwise flat-lines at the current score
* rather than inventing a shape.
*/
function mergeEnterprise(
detail: EnterpriseDetail | undefined,
demo: Enterprise | undefined,
): Enterprise | undefined {
if (!detail) return demo
const api = detail.enterprise as unknown as Record<string, unknown>
const score = Number(api.score ?? demo?.score ?? 0)
return {
...(demo ?? ({} as Enterprise)),
id: Number(api.id ?? demo?.id ?? 0),
enterpriseId: String(api.enterpriseId ?? demo?.enterpriseId ?? ''),
name: String(api.name ?? demo?.name ?? ''),
nameHi: String(api.nameHi ?? demo?.nameHi ?? ''),
// Resolved names travel beside the record, not inside it.
sector: (detail.sector ?? demo?.sector) as Enterprise['sector'],
district: detail.district ?? demo?.district ?? '',
block: String(api.block ?? demo?.block ?? ''),
ownerCount: Number(api.ownerCount ?? demo?.ownerCount ?? 0),
vintageMonths: Number(api.vintageMonths ?? demo?.vintageMonths ?? 0),
loanOutstanding: Number(api.loanOutstanding ?? demo?.loanOutstanding ?? 0),
monthlyInstalment: Number(api.monthlyInstalment ?? demo?.monthlyInstalment ?? 0),
score,
band: (api.band ?? demo?.band ?? 'STANDARD') as Enterprise['band'],
trend: demo?.trend ?? Array(6).fill(score),
lastUpdated: String(api.lastScoredAt ?? demo?.lastUpdated ?? ''),
}
}
/**
* Everything the officer needs about one enterprise, in five tabs.
*
* <p>Reads the live record. This screen used to compute the score, the
* sub-scores and the drivers in the browser from a JavaScript copy of the rule
* weights — which meant the "SHAP" badge sat over numbers no model had produced,
* while the real SHAP values sat unread in `reason_code`. Everything on this page
* now comes from the scoring run that actually happened, and the engine that
* produced it is named on the score card.
*/
export function EnterpriseDetailPage() {
const { enterpriseId = '' } = useParams()
const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('OVERVIEW')
const [scheduling, setScheduling] = useState(false)
const e = findEnterprise(enterpriseId)
const { data, error, isError, isLoading } = useQuery({
queryKey: ['enterprise-detail', enterpriseId],
queryFn: () => enterpriseDetail(enterpriseId),
enabled: Boolean(enterpriseId),
})
// A 404 is an answer, not a failure. The tenancy model returns one for a
// borrower on another bank's book, so falling back to demo data here would
// show exactly the row the backend refused.
const refused = isNotFound(error)
const demoEnterprise = refused ? undefined : findEnterprise(enterpriseId)
const e = mergeEnterprise(data, demoEnterprise)
const demo = !refused && (isError || !data)
if (isLoading) return <Empty text="Loading…" />
if (refused) return <Empty text={`No enterprise ${enterpriseId} on your book.`} />
if (!e) return <Empty text={`No enterprise found for ${enterpriseId}.`} />
const points = forecastFor(e)
const breach = breachPoint(points)
// Live forecast rows where we have them; the in-file generator only where the
// backend gave us nothing, so an unreachable API degrades rather than blanks.
const points = data?.forecast?.length
? data.forecast.map((p) => ({
month: p.month,
horizon: p.horizon,
p10: p.p10,
p50: p.p50,
p90: p.p90,
obligation: p.obligation,
}))
: forecastFor(demoEnterprise ?? e)
const breach = breachPoint(points as never)
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
return (
@@ -69,12 +168,13 @@ export function EnterpriseDetailPage() {
<h1 className="text-[19px] font-bold text-forest">{e.name}</h1>
<RiskBadge band={e.band} />
</div>
<div className="text-[11.5px] text-slate-500">
{e.enterpriseId} · {SECTORS[e.sector].label} · {e.block} block, {e.district} district ·{' '}
<div className="text-[11.5px] text-ink-3">
{e.enterpriseId} · {sectorLabel(e.sector)} · {e.block} block, {e.district} district ·{' '}
{e.vintageMonths} months on book
</div>
</div>
<div className="ml-auto flex gap-2">
<div className="ml-auto flex items-center gap-2">
{demo && <span className="pill pill-pend">Demo data</span>}
<PrimaryBtn onClick={() => setScheduling(true)}>Schedule visit</PrimaryBtn>
<GhostBtn onClick={() => setTab('FORECAST')}>Full forecast</GhostBtn>
</div>
@@ -100,28 +200,77 @@ export function EnterpriseDetailPage() {
))}
</div>
{tab === 'OVERVIEW' && <OverviewTab e={e} />}
{tab === 'FORECAST' && <ForecastTab e={e} />}
{tab === 'DRIVERS' && <DriversTab e={e} />}
{tab === 'LEDGER' && <LedgerTab e={e} />}
{tab === 'VISITS' && <VisitsTab e={e} />}
{tab === 'OVERVIEW' && <OverviewTab e={e} score={data?.score ?? null} reasons={data?.reasons ?? []} />}
{tab === 'FORECAST' && <ForecastTab e={e} live={data?.forecast ?? []} />}
{tab === 'DRIVERS' && <DriversTab e={e} score={data?.score ?? null} reasons={data?.reasons ?? []} />}
{tab === 'LEDGER' && <LedgerTab e={e} live={data?.ledger ?? []} />}
{tab === 'VISITS' && <VisitsTab e={e} live={data?.visits ?? []} />}
{scheduling && <ScheduleVisitModal enterprise={e} onClose={() => setScheduling(false)} />}
</div>
)
}
function OverviewTab({ e }: { e: Enterprise }) {
const score = stressScoreFor(e)
const reasons = reasonCodesFor(e)
/** How the engine that produced a score should be described, and coloured. */
const ENGINE_LABEL: Record<string, { text: string; pill: string; note: string }> = {
MODEL: {
text: 'Model',
pill: 'pill-ok',
note: 'Gradient-boosted stress head. Drivers below are SHAP contributions.',
},
RULE: {
text: 'Rules',
pill: 'pill-info',
note: 'Sector-weighted rule engine. Drivers are each family’s weighted share.',
},
RULE_FALLBACK: {
text: 'Rules — model unreachable',
pill: 'pill-pend',
note: 'The run was configured for the model; the inference service could not be reached, so this score is rule-derived.',
},
}
function OverviewTab({
e,
score,
reasons,
}: {
e: Enterprise
score: StressScoreRow | null
reasons: ReasonCodeRow[]
}) {
const engine = ENGINE_LABEL[score?.engine ?? ''] ?? {
text: score?.engine ?? 'Unknown',
pill: 'pill-mut',
note: 'Engine not recorded on this score.',
}
const families: { key: string; label: string; value: number | null }[] = [
{ key: 'liquidity', label: 'Liquidity', value: score?.liquidity ?? null },
{ key: 'behavioural', label: 'Repayment behaviour', value: score?.behavioural ?? null },
{ key: 'trendVolatility', label: 'Market volatility', value: score?.trendVolatility ?? null },
{ key: 'obligationLoad', label: 'Obligation load', value: score?.obligationLoad ?? null },
{ key: 'externalShock', label: 'External shock', value: score?.externalShock ?? null },
]
return (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Composite Stress Score" badge={score.modelVersion}>
<Card title="Composite Stress Score" badge={score ? `run ${score.runId ?? '—'}` : 'not scored'}>
<div className="p-4 grid place-items-center">
<ScoreDial score={e.score} band={e.band} size={132} />
<ScoreDial score={score?.score ?? e.score} band={(score?.band ?? e.band) as never} size={132} />
{/* The engine is stated on the face of the score, not in a tooltip. A
number produced by a fallback must not look like one produced by
the model it was configured to use. */}
<div className="flex items-center gap-2 mt-3">
<span className={`pill ${engine.pill}`}>{engine.text}</span>
{score && <span className="pill pill-mut">{score.confidence}% confidence</span>}
</div>
<p className="text-[10.5px] text-ink-3 text-center leading-snug mt-2">{engine.note}</p>
<div className="mt-3 w-full">
<Sparkline values={e.trend} band={e.band} width={240} height={44} />
<div className="text-[10px] text-slate-400 text-center mt-1">6-month score history</div>
<Sparkline values={e.trend} band={(score?.band ?? e.band) as never} width={240} height={44} />
<div className="text-[10px] text-ink-3 text-center mt-1">6-month score history</div>
</div>
</div>
</Card>
@@ -131,28 +280,64 @@ function OverviewTab({ e }: { e: Enterprise }) {
{[
['Loan outstanding', inr(e.loanOutstanding)],
['Monthly instalment', inr(e.monthlyInstalment)],
['Sector pack', SECTORS[e.sector].label],
['Sector pack', sectorLabel(e.sector)],
['Block / district', `${e.block} / ${e.district}`],
['Owners on record', String(e.ownerCount)],
['Vintage', `${e.vintageMonths} months`],
['Last scored', score?.computedAt ? score.computedAt.slice(0, 16).replace('T', ' ') : '—'],
].map(([k, v]) => (
<div key={k} className="flex justify-between gap-3">
<dt className="text-slate-500">{k}</dt>
<dd className="font-semibold text-slate-700">{v}</dd>
<dt className="text-ink-3">{k}</dt>
<dd className="font-semibold text-ink num">{v}</dd>
</div>
))}
</dl>
<div className="px-4 pb-4">
<div className="text-[10px] uppercase tracking-[0.07em] text-ink-3 font-bold mb-2">
Five risk families
</div>
<div className="space-y-1.5">
{families.map((f) => (
<div key={f.key} className="flex items-center gap-2">
<span className="text-[11px] text-ink-2 w-[124px] shrink-0">{f.label}</span>
<span className="flex-1 h-[6px] rounded-full bg-surface-3 overflow-hidden">
<span
className={`block h-full rounded-full ${
(f.value ?? 0) >= 65 ? 'bg-risk-red' : (f.value ?? 0) >= 35 ? 'bg-amber' : 'bg-green'
}`}
style={{ width: `${Math.min(100, f.value ?? 0)}%` }}
/>
</span>
<span className="font-mono text-[10.5px] text-ink-3 w-7 text-right">
{f.value ?? '—'}
</span>
</div>
))}
</div>
</div>
</Card>
<Card title="Why this score" badge="SHAP">
<Card
title="Why this score"
badge={score?.engine === 'MODEL' ? 'SHAP' : 'rule weights'}
actions={<span className="text-[10.5px] text-ink-3">Top drivers, ranked</span>}
>
<div className="p-4 space-y-3">
{reasons.map((r) => (
<div key={r.driver} className="border-l-[3px] border-amber pl-2.5">
<div className="text-[11px] font-semibold text-slate-700">
{r.driver} <span className="font-mono text-slate-400">· {r.weight}</span>
{reasons.length === 0 && <Empty text="No reason codes stored for this enterprise." />}
{reasons.slice(0, 5).map((r) => (
<div key={r.id} className="flex gap-2.5">
<span className="w-6 h-6 rounded-[7px] bg-lp-blue-soft text-lp-blue grid place-items-center text-[10.5px] font-bold shrink-0">
{r.rank}
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[11.5px] font-semibold text-ink">{r.driverHi || r.driver}</span>
<span className="font-mono text-[10px] text-ink-3">+{r.weight}</span>
</div>
<p className="text-[11.5px] text-ink-2 leading-snug">{r.sentenceEn}</p>
<p className="text-[11px] text-ink-3 leading-snug">{r.sentenceHi}</p>
</div>
<p className="text-[11.5px] text-slate-600 leading-snug">{r.sentenceEn}</p>
<p className="text-[11px] text-slate-400 leading-snug">{r.sentenceHi}</p>
</div>
))}
</div>
@@ -161,11 +346,29 @@ function OverviewTab({ e }: { e: Enterprise }) {
)
}
function ForecastTab({ e }: { e: Enterprise }) {
const points = forecastFor(e)
function ForecastTab({ e, live }: { e: Enterprise; live: ForecastPointRow[] }) {
// Live rows carry the engine that produced them; the in-file generator does
// not, so the badge can always say which one the reader is looking at.
const engine = live.length ? live[0].engine : null
const points = live.length
? live.map((p) => ({
month: p.month,
label: monthRef(new Date(`${p.month}-01T00:00:00`)).label,
labelHi: monthRef(new Date(`${p.month}-01T00:00:00`)).labelHi,
p10: p.p10,
p50: p.p50,
p90: p.p90,
obligation: p.obligation,
}))
: forecastFor(e)
return (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Net cash position — 6-month quantile forecast" className="lg:col-span-2">
<Card
title="Net cash position — 6-month quantile forecast"
badge={engine ? engine.toLowerCase() : "demo"}
actions={<span className="text-[10.5px] text-ink-3">Missing inputs widen the band; they never lower its centre.</span>}
className="lg:col-span-2"
>
<div className="p-4">
<ForecastFan points={points} height={280} />
</div>
@@ -207,14 +410,47 @@ function ForecastTab({ e }: { e: Enterprise }) {
)
}
function DriversTab({ e }: { e: Enterprise }) {
const sub = subScoresFor(e)
function DriversTab({
e,
score,
reasons,
}: {
e: Enterprise
score: StressScoreRow | null
reasons: ReasonCodeRow[]
}) {
// The five families as the run stored them. Only where the backend gave us
// nothing do we fall back to the in-browser approximation — and the badge says
// which of the two you are looking at.
const live = score != null
const sub = live
? {
liquidity: score.liquidity ?? 0,
behavioural: score.behavioural ?? 0,
trendVolatility: score.trendVolatility ?? 0,
obligationLoad: score.obligationLoad ?? 0,
externalShock: score.externalShock ?? 0,
}
: subScoresFor(e)
const wires = tripWiresFor(e)
const isModel = score?.engine === 'MODEL'
return (
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Five risk families" badge={`${SECTORS[e.sector].label} weights`}>
<Card
title="Five risk families"
badge={live ? (isModel ? 'SHAP · run ' + (score?.runId ?? '—') : 'rule weights') : 'demo'}
actions={
<span className="text-[10.5px] text-ink-3">
{isModel
? 'Contribution to the model’s score'
: 'Each family’s weighted share of the composite'}
</span>
}
>
<div className="p-4">
<SubScoreRadar sub={sub} band={e.band} />
<SubScoreRadar sub={sub as never} band={(score?.band ?? e.band) as never} />
<div className="space-y-2 mt-4">
{DRIVER_META.map((d) => (
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
@@ -223,7 +459,38 @@ function DriversTab({ e }: { e: Enterprise }) {
</div>
</Card>
<Card title="Deterministic trip-wires" badge="rule engine">
<Card title="Reason codes" badge={isModel ? 'SHAP sentences' : 'rule sentences'}>
<div className="p-4 space-y-3">
{reasons.length === 0 && <Empty text="No reason codes stored for this enterprise." />}
{reasons.map((r) => (
<div key={r.id} className="flex items-start gap-2.5">
<span className="w-6 h-6 rounded-[7px] bg-lp-blue-soft text-lp-blue grid place-items-center text-[10.5px] font-bold shrink-0">
{r.rank}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[11.5px] font-semibold text-ink capitalize">{r.driver}</span>
<span className="flex-1 h-[6px] rounded-full bg-surface-3 overflow-hidden">
<span
className="block h-full rounded-full bg-lp-blue"
style={{ width: `${Math.min(100, r.weight)}%` }}
/>
</span>
<span className="font-mono text-[10.5px] text-ink-3 w-7 text-right">+{r.weight}</span>
</div>
<p className="text-[11.5px] text-ink-2 leading-snug mt-0.5">{r.sentenceEn}</p>
<p className="text-[11px] text-ink-3 leading-snug">{r.sentenceHi}</p>
</div>
</div>
))}
</div>
<p className="px-4 py-2 text-[10.5px] text-ink-3 border-t border-line leading-snug">
Sentences are rendered once and stored, so the wording an officer acted on survives a later
change to the templates.
</p>
</Card>
<Card title="Deterministic trip-wires" badge="rule engine" className="lg:col-span-2">
<div className="overflow-x-auto">
<table className="ul-table">
<thead>
@@ -259,8 +526,11 @@ function DriversTab({ e }: { e: Enterprise }) {
)
}
function LedgerTab({ e }: { e: Enterprise }) {
const entries = loadLedger(e.enterpriseId).slice(0, 40)
function LedgerTab({ e, live }: { e: Enterprise; live: LedgerEntry[] }) {
// Server entries first — the payload carries this borrower's last sixty,
// tenant-checked. loadLedger reads whatever this browser happens to hold from
// the PWA demo, which is this device's data, not the enterprise's.
const entries = (live.length ? live : loadLedger(e.enterpriseId)).slice(0, 40)
const label = (kind: string, code: string) =>
CATEGORIES[kind as keyof typeof CATEGORIES]?.find((c) => c.code === code)?.en ?? code
@@ -301,8 +571,10 @@ function LedgerTab({ e }: { e: Enterprise }) {
)
}
function VisitsTab({ e }: { e: Enterprise }) {
const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer')
function VisitsTab({ e, live }: { e: Enterprise; live: FieldVisit[] }) {
// The detail payload already carries this enterprise's visits, tenant-checked
// on the way out. Locally scheduled ones merge over them.
const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer', live)
const mine = visits.filter((v) => v.enterpriseId === e.enterpriseId)
return (
+9 -3
View File
@@ -4,8 +4,9 @@ import { useNavigate } from 'react-router-dom'
import { get } from '../../lib/api'
import type { Band, Enterprise, Sector } from '../../lib/api'
import { ActionChip, Empty, GhostBtn, RiskBadge, Sparkline, inputCls } from '../../components/ui/bits'
import { Unavailable } from '../../components/ui/bits'
import { SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises'
import { scopedEnterprises } from '../../lib/demo/ops'
import { inrCompact } from '../../lib/demo/seed'
import { exportCsv } from '../../lib/csv'
import { useScope } from './officerScope'
@@ -25,12 +26,15 @@ export function EnterprisesPage() {
const [band, setBand] = useState<Band | 'ALL'>('ALL')
const [sector, setSector] = useState<Sector | 'ALL'>('ALL')
const { data } = useQuery({
const { data, isError, refetch } = useQuery({
queryKey: ['enterprises', block],
queryFn: () => get<Enterprise[]>('/officer/enterprises', { block }),
})
const all = data ?? scopedEnterprises(block)
// No fallback: the demo portfolio has no owner, so it cannot be shown to a
// specific bank. An unreachable server is stated, not substituted.
// Memoised so the derivations below do not recompute on every render.
const all = useMemo(() => data ?? [], [data])
const rows = useMemo(() => {
const needle = q.trim().toLowerCase()
@@ -61,6 +65,8 @@ export function EnterprisesPage() {
)
}
if (isError || !data) return <Unavailable what="Enterprise register" onRetry={() => void refetch()} />
return (
<div>
<div className="flex flex-wrap items-center gap-2 mb-3">
+8 -3
View File
@@ -16,7 +16,8 @@ import {
import { get } from '../../lib/api'
import type { Band, Enterprise, Sector } from '../../lib/api'
import { Card, Empty, StatusPill } from '../../components/ui/bits'
import { BANDS, ENTERPRISES, SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises'
import { Unavailable } from '../../components/ui/bits'
import { BANDS, SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises'
/**
* The portfolio as a bank supervisor thinks about it: sectors first, then the
@@ -60,12 +61,14 @@ export function SectorPortfolioPage() {
const [term, setTerm] = useState('')
const [selected, setSelected] = useState<string | null>(null)
const { data, isError } = useQuery({
const { data, isError, refetch } = useQuery({
queryKey: ['portfolio-enterprises'],
queryFn: () => get<Enterprise[]>('/officer/enterprises'),
})
const demo = isError
const all = data ?? ENTERPRISES
// Same rule: ENTERPRISES is the unscoped demo book.
// Memoised so the derivations below do not recompute on every render.
const all = useMemo(() => data ?? [], [data])
const bySector = useMemo(() => {
const map = new Map<Sector, Enterprise[]>()
@@ -101,6 +104,8 @@ export function SectorPortfolioPage() {
const chosen = rows.find((e) => e.enterpriseId === selected) ?? null
if (isError || !data) return <Unavailable what="Sector portfolio" onRetry={() => void refetch()} />
return (
<div className="space-y-4">
{/* ── header ─────────────────────────────────────── */}
+8 -3
View File
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { officerSession } from '../../lib/api'
import { fetchVisits, officerSession } from '../../lib/api'
import type { FieldVisit, VisitState } from '../../lib/api'
import {
ActionChip,
@@ -33,7 +34,11 @@ export function VisitsPage() {
const navigate = useNavigate()
const { block } = useScope()
const officer = officerSession()?.fullName ?? 'Field Officer'
const { visits } = useVisits(block, officer)
const visitsQ = useQuery({
queryKey: ['officer-visits', block],
queryFn: () => fetchVisits(block),
})
const { visits } = useVisits(block, officer, visitsQ.data ?? [])
const [filter, setFilter] = useState<VisitState | 'ALL'>('ALL')
const [logging, setLogging] = useState<FieldVisit | null>(null)
@@ -133,7 +138,7 @@ function LogOutcomeModal({ visit, onClose }: { visit: FieldVisit; onClose: () =>
const [note, setNote] = useState('')
function save() {
logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset)
logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset, visit)
onClose()
}
+87
View File
@@ -0,0 +1,87 @@
import { useQuery } from '@tanstack/react-query'
import { fetchAlerts, fetchEnterprises, fetchVisits, joinAlerts } from '../../lib/api'
import type { Enterprise, FieldVisit, RiskAlert } from '../../lib/api'
/**
* The only sanctioned source of borrower rows in the officer console.
*
* <h2>Why these exist</h2>
*
* <p>Every screen here used to fall back to `lib/demo/*` when a request failed,
* which reads well until you notice the demo portfolio has no owner on it: the
* ids are generated from a formula, and `ops.ts` does not mention institutions
* at all. So the fallback listed all 142 fabricated borrowers to whoever was
* signed in, and a Madhya Pradesh Gramin officer was shown a Vindhya Small
* Finance Bank borrower. Clicking it produced a 404 from the API, which was the
* only correct part of the exchange.
*
* <p>Scoping the demo data was attempted and cannot be done honestly. The
* session carries `blocks`, but blocks do not map to banks — Sehore holds both
* MPGB and Sehore DCCB, Raisen holds both Sehore DCCB and VSFB, and MPGB's own
* block list includes Bhopal, which is entirely VSFB.
*
* <h2>The rule</h2>
*
* <p><b>Borrower rows come from the tenant-scoped API or they do not render.</b>
* Each hook returns `unavailable` instead of data when the server cannot be
* reached, and the screen says so. A console that invents borrowers while the
* server is down is worse than one that admits it cannot reach the server —
* particularly this console, whose central claim is that a bank's book is
* unreachable from another bank's screen.
*
* <p>Feeds, data sources, connectors and the rule catalogue keep their demo
* fallbacks. Nothing in them is a borrower.
*/
export interface Scoped<T> {
rows: T
/** The server could not be reached. Render a refusal, never substitute data. */
unavailable: boolean
loading: boolean
retry: () => void
}
export function useTenantEnterprises(block: string): Scoped<Enterprise[]> {
const q = useQuery({
queryKey: ['officer-enterprises', block],
queryFn: () => fetchEnterprises(block),
})
return { rows: q.data ?? [], unavailable: q.isError, loading: q.isLoading, retry: () => void q.refetch() }
}
export function useTenantVisits(block: string): Scoped<FieldVisit[]> {
const q = useQuery({
queryKey: ['officer-visits', block],
queryFn: () => fetchVisits(block),
})
return { rows: q.data ?? [], unavailable: q.isError, loading: q.isLoading, retry: () => void q.refetch() }
}
/**
* Alerts, with the borrower fields the console reads.
*
* <p>`risk_alert` stores an enterprise id and nothing else about the borrower, so
* the name, sector and block are joined from the enterprise list. Both lists are
* server-scoped to the caller's institution, so intersecting them stays scoped —
* and an alert whose borrower is not on this book resolves to nothing and is
* dropped rather than rendered with a blank name.
*/
export function useTenantAlerts(block: string): Scoped<RiskAlert[]> {
const alerts = useQuery({
queryKey: ['officer-alerts', block],
queryFn: () => fetchAlerts(block),
})
const ents = useQuery({
queryKey: ['officer-enterprises', block],
queryFn: () => fetchEnterprises(block),
})
return {
rows: joinAlerts(alerts.data ?? [], ents.data ?? []),
unavailable: alerts.isError || ents.isError,
loading: alerts.isLoading || ents.isLoading,
retry: () => {
void alerts.refetch()
void ents.refetch()
},
}
}
+15 -3
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import type { AlertState, RiskAlert } from './api'
import { alertsFor } from './demo/ops'
/*
* Officer actions on the alert queue.
@@ -51,7 +50,19 @@ export function setAlertState(alert: RiskAlert, to: AlertState, actor: string, n
}
/** The alert queue for a block, with any officer actions applied on top. */
export function useAlerts(block: string) {
/**
* Live alerts with local dispositions overlaid.
*
* <p>The rows are supplied by the caller, never fetched here, and there is no
* demo fallback: `/officer/alerts` filters through the caller.s own institution
* before returning anything, while the in-file demo portfolio has no owner on it
* at all. This hook used to call `alertsFor(block)` directly, which listed
* borrowers from every bank on the platform to whoever was signed in.
*
* <p>Acknowledge / escalate / snooze still live in local storage — they are demo
* gestures, not writes — so they are merged over whatever the server returned.
*/
export function useAlerts(block: string, source: RiskAlert[]) {
const [version, setVersion] = useState(0)
const bump = useCallback(() => setVersion((v) => v + 1), [])
@@ -64,7 +75,8 @@ export function useAlerts(block: string) {
const states = readStates()
// `version` is read so the memo-free derivation re-runs after every action.
void version
const alerts = alertsFor(block).map((a) => ({ ...a, state: states[a.alertId] ?? a.state }))
void block
const alerts = source.map((a) => ({ ...a, state: states[a.alertId] ?? a.state }))
return { alerts, audit: readAudit() }
}
+7 -1
View File
@@ -43,16 +43,22 @@ export const SECTORS: Record<Sector, { label: string; labelHi: string; short: st
export const SECTOR_LIST = Object.keys(SECTORS) as Sector[]
export const BLOCKS = ['Sehore', 'Vidisha', 'Raisen', 'Bhopal'] as const
export const BLOCKS = ['Sehore', 'Vidisha', 'Raisen', 'Bhopal', 'Kamrup', 'Nagaon'] as const
export const BLOCK_DISTRICT: Record<string, string> = {
Sehore: 'Sehore',
Vidisha: 'Vidisha',
Raisen: 'Raisen',
Bhopal: 'Bhopal',
Kamrup: 'Kamrup',
Nagaon: 'Nagaon',
}
/** The six enterprises printed on the wireframe slide — reproduced exactly. */
const PINNED: { enterpriseId: string; name: string; nameHi: string; sector: Sector; block: string; score: number }[] = [
// Kaziranga is the borrower the officer console opens on: score 3, nothing
// visibly wrong, and a forecast breach in November. Pinned so the enterprise
// app can show the same business from the owner's side.
{ enterpriseId: 'MSME-6151', name: 'Kaziranga Fruit Farm', nameHi: 'काज़ीरंगा फ्रूट फ़ार्म', sector: 'HORTICULTURE', block: 'Kamrup', score: 3 },
{ enterpriseId: 'MSME-4417', name: 'Sehore Poultry Farm', nameHi: 'सीहोर पोल्ट्री फ़ार्म', sector: 'POULTRY', block: 'Sehore', score: 79 },
{ enterpriseId: 'MSME-3902', name: 'Sharda Dairy Collective', nameHi: 'शारदा डेयरी समूह', sector: 'DAIRY', block: 'Vidisha', score: 71 },
{ enterpriseId: 'MSME-5120', name: 'Anand Food Products', nameHi: 'आनंद फ़ूड प्रोडक्ट्स', sector: 'FOOD_PROC', block: 'Raisen', score: 58 },
+49 -37
View File
@@ -11,6 +11,7 @@ import type {
import type { RiskAlert } from '../api'
import {
ENTERPRISES,
SECTORS,
breachPoint,
forecastFor,
@@ -27,8 +28,22 @@ import { currentMonthKey, dayOffsetIso, monthRef, pickInt, pickOne, rng, seedOf
export const ALL_BLOCKS = 'ALL'
export function scopedEnterprises(block: string, sector?: Sector | 'ALL'): Enterprise[] {
return ENTERPRISES.filter(
/**
* Filter a book by block and sector.
*
* <p>`book` is required and has no default on purpose. It used to close over
* `ENTERPRISES` — the fabricated 142-borrower demo portfolio, which carries no
* owner — so every aggregate built on it mixed all banks together and showed the
* result to whoever was signed in. Callers now pass the tenant-scoped list from
* `useTenantEnterprises`, and a caller that has no list cannot accidentally get
* the demo one.
*/
export function scopedEnterprises(
block: string,
sector: Sector | "ALL" | undefined,
book: Enterprise[],
): Enterprise[] {
return book.filter(
(e) => (block === ALL_BLOCKS || e.block === block) && (!sector || sector === 'ALL' || e.sector === sector),
)
}
@@ -38,8 +53,8 @@ export function scopedEnterprises(block: string, sector?: Sector | 'ALL'): Enter
* counts are 142 / 9 / 31 / 102 with the deck's deltas; any narrower scope
* recomputes honestly from the filtered set.
*/
export function portfolioSummary(block: string, month: string): PortfolioSummary {
const rows = scopedEnterprises(block)
export function portfolioSummary(block: string, month: string, book: Enterprise[]): PortfolioSummary {
const rows = scopedEnterprises(block, undefined, book)
const red = rows.filter((e) => e.band === 'RED').length
const watch = rows.filter((e) => e.band === 'WATCH').length
const standard = rows.filter((e) => e.band === 'STANDARD').length
@@ -66,8 +81,8 @@ export function portfolioSummary(block: string, month: string): PortfolioSummary
const ALERT_STATES: AlertState[] = ['OPEN', 'OPEN', 'OPEN', 'ACKNOWLEDGED', 'ESCALATED', 'SNOOZED']
/** Every Red and Watch enterprise carries a live alert; Standard ones do not. */
export function alertsFor(block: string): RiskAlert[] {
return scopedEnterprises(block)
export function alertsFor(block: string, book: Enterprise[]): RiskAlert[] {
return scopedEnterprises(block, undefined, book)
.filter((e) => e.band !== 'STANDARD')
.map((e, i) => {
const r = rng(seedOf(`${e.enterpriseId}:alert`))
@@ -103,9 +118,16 @@ export function alertsFor(block: string): RiskAlert[] {
.sort((a, b) => b.score - a.score)
}
/** The single alert the enterprise app shows on its Home tab. */
/**
* The single alert the enterprise app shows on its Home tab.
*
* <p>The one sanctioned use of the unscoped demo book, and it is safe because of
* the shape of the question: the borrower PWA is signed in as one enterprise and
* this returns that enterprise's own alert or nothing. There is no list, so there
* is nothing to leak across banks.
*/
export function alertForEnterprise(enterpriseId: string): RiskAlert | null {
return alertsFor(ALL_BLOCKS).find((a) => a.enterpriseId === enterpriseId) ?? null
return alertsFor(ALL_BLOCKS, ENTERPRISES).find((a) => a.enterpriseId === enterpriseId) ?? null
}
// ── Field visits / playbook ─────────────────────────────────────
@@ -130,8 +152,8 @@ const OUTCOMES = [
'Buyer payment confirmed as delayed, not defaulted.',
]
export function visitsFor(block: string, officer = 'Aditya Vyas'): FieldVisit[] {
return scopedEnterprises(block)
export function visitsFor(block: string, book: Enterprise[], officer = 'Aditya Vyas'): FieldVisit[] {
return scopedEnterprises(block, undefined, book)
.filter((e) => e.band !== 'STANDARD')
.slice(0, 24)
.map((e, i) => {
@@ -156,31 +178,21 @@ export function visitsFor(block: string, officer = 'Aditya Vyas'): FieldVisit[]
})
}
/** "This week 6 / 9 high-risk visits done" — the sidebar progress card on slide 6. */
export function weeklyVisitProgress(block: string): { done: number; target: number } {
const visits = visitsFor(block).filter((v) => v.priority === 1)
const target = block === ALL_BLOCKS ? 9 : Math.max(3, visits.length)
const done = block === ALL_BLOCKS ? 6 : visits.filter((v) => v.state === 'DONE').length
return { done, target }
}
// ── Benchmarks (deck slide 10) ──────────────────────────────────
export const BENCHMARKS = {
aucRoc: 0.87,
aucRepaymentOnly: 0.68,
aucBureau: 0.61,
alertPrecisionRed: 0.79,
medianLeadTimeMonths: '3–6',
onDeviceLatencyMs: 250,
datasetSize: 2400,
sectorsCovered: 5,
districtsCovered: 4,
}
// ── Benchmarks ─────────────────────────────────────────────────
//
// Removed 2026-08-24. This block held nine constants the Reports page rendered as
// measurements: AUC 0.87 against an invented "bureau baseline", a 2,400-enterprise
// dataset that did not exist, and an on-device inference latency for inference
// that does not run on a device. Nothing computed any of them.
//
// The replacement is the `model_metric` table, written by
// `UdyamLinc-ML/training/train_stress.py` at the end of every run and served by
// GET /admin/model/metrics. The measured figures are lower than the ones that
// were here, which is the point.
/** Band migration month over month — how the book moved. */
export function bandMigration(block: string): { month: string; red: number; watch: number; standard: number }[] {
const rows = scopedEnterprises(block)
export function bandMigration(block: string, book: Enterprise[]): { month: string; red: number; watch: number; standard: number }[] {
const rows = scopedEnterprises(block, undefined, book)
const r = rng(seedOf(`${block}:migration`))
const base = new Date()
return Array.from({ length: 6 }, (_, i) => {
@@ -197,9 +209,9 @@ export function bandMigration(block: string): { month: string; red: number; watc
}
/** Per-sector-pack rollup for the Forecasts screen. */
export function sectorRollup(block: string) {
export function sectorRollup(block: string, book: Enterprise[]) {
return (Object.keys(SECTORS) as Sector[]).map((s) => {
const rows = scopedEnterprises(block, s)
const rows = scopedEnterprises(block, s, book)
const red = rows.filter((e) => e.band === 'RED').length
const watch = rows.filter((e) => e.band === 'WATCH').length
const avg = rows.length ? rows.reduce((a, e) => a + e.score, 0) / rows.length : 0
@@ -216,8 +228,8 @@ export function sectorRollup(block: string) {
}
/** Portfolio-level P10/P50/P90 aggregate over the 6-month horizon. */
export function portfolioForecast(block: string) {
const rows = scopedEnterprises(block)
export function portfolioForecast(block: string, book: Enterprise[]) {
const rows = scopedEnterprises(block, undefined, book)
const sample = rows.slice(0, 60) // enough for a stable aggregate without 142 × 6 work
const paths = sample.map((e) => forecastFor(e))
return paths[0].map((_, h) => ({
+18 -8
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import type { FieldVisit } from './api'
import { visitsFor } from './demo/ops'
/*
* Visits scheduled from the console during a demo session.
@@ -51,22 +50,32 @@ export function scheduleVisit(input: {
}
/** Record the outcome of a visit — this is what feeds the next retraining cycle. */
export function logOutcome(visitId: string, outcome: string) {
export function logOutcome(visitId: string, outcome: string, source?: FieldVisit) {
const local = read()
const hit = local.find((v) => v.visitId === visitId)
if (hit) {
write(local.map((v) => (v.visitId === visitId ? { ...v, state: 'DONE', outcome, loggedAt: new Date().toISOString() } : v)))
return
}
// A seeded visit: shadow it locally rather than mutating the generator.
const seeded = visitsFor('ALL').find((v) => v.visitId === visitId)
if (seeded) {
write([{ ...seeded, state: 'DONE', outcome, loggedAt: new Date().toISOString() }, ...local])
// A server visit: shadow it locally rather than reaching for the demo
// generator, which is not scoped to any bank. The caller supplies the row it
// is closing, because it already has it on screen.
if (source) {
write([{ ...source, state: "DONE", outcome, loggedAt: new Date().toISOString() }, ...local])
}
}
/** Seeded playbook plus locally scheduled/updated visits, local taking precedence. */
export function useVisits(block: string, officer: string) {
/**
* Live visits with locally scheduled ones merged in.
*
* <p>Server rows are supplied by the caller and there is no demo fallback, for
* the same reason as `useAlerts`: `/officer/visits` filters through the
* caller.s own borrowers, and the in-file portfolio has no owner on it. Visits
* scheduled in the browser are demo gestures held in local storage and are
* merged over the server list.
*/
export function useVisits(block: string, officer: string, source: FieldVisit[]) {
const [local, setLocal] = useState<FieldVisit[]>(read)
const refresh = useCallback(() => setLocal(read()), [])
@@ -76,7 +85,8 @@ export function useVisits(block: string, officer: string) {
return () => window.removeEventListener('ul-visits-changed', refresh)
}, [refresh])
const seeded = visitsFor(block, officer)
void officer
const seeded = source
const overridden = new Set(local.map((v) => v.visitId))
const scoped = local.filter((v) => block === 'ALL' || v.block === block)
const visits = [...scoped, ...seeded.filter((v) => !overridden.has(v.visitId))]