/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>
127 lines
5.3 KiB
TypeScript
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>
|
|
)
|
|
}
|