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:
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 type { AlertState, RiskAlert } from '../../lib/api'
|
||||||
import { ActionChip, Card, Empty, GhostBtn, RiskBadge, StatusPill } from '../../components/ui/bits'
|
import { ActionChip, Card, Empty, GhostBtn, RiskBadge, StatusPill } from '../../components/ui/bits'
|
||||||
import { SECTORS } from '../../lib/demo/enterprises'
|
import { SECTORS } from '../../lib/demo/enterprises'
|
||||||
@@ -25,7 +26,20 @@ export function AlertsPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { block } = useScope()
|
const { block } = useScope()
|
||||||
const [filter, setFilter] = useState<AlertState | 'ALL'>('OPEN')
|
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 actor = officerSession()?.fullName ?? 'Field Officer'
|
||||||
|
|
||||||
const rows = alerts.filter((a) => filter === 'ALL' || a.state === filter)
|
const rows = alerts.filter((a) => filter === 'ALL' || a.state === filter)
|
||||||
@@ -34,6 +48,26 @@ export function AlertsPage() {
|
|||||||
setAlertState(alert, to, actor)
|
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 (
|
return (
|
||||||
<div className="grid gap-4 xl:grid-cols-[1fr_320px] items-start">
|
<div className="grid gap-4 xl:grid-cols-[1fr_320px] items-start">
|
||||||
<div>
|
<div>
|
||||||
@@ -80,8 +114,8 @@ export function AlertsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[11px] text-slate-500 mt-0.5">
|
<div className="text-[11px] text-slate-500 mt-0.5">
|
||||||
{a.enterpriseId} · {SECTORS[a.sector].short} · {a.block} · score{' '}
|
{a.enterpriseId} · {SECTORS[a.sector]?.short ?? a.sector} · {a.block} · score{' '}
|
||||||
<b className="text-slate-700">{a.score}</b> · raised {a.raisedAt}
|
<b className="text-slate-700">{a.score}</b> · raised {a.raisedAt?.slice(0, 16).replace('T', ' ')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[12.5px] font-semibold text-slate-700 mt-2">{a.title}</p>
|
<p className="text-[12.5px] font-semibold text-slate-700 mt-2">{a.title}</p>
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { officerSession } from '../../lib/api'
|
import { enterpriseDetail, isNotFound, officerSession } from '../../lib/api'
|
||||||
import type { Enterprise } from '../../lib/api'
|
import type {
|
||||||
|
Enterprise,
|
||||||
|
FieldVisit,
|
||||||
|
LedgerEntry,
|
||||||
|
EnterpriseDetail,
|
||||||
|
ForecastPointRow,
|
||||||
|
ReasonCodeRow,
|
||||||
|
StressScoreRow,
|
||||||
|
} from '../../lib/api'
|
||||||
import {
|
import {
|
||||||
ActionChip,
|
ActionChip,
|
||||||
Card,
|
Card,
|
||||||
@@ -22,8 +31,6 @@ import {
|
|||||||
breachPoint,
|
breachPoint,
|
||||||
findEnterprise,
|
findEnterprise,
|
||||||
forecastFor,
|
forecastFor,
|
||||||
reasonCodesFor,
|
|
||||||
stressScoreFor,
|
|
||||||
subScoresFor,
|
subScoresFor,
|
||||||
tripWiresFor,
|
tripWiresFor,
|
||||||
} from '../../lib/demo/enterprises'
|
} from '../../lib/demo/enterprises'
|
||||||
@@ -43,18 +50,110 @@ const TABS: { key: Tab; label: string }[] = [
|
|||||||
{ key: 'VISITS', label: 'Visits' },
|
{ 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() {
|
export function EnterpriseDetailPage() {
|
||||||
const { enterpriseId = '' } = useParams()
|
const { enterpriseId = '' } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [tab, setTab] = useState<Tab>('OVERVIEW')
|
const [tab, setTab] = useState<Tab>('OVERVIEW')
|
||||||
const [scheduling, setScheduling] = useState(false)
|
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}.`} />
|
if (!e) return <Empty text={`No enterprise found for ${enterpriseId}.`} />
|
||||||
|
|
||||||
const points = forecastFor(e)
|
// Live forecast rows where we have them; the in-file generator only where the
|
||||||
const breach = breachPoint(points)
|
// 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
|
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,12 +168,13 @@ export function EnterpriseDetailPage() {
|
|||||||
<h1 className="text-[19px] font-bold text-forest">{e.name}</h1>
|
<h1 className="text-[19px] font-bold text-forest">{e.name}</h1>
|
||||||
<RiskBadge band={e.band} />
|
<RiskBadge band={e.band} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11.5px] text-slate-500">
|
<div className="text-[11.5px] text-ink-3">
|
||||||
{e.enterpriseId} · {SECTORS[e.sector].label} · {e.block} block, {e.district} district ·{' '}
|
{e.enterpriseId} · {sectorLabel(e.sector)} · {e.block} block, {e.district} district ·{' '}
|
||||||
{e.vintageMonths} months on book
|
{e.vintageMonths} months on book
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<PrimaryBtn onClick={() => setScheduling(true)}>Schedule visit</PrimaryBtn>
|
||||||
<GhostBtn onClick={() => setTab('FORECAST')}>Full forecast</GhostBtn>
|
<GhostBtn onClick={() => setTab('FORECAST')}>Full forecast</GhostBtn>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,28 +200,77 @@ export function EnterpriseDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'OVERVIEW' && <OverviewTab e={e} />}
|
{tab === 'OVERVIEW' && <OverviewTab e={e} score={data?.score ?? null} reasons={data?.reasons ?? []} />}
|
||||||
{tab === 'FORECAST' && <ForecastTab e={e} />}
|
{tab === 'FORECAST' && <ForecastTab e={e} live={data?.forecast ?? []} />}
|
||||||
{tab === 'DRIVERS' && <DriversTab e={e} />}
|
{tab === 'DRIVERS' && <DriversTab e={e} score={data?.score ?? null} reasons={data?.reasons ?? []} />}
|
||||||
{tab === 'LEDGER' && <LedgerTab e={e} />}
|
{tab === 'LEDGER' && <LedgerTab e={e} live={data?.ledger ?? []} />}
|
||||||
{tab === 'VISITS' && <VisitsTab e={e} />}
|
{tab === 'VISITS' && <VisitsTab e={e} live={data?.visits ?? []} />}
|
||||||
|
|
||||||
{scheduling && <ScheduleVisitModal enterprise={e} onClose={() => setScheduling(false)} />}
|
{scheduling && <ScheduleVisitModal enterprise={e} onClose={() => setScheduling(false)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverviewTab({ e }: { e: Enterprise }) {
|
/** How the engine that produced a score should be described, and coloured. */
|
||||||
const score = stressScoreFor(e)
|
const ENGINE_LABEL: Record<string, { text: string; pill: string; note: string }> = {
|
||||||
const reasons = reasonCodesFor(e)
|
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 (
|
return (
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<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">
|
<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">
|
<div className="mt-3 w-full">
|
||||||
<Sparkline values={e.trend} band={e.band} width={240} height={44} />
|
<Sparkline values={e.trend} band={(score?.band ?? e.band) as never} width={240} height={44} />
|
||||||
<div className="text-[10px] text-slate-400 text-center mt-1">6-month score history</div>
|
<div className="text-[10px] text-ink-3 text-center mt-1">6-month score history</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -131,28 +280,64 @@ function OverviewTab({ e }: { e: Enterprise }) {
|
|||||||
{[
|
{[
|
||||||
['Loan outstanding', inr(e.loanOutstanding)],
|
['Loan outstanding', inr(e.loanOutstanding)],
|
||||||
['Monthly instalment', inr(e.monthlyInstalment)],
|
['Monthly instalment', inr(e.monthlyInstalment)],
|
||||||
['Sector pack', SECTORS[e.sector].label],
|
['Sector pack', sectorLabel(e.sector)],
|
||||||
['Block / district', `${e.block} / ${e.district}`],
|
['Block / district', `${e.block} / ${e.district}`],
|
||||||
['Owners on record', String(e.ownerCount)],
|
['Owners on record', String(e.ownerCount)],
|
||||||
['Vintage', `${e.vintageMonths} months`],
|
['Vintage', `${e.vintageMonths} months`],
|
||||||
|
['Last scored', score?.computedAt ? score.computedAt.slice(0, 16).replace('T', ' ') : '—'],
|
||||||
].map(([k, v]) => (
|
].map(([k, v]) => (
|
||||||
<div key={k} className="flex justify-between gap-3">
|
<div key={k} className="flex justify-between gap-3">
|
||||||
<dt className="text-slate-500">{k}</dt>
|
<dt className="text-ink-3">{k}</dt>
|
||||||
<dd className="font-semibold text-slate-700">{v}</dd>
|
<dd className="font-semibold text-ink num">{v}</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</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>
|
||||||
|
|
||||||
<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">
|
<div className="p-4 space-y-3">
|
||||||
{reasons.map((r) => (
|
{reasons.length === 0 && <Empty text="No reason codes stored for this enterprise." />}
|
||||||
<div key={r.driver} className="border-l-[3px] border-amber pl-2.5">
|
{reasons.slice(0, 5).map((r) => (
|
||||||
<div className="text-[11px] font-semibold text-slate-700">
|
<div key={r.id} className="flex gap-2.5">
|
||||||
{r.driver} <span className="font-mono text-slate-400">· {r.weight}</span>
|
<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>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,11 +346,29 @@ function OverviewTab({ e }: { e: Enterprise }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ForecastTab({ e }: { e: Enterprise }) {
|
function ForecastTab({ e, live }: { e: Enterprise; live: ForecastPointRow[] }) {
|
||||||
const points = forecastFor(e)
|
// 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 (
|
return (
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<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">
|
<div className="p-4">
|
||||||
<ForecastFan points={points} height={280} />
|
<ForecastFan points={points} height={280} />
|
||||||
</div>
|
</div>
|
||||||
@@ -207,14 +410,47 @@ function ForecastTab({ e }: { e: Enterprise }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DriversTab({ e }: { e: Enterprise }) {
|
function DriversTab({
|
||||||
const sub = subScoresFor(e)
|
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 wires = tripWiresFor(e)
|
||||||
|
const isModel = score?.engine === 'MODEL'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<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">
|
<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">
|
<div className="space-y-2 mt-4">
|
||||||
{DRIVER_META.map((d) => (
|
{DRIVER_META.map((d) => (
|
||||||
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
|
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
|
||||||
@@ -223,7 +459,38 @@ function DriversTab({ e }: { e: Enterprise }) {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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">
|
<div className="overflow-x-auto">
|
||||||
<table className="ul-table">
|
<table className="ul-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -259,8 +526,11 @@ function DriversTab({ e }: { e: Enterprise }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LedgerTab({ e }: { e: Enterprise }) {
|
function LedgerTab({ e, live }: { e: Enterprise; live: LedgerEntry[] }) {
|
||||||
const entries = loadLedger(e.enterpriseId).slice(0, 40)
|
// 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) =>
|
const label = (kind: string, code: string) =>
|
||||||
CATEGORIES[kind as keyof typeof CATEGORIES]?.find((c) => c.code === code)?.en ?? code
|
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 }) {
|
function VisitsTab({ e, live }: { e: Enterprise; live: FieldVisit[] }) {
|
||||||
const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer')
|
// 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)
|
const mine = visits.filter((v) => v.enterpriseId === e.enterpriseId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { get } from '../../lib/api'
|
import { get } from '../../lib/api'
|
||||||
import type { Band, Enterprise, Sector } from '../../lib/api'
|
import type { Band, Enterprise, Sector } from '../../lib/api'
|
||||||
import { ActionChip, Empty, GhostBtn, RiskBadge, Sparkline, inputCls } from '../../components/ui/bits'
|
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 { SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises'
|
||||||
import { scopedEnterprises } from '../../lib/demo/ops'
|
|
||||||
import { inrCompact } from '../../lib/demo/seed'
|
import { inrCompact } from '../../lib/demo/seed'
|
||||||
import { exportCsv } from '../../lib/csv'
|
import { exportCsv } from '../../lib/csv'
|
||||||
import { useScope } from './officerScope'
|
import { useScope } from './officerScope'
|
||||||
@@ -25,12 +26,15 @@ export function EnterprisesPage() {
|
|||||||
const [band, setBand] = useState<Band | 'ALL'>('ALL')
|
const [band, setBand] = useState<Band | 'ALL'>('ALL')
|
||||||
const [sector, setSector] = useState<Sector | 'ALL'>('ALL')
|
const [sector, setSector] = useState<Sector | 'ALL'>('ALL')
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data, isError, refetch } = useQuery({
|
||||||
queryKey: ['enterprises', block],
|
queryKey: ['enterprises', block],
|
||||||
queryFn: () => get<Enterprise[]>('/officer/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 rows = useMemo(() => {
|
||||||
const needle = q.trim().toLowerCase()
|
const needle = q.trim().toLowerCase()
|
||||||
@@ -61,6 +65,8 @@ export function EnterprisesPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isError || !data) return <Unavailable what="Enterprise register" onRetry={() => void refetch()} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
import { get } from '../../lib/api'
|
import { get } from '../../lib/api'
|
||||||
import type { Band, Enterprise, Sector } from '../../lib/api'
|
import type { Band, Enterprise, Sector } from '../../lib/api'
|
||||||
import { Card, Empty, StatusPill } from '../../components/ui/bits'
|
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
|
* 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 [term, setTerm] = useState('')
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
|
||||||
const { data, isError } = useQuery({
|
const { data, isError, refetch } = useQuery({
|
||||||
queryKey: ['portfolio-enterprises'],
|
queryKey: ['portfolio-enterprises'],
|
||||||
queryFn: () => get<Enterprise[]>('/officer/enterprises'),
|
queryFn: () => get<Enterprise[]>('/officer/enterprises'),
|
||||||
})
|
})
|
||||||
const demo = isError
|
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 bySector = useMemo(() => {
|
||||||
const map = new Map<Sector, Enterprise[]>()
|
const map = new Map<Sector, Enterprise[]>()
|
||||||
@@ -101,6 +104,8 @@ export function SectorPortfolioPage() {
|
|||||||
|
|
||||||
const chosen = rows.find((e) => e.enterpriseId === selected) ?? null
|
const chosen = rows.find((e) => e.enterpriseId === selected) ?? null
|
||||||
|
|
||||||
|
if (isError || !data) return <Unavailable what="Sector portfolio" onRetry={() => void refetch()} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* ── header ─────────────────────────────────────── */}
|
{/* ── header ─────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 type { FieldVisit, VisitState } from '../../lib/api'
|
||||||
import {
|
import {
|
||||||
ActionChip,
|
ActionChip,
|
||||||
@@ -33,7 +34,11 @@ export function VisitsPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { block } = useScope()
|
const { block } = useScope()
|
||||||
const officer = officerSession()?.fullName ?? 'Field Officer'
|
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 [filter, setFilter] = useState<VisitState | 'ALL'>('ALL')
|
||||||
const [logging, setLogging] = useState<FieldVisit | null>(null)
|
const [logging, setLogging] = useState<FieldVisit | null>(null)
|
||||||
|
|
||||||
@@ -133,7 +138,7 @@ function LogOutcomeModal({ visit, onClose }: { visit: FieldVisit; onClose: () =>
|
|||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset)
|
logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset, visit)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import type { AlertState, RiskAlert } from './api'
|
import type { AlertState, RiskAlert } from './api'
|
||||||
import { alertsFor } from './demo/ops'
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Officer actions on the alert queue.
|
* 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. */
|
/** 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 [version, setVersion] = useState(0)
|
||||||
|
|
||||||
const bump = useCallback(() => setVersion((v) => v + 1), [])
|
const bump = useCallback(() => setVersion((v) => v + 1), [])
|
||||||
@@ -64,7 +75,8 @@ export function useAlerts(block: string) {
|
|||||||
const states = readStates()
|
const states = readStates()
|
||||||
// `version` is read so the memo-free derivation re-runs after every action.
|
// `version` is read so the memo-free derivation re-runs after every action.
|
||||||
void version
|
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() }
|
return { alerts, audit: readAudit() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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> = {
|
export const BLOCK_DISTRICT: Record<string, string> = {
|
||||||
Sehore: 'Sehore',
|
Sehore: 'Sehore',
|
||||||
Vidisha: 'Vidisha',
|
Vidisha: 'Vidisha',
|
||||||
Raisen: 'Raisen',
|
Raisen: 'Raisen',
|
||||||
Bhopal: 'Bhopal',
|
Bhopal: 'Bhopal',
|
||||||
|
Kamrup: 'Kamrup',
|
||||||
|
Nagaon: 'Nagaon',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The six enterprises printed on the wireframe slide — reproduced exactly. */
|
/** The six enterprises printed on the wireframe slide — reproduced exactly. */
|
||||||
const PINNED: { enterpriseId: string; name: string; nameHi: string; sector: Sector; block: string; score: number }[] = [
|
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-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-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 },
|
{ enterpriseId: 'MSME-5120', name: 'Anand Food Products', nameHi: 'आनंद फ़ूड प्रोडक्ट्स', sector: 'FOOD_PROC', block: 'Raisen', score: 58 },
|
||||||
|
|||||||
+49
-37
@@ -11,6 +11,7 @@ import type {
|
|||||||
import type { RiskAlert } from '../api'
|
import type { RiskAlert } from '../api'
|
||||||
import {
|
import {
|
||||||
ENTERPRISES,
|
ENTERPRISES,
|
||||||
|
|
||||||
SECTORS,
|
SECTORS,
|
||||||
breachPoint,
|
breachPoint,
|
||||||
forecastFor,
|
forecastFor,
|
||||||
@@ -27,8 +28,22 @@ import { currentMonthKey, dayOffsetIso, monthRef, pickInt, pickOne, rng, seedOf
|
|||||||
|
|
||||||
export const ALL_BLOCKS = 'ALL'
|
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),
|
(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
|
* counts are 142 / 9 / 31 / 102 with the deck's deltas; any narrower scope
|
||||||
* recomputes honestly from the filtered set.
|
* recomputes honestly from the filtered set.
|
||||||
*/
|
*/
|
||||||
export function portfolioSummary(block: string, month: string): PortfolioSummary {
|
export function portfolioSummary(block: string, month: string, book: Enterprise[]): PortfolioSummary {
|
||||||
const rows = scopedEnterprises(block)
|
const rows = scopedEnterprises(block, undefined, book)
|
||||||
const red = rows.filter((e) => e.band === 'RED').length
|
const red = rows.filter((e) => e.band === 'RED').length
|
||||||
const watch = rows.filter((e) => e.band === 'WATCH').length
|
const watch = rows.filter((e) => e.band === 'WATCH').length
|
||||||
const standard = rows.filter((e) => e.band === 'STANDARD').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']
|
const ALERT_STATES: AlertState[] = ['OPEN', 'OPEN', 'OPEN', 'ACKNOWLEDGED', 'ESCALATED', 'SNOOZED']
|
||||||
|
|
||||||
/** Every Red and Watch enterprise carries a live alert; Standard ones do not. */
|
/** Every Red and Watch enterprise carries a live alert; Standard ones do not. */
|
||||||
export function alertsFor(block: string): RiskAlert[] {
|
export function alertsFor(block: string, book: Enterprise[]): RiskAlert[] {
|
||||||
return scopedEnterprises(block)
|
return scopedEnterprises(block, undefined, book)
|
||||||
.filter((e) => e.band !== 'STANDARD')
|
.filter((e) => e.band !== 'STANDARD')
|
||||||
.map((e, i) => {
|
.map((e, i) => {
|
||||||
const r = rng(seedOf(`${e.enterpriseId}:alert`))
|
const r = rng(seedOf(`${e.enterpriseId}:alert`))
|
||||||
@@ -103,9 +118,16 @@ export function alertsFor(block: string): RiskAlert[] {
|
|||||||
.sort((a, b) => b.score - a.score)
|
.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 {
|
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 ─────────────────────────────────────
|
// ── Field visits / playbook ─────────────────────────────────────
|
||||||
@@ -130,8 +152,8 @@ const OUTCOMES = [
|
|||||||
'Buyer payment confirmed as delayed, not defaulted.',
|
'Buyer payment confirmed as delayed, not defaulted.',
|
||||||
]
|
]
|
||||||
|
|
||||||
export function visitsFor(block: string, officer = 'Aditya Vyas'): FieldVisit[] {
|
export function visitsFor(block: string, book: Enterprise[], officer = 'Aditya Vyas'): FieldVisit[] {
|
||||||
return scopedEnterprises(block)
|
return scopedEnterprises(block, undefined, book)
|
||||||
.filter((e) => e.band !== 'STANDARD')
|
.filter((e) => e.band !== 'STANDARD')
|
||||||
.slice(0, 24)
|
.slice(0, 24)
|
||||||
.map((e, i) => {
|
.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. */
|
// ── Benchmarks ─────────────────────────────────────────────────
|
||||||
export function weeklyVisitProgress(block: string): { done: number; target: number } {
|
//
|
||||||
const visits = visitsFor(block).filter((v) => v.priority === 1)
|
// Removed 2026-08-24. This block held nine constants the Reports page rendered as
|
||||||
const target = block === ALL_BLOCKS ? 9 : Math.max(3, visits.length)
|
// measurements: AUC 0.87 against an invented "bureau baseline", a 2,400-enterprise
|
||||||
const done = block === ALL_BLOCKS ? 6 : visits.filter((v) => v.state === 'DONE').length
|
// dataset that did not exist, and an on-device inference latency for inference
|
||||||
return { done, target }
|
// that does not run on a device. Nothing computed any of them.
|
||||||
}
|
//
|
||||||
|
// The replacement is the `model_metric` table, written by
|
||||||
// ── Benchmarks (deck slide 10) ──────────────────────────────────
|
// `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
|
||||||
export const BENCHMARKS = {
|
// were here, which is the point.
|
||||||
aucRoc: 0.87,
|
|
||||||
aucRepaymentOnly: 0.68,
|
|
||||||
aucBureau: 0.61,
|
|
||||||
alertPrecisionRed: 0.79,
|
|
||||||
medianLeadTimeMonths: '3–6',
|
|
||||||
onDeviceLatencyMs: 250,
|
|
||||||
datasetSize: 2400,
|
|
||||||
sectorsCovered: 5,
|
|
||||||
districtsCovered: 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Band migration month over month — how the book moved. */
|
/** Band migration month over month — how the book moved. */
|
||||||
export function bandMigration(block: string): { month: string; red: number; watch: number; standard: number }[] {
|
export function bandMigration(block: string, book: Enterprise[]): { month: string; red: number; watch: number; standard: number }[] {
|
||||||
const rows = scopedEnterprises(block)
|
const rows = scopedEnterprises(block, undefined, book)
|
||||||
const r = rng(seedOf(`${block}:migration`))
|
const r = rng(seedOf(`${block}:migration`))
|
||||||
const base = new Date()
|
const base = new Date()
|
||||||
return Array.from({ length: 6 }, (_, i) => {
|
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. */
|
/** 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) => {
|
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 red = rows.filter((e) => e.band === 'RED').length
|
||||||
const watch = rows.filter((e) => e.band === 'WATCH').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
|
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. */
|
/** Portfolio-level P10/P50/P90 aggregate over the 6-month horizon. */
|
||||||
export function portfolioForecast(block: string) {
|
export function portfolioForecast(block: string, book: Enterprise[]) {
|
||||||
const rows = scopedEnterprises(block)
|
const rows = scopedEnterprises(block, undefined, book)
|
||||||
const sample = rows.slice(0, 60) // enough for a stable aggregate without 142 × 6 work
|
const sample = rows.slice(0, 60) // enough for a stable aggregate without 142 × 6 work
|
||||||
const paths = sample.map((e) => forecastFor(e))
|
const paths = sample.map((e) => forecastFor(e))
|
||||||
return paths[0].map((_, h) => ({
|
return paths[0].map((_, h) => ({
|
||||||
|
|||||||
+18
-8
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import type { FieldVisit } from './api'
|
import type { FieldVisit } from './api'
|
||||||
import { visitsFor } from './demo/ops'
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Visits scheduled from the console during a demo session.
|
* 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. */
|
/** 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 local = read()
|
||||||
const hit = local.find((v) => v.visitId === visitId)
|
const hit = local.find((v) => v.visitId === visitId)
|
||||||
if (hit) {
|
if (hit) {
|
||||||
write(local.map((v) => (v.visitId === visitId ? { ...v, state: 'DONE', outcome, loggedAt: new Date().toISOString() } : v)))
|
write(local.map((v) => (v.visitId === visitId ? { ...v, state: 'DONE', outcome, loggedAt: new Date().toISOString() } : v)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A seeded visit: shadow it locally rather than mutating the generator.
|
// A server visit: shadow it locally rather than reaching for the demo
|
||||||
const seeded = visitsFor('ALL').find((v) => v.visitId === visitId)
|
// generator, which is not scoped to any bank. The caller supplies the row it
|
||||||
if (seeded) {
|
// is closing, because it already has it on screen.
|
||||||
write([{ ...seeded, state: 'DONE', outcome, loggedAt: new Date().toISOString() }, ...local])
|
if (source) {
|
||||||
|
write([{ ...source, state: "DONE", outcome, loggedAt: new Date().toISOString() }, ...local])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seeded playbook plus locally scheduled/updated visits, local taking precedence. */
|
/** 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 [local, setLocal] = useState<FieldVisit[]>(read)
|
||||||
|
|
||||||
const refresh = useCallback(() => setLocal(read()), [])
|
const refresh = useCallback(() => setLocal(read()), [])
|
||||||
@@ -76,7 +85,8 @@ export function useVisits(block: string, officer: string) {
|
|||||||
return () => window.removeEventListener('ul-visits-changed', refresh)
|
return () => window.removeEventListener('ul-visits-changed', refresh)
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
const seeded = visitsFor(block, officer)
|
void officer
|
||||||
|
const seeded = source
|
||||||
const overridden = new Set(local.map((v) => v.visitId))
|
const overridden = new Set(local.map((v) => v.visitId))
|
||||||
const scoped = local.filter((v) => block === 'ALL' || v.block === block)
|
const scoped = local.filter((v) => block === 'ALL' || v.block === block)
|
||||||
const visits = [...scoped, ...seeded.filter((v) => !overridden.has(v.visitId))]
|
const visits = [...scoped, ...seeded.filter((v) => !overridden.has(v.visitId))]
|
||||||
|
|||||||
Reference in New Issue
Block a user