Files
udyamlinc/src/features/nabard/DigestPage.tsx
T
LincPayandClaude Opus 5 38067c41f8 feat(nabard): supervisory portal — national view, banks, early warning
/nabard, reachable only by a NABARD role. Four screens: national view with
the India→zone→state→district drill-down, partner-bank scorecard that
expands into branches, early-warning digest, and the institution register.

Deliberately a different chrome from the bank console. A NABARD user and a
branch officer are not two ranks of one job; a portal that looked identical
would invite the assumption that the apex user is a bank user with more
rights. The sidebar states on every screen that this tier holds no borrower
identity, so a supervisor can tell a partner bank what it can see without
checking.

Response rate is the column the scorecard is built around. Stress rate is
mostly a fact about a bank's districts and sectors; response rate is a fact
about the bank, it is the one no bank self-reports, and low values are
coloured to draw the eye.

Suppressed cells keep their row and lose their numbers — dropping the row
reads as 'no enterprises here', a different and wrong claim, and the one a
reader would most easily act on.

Login routes by tier: both staff tiers share one sign-in and land in
different portals. RequireApex bounces a bank session to /officer rather
than to /login, because signed in as the wrong tier is not signed out. The
routing is a convenience only — the backend re-derives the tier from the
signed JWT and refuses the other surface with a 403.

The demo fallback includes a partner bank below the suppression threshold,
so the withholding behaviour is visible without a backend.

tsc, oxlint and npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:10:51 +05:30

127 lines
5.3 KiB
TypeScript

import { useQuery } from '@tanstack/react-query'
import { Siren, TrendingDown } from 'lucide-react'
import { get } from '../../lib/api'
import type { ApexDigest } from '../../lib/api'
import { Card, Empty, StatusPill } from '../../components/ui/bits'
import { demoDigest } from '../../lib/demo/apex'
/**
* Where the refinanced portfolio is deteriorating — as districts, sectors and
* partner banks, never as borrowers.
*
* <p>Ranked by the count of stressed enterprises rather than by rate. A 40%
* stress rate over eight borrowers is noise; a 12% rate over three hundred is a
* programme. Both numbers are shown, but the ordering says which to read first.
*/
export function DigestPage() {
const { data, isError } = useQuery({ queryKey: ['apex-digest'], queryFn: () => get<ApexDigest>('/nabard/digest') })
const d = data ?? demoDigest()
return (
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<h1 className="text-[15px] font-bold text-forest leading-tight">Early warning</h1>
<p className="text-[11px] text-slate-500">
As at {d.asOf} · ranked by how many enterprises are affected, not by rate
</p>
</div>
{isError && <StatusPill status="Demo data" />}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3 items-start">
<Card title="Districts under stress" badge="most affected first">
<div className="p-4">
{d.districtsUnderStress.length === 0 ? (
<Empty text="No district is carrying stressed enterprises." />
) : (
<ol className="space-y-2">
{d.districtsUnderStress.map((c) => (
<li key={c.districtName} className="flex items-center gap-3">
<Siren size={14} className="text-rose-600 shrink-0" />
<span className="flex-1">
<b className="text-[12.5px] text-slate-800">{c.districtName}</b>
<span className="text-[11px] text-slate-500">
{' '}
· {c.red} of {c.enterprises} stressed
</span>
</span>
<span className="text-[12px] font-bold tabular-nums text-rose-700">{c.stressRatePct}%</span>
</li>
))}
</ol>
)}
</div>
</Card>
<Card title="Sectors under stress" badge="where the shock arrived">
<div className="p-4">
{d.sectorsUnderStress.length === 0 ? (
<Empty text="No sector is carrying stressed enterprises." />
) : (
<ol className="space-y-2">
{d.sectorsUnderStress.map((c) => (
<li key={c.sectorName} className="flex items-center gap-3">
<TrendingDown size={14} className="text-amber-600 shrink-0" />
<span className="flex-1">
<b className="text-[12.5px] text-slate-800">{c.sectorName}</b>
<span className="text-[11px] text-slate-500">
{' '}
· {c.red} of {c.enterprises} stressed
</span>
</span>
<span className="text-[12px] font-bold tabular-nums text-amber-700">{c.stressRatePct}%</span>
</li>
))}
</ol>
)}
</div>
</Card>
</div>
<Card title="Slowest to act" badge="alerts raised vs acted on">
<div className="p-4">
{d.slowestResponders.length === 0 ? (
<Empty text="No partner bank has open alerts." />
) : (
<table className="ul-table w-full">
<thead>
<tr>
<th>Institution</th>
<th className="text-right">Alerts raised</th>
<th className="text-right">Acted on</th>
<th className="text-right">Response rate</th>
</tr>
</thead>
<tbody>
{d.slowestResponders.map((b) => (
<tr key={b.institutionCode}>
<td className="font-medium text-slate-700">{b.shortName}</td>
<td className="text-right tabular-nums">{b.alertsRaised}</td>
<td className="text-right tabular-nums">{b.alertsActioned}</td>
<td
className={`text-right tabular-nums font-bold ${
(b.responseRatePct ?? 0) < 25
? 'text-rose-700'
: (b.responseRatePct ?? 0) < 60
? 'text-amber-700'
: 'text-emerald-700'
}`}
>
{b.responseRatePct}%
</td>
</tr>
))}
</tbody>
</table>
)}
<p className="text-[10.5px] text-slate-400 mt-2.5">
An alert nobody acts on is the failure this tier is uniquely placed to catch — the
borrower's own bank has every reason not to report it, and the borrower cannot.
</p>
</div>
</Card>
</div>
)
}