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>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user