Files
udyamlinc/src/features/officer/EnterpriseDetailPage.tsx
T
LincPayandClaude Opus 5 41c49113c2 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>
2026-08-25 13:45:19 +05:30

612 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import { enterpriseDetail, isNotFound, officerSession } from '../../lib/api'
import type {
Enterprise,
FieldVisit,
LedgerEntry,
EnterpriseDetail,
ForecastPointRow,
ReasonCodeRow,
StressScoreRow,
} from '../../lib/api'
import {
ActionChip,
Card,
DriverBar,
Empty,
GhostBtn,
PrimaryBtn,
RiskBadge,
ScoreDial,
Sparkline,
StatusPill,
} from '../../components/ui/bits'
import { ForecastFan } from '../../components/charts/ForecastFan'
import { SubScoreRadar } from '../../components/charts/SubScoreRadar'
import {
DRIVER_META,
SECTORS,
breachPoint,
findEnterprise,
forecastFor,
subScoresFor,
tripWiresFor,
} from '../../lib/demo/enterprises'
import { inr, inrCompact, monthRef } from '../../lib/demo/seed'
import { loadLedger } from '../../lib/ledgerStore'
import { CATEGORIES } from '../../lib/ledgerStore'
import { useVisits } from '../../lib/visitStore'
import { ScheduleVisitModal } from './ScheduleVisitModal'
type Tab = 'OVERVIEW' | 'FORECAST' | 'DRIVERS' | 'LEDGER' | 'VISITS'
const TABS: { key: Tab; label: string }[] = [
{ key: 'OVERVIEW', label: 'Overview' },
{ key: 'FORECAST', label: 'Forecast' },
{ key: 'DRIVERS', label: 'Drivers' },
{ key: 'LEDGER', label: 'Ledger' },
{ key: 'VISITS', label: 'Visits' },
]
/**
* 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 { 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}.`} />
// 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 (
<div>
{/* ── header ─────────────────────────────────────────── */}
<div className="flex items-start gap-4 mb-4">
<button onClick={() => navigate(-1)} className="text-[11.5px] text-slate-400 hover:text-forest mt-1">
← Back
</button>
<div className="min-w-0">
<div className="flex items-center gap-2.5">
<h1 className="text-[19px] font-bold text-forest">{e.name}</h1>
<RiskBadge band={e.band} />
</div>
<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 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>
</div>
{breach && (
<div className="bg-risk-soft border border-risk-red/20 rounded-lg px-4 py-2.5 mb-4 flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-risk-red text-white grid place-items-center text-[12px] font-bold shrink-0">
!
</span>
<p className="text-[12px] text-slate-700">
Downside case fails to cover obligations from <b>{breachRef?.fullEn}</b> — projected gap{' '}
<b className="text-risk-red">{inr(breach.obligation - breach.p10)}</b> at P10.
</p>
</div>
)}
<div className="flex gap-1.5 mb-4">
{TABS.map((t) => (
<ActionChip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
{t.label}
</ActionChip>
))}
</div>
{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>
)
}
/** 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 ? `run ${score.runId ?? '—'}` : 'not scored'}>
<div className="p-4 grid place-items-center">
<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={(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>
<Card title="Institutional record">
<dl className="p-4 space-y-2.5 text-[12px]">
{[
['Loan outstanding', inr(e.loanOutstanding)],
['Monthly instalment', inr(e.monthlyInstalment)],
['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-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={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.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>
</div>
))}
</div>
</Card>
</div>
)
}
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"
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>
</Card>
<Card title="Month by month">
<div className="overflow-x-auto">
<table className="ul-table">
<thead>
<tr>
<th>Month</th>
<th className="text-right">P10</th>
<th className="text-right">P50</th>
<th className="text-right">Due</th>
</tr>
</thead>
<tbody>
{points.map((p) => {
const short = p.p10 < p.obligation
return (
<tr key={p.month} className={short ? '!bg-risk-soft' : ''}>
<td className="font-semibold text-slate-700">{p.label}</td>
<td className={`text-right ${short ? 'text-risk-red font-bold' : 'text-slate-600'}`}>
{inrCompact(p.p10)}
</td>
<td className="text-right text-slate-600">{inrCompact(p.p50)}</td>
<td className="text-right text-slate-500">{inrCompact(p.obligation)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<p className="px-3.5 py-2 text-[10.5px] text-slate-400 border-t border-slate-100">
Rows in red are months where the downside case does not cover what is due.
</p>
</Card>
</div>
)
}
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={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 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]} />
))}
</div>
</div>
</Card>
<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>
<tr>
<th>Code</th>
<th>Rule</th>
<th>Threshold</th>
<th>State</th>
</tr>
</thead>
<tbody>
{wires.map((w) => (
<tr key={w.code}>
<td className="font-mono text-[10.5px] text-slate-500">{w.code}</td>
<td>
<span className="block font-semibold text-slate-700">{w.label}</span>
<span className="block text-[10.5px] text-slate-400">{w.detail}</span>
</td>
<td className="font-mono text-[10.5px] text-slate-500">{w.threshold}</td>
<td>
<span className={`pill ${w.fired ? 'pill-bad' : 'pill-mut'}`}>{w.fired ? 'Fired' : 'Clear'}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="px-3.5 py-2 text-[10.5px] text-slate-400 border-t border-slate-100">
Trip-wires fire independently of the model, so every alert stays auditable.
</p>
</Card>
</div>
)
}
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
return (
<Card title="Three-tap ledger — last 40 entries" badge="owner-reported">
<div className="overflow-x-auto max-h-[520px] overflow-y-auto">
<table className="ul-table">
<thead className="sticky top-0">
<tr>
<th>Date</th>
<th>Type</th>
<th>Category</th>
<th className="text-right">Amount</th>
<th>Sync</th>
</tr>
</thead>
<tbody>
{entries.map((l) => (
<tr key={l.id}>
<td className="text-slate-600">{l.entryDate}</td>
<td>
<span className={`pill ${l.kind === 'IN' ? 'pill-ok' : l.kind === 'OUT' ? 'pill-bad' : 'pill-info'}`}>
{l.kind === 'IN' ? 'Money in' : l.kind === 'OUT' ? 'Money out' : 'Stock'}
</span>
</td>
<td className="text-slate-600">{label(l.kind, l.category)}</td>
<td className="text-right font-semibold text-slate-700">{inr(l.amount)}</td>
<td>
<StatusPill status={l.syncState} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{!entries.length && <Empty text="This enterprise has not recorded any entries yet." />}
</Card>
)
}
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 (
<Card title="Field visits and interventions">
<div className="overflow-x-auto">
<table className="ul-table">
<thead>
<tr>
<th>Visit</th>
<th>Action</th>
<th>Due</th>
<th>State</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
{mine.map((v) => (
<tr key={v.visitId}>
<td className="font-mono text-[10.5px] text-slate-500">{v.visitId}</td>
<td className="text-slate-700 whitespace-normal">{v.action}</td>
<td className="text-slate-600">{v.dueDate}</td>
<td>
<StatusPill status={v.state} />
</td>
<td className="text-slate-500 whitespace-normal max-w-[320px]">{v.outcome ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
{!mine.length && <Empty text="No visit scheduled for this enterprise yet." />}
</Card>
)
}