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:
LincPay
2026-08-06 18:10:51 +05:30
co-authored by Claude Opus 5
parent 98d3793bdf
commit 38067c41f8
10 changed files with 1160 additions and 4 deletions
+36 -2
View File
@@ -1,10 +1,15 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { LoginPage } from './pages/LoginPage'
import { enterpriseSession, officerSession } from './lib/api'
import { enterpriseSession, officerSession, homeFor, isApexRole } from './lib/api'
// Field officer console
import { OfficerShell } from './features/officer/OfficerShell'
import { NabardShell } from './features/nabard/NabardShell'
import { NationalDashboard } from './features/nabard/NationalDashboard'
import { BankScorecardPage } from './features/nabard/BankScorecardPage'
import { DigestPage } from './features/nabard/DigestPage'
import { InstitutionsPage } from './features/nabard/InstitutionsPage'
import { OfficerDashboard } from './features/officer/OfficerDashboard'
import { EnterprisesPage } from './features/officer/EnterprisesPage'
import { SectorPortfolioPage } from './features/officer/SectorPortfolioPage'
@@ -41,6 +46,19 @@ function RequireOfficer({ children }: { children: React.ReactNode }) {
return children
}
/**
* The apex portal is not a more-privileged officer console, so a bank session
* reaching it is bounced to its own portal rather than to the login page —
* being signed in as the wrong tier is not the same as being signed out.
*/
function RequireApex({ children }: { children: React.ReactNode }) {
const location = useLocation()
const session = officerSession()
if (!session?.token) return <Navigate to="/login" replace state={{ from: location }} />
if (!isApexRole(session.role)) return <Navigate to="/officer" replace />
return children
}
function RequireEnterprise({ children }: { children: React.ReactNode }) {
const location = useLocation()
if (!enterpriseSession()?.token) return <Navigate to="/login" replace state={{ from: location }} />
@@ -49,7 +67,8 @@ function RequireEnterprise({ children }: { children: React.ReactNode }) {
/** Land on whichever surface already has a session. */
function RootRedirect() {
if (officerSession()?.token) return <Navigate to="/officer" replace />
const session = officerSession()
if (session?.token) return <Navigate to={homeFor(session)} replace />
if (enterpriseSession()?.token) return <Navigate to="/app" replace />
return <Navigate to="/login" replace />
}
@@ -85,6 +104,21 @@ export default function App() {
<Route path="settings" element={<SettingsPage />} />
</Route>
{/* NABARD supervisory portal — aggregates only, no borrower reachable */}
<Route
path="/nabard"
element={
<RequireApex>
<NabardShell />
</RequireApex>
}
>
<Route index element={<NationalDashboard />} />
<Route path="banks" element={<BankScorecardPage />} />
<Route path="digest" element={<DigestPage />} />
<Route path="institutions" element={<InstitutionsPage />} />
</Route>
{/* Enterprise PWA — offline, Hindi-first */}
<Route
path="/app"
+203
View File
@@ -0,0 +1,203 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react'
import { get } from '../../lib/api'
import type { BankScorecardRow, BranchCell } from '../../lib/api'
import { Card, Empty, StatusPill } from '../../components/ui/bits'
import { demoBankScorecard } from '../../lib/demo/apex'
/**
* The partner banks NABARD refinances, and how each is doing.
*
* <p>Stress rate says how hard a bank's borrowers are being hit — largely a fact
* about its districts and sectors, not about the bank. <b>Response rate</b> says
* how much of what was flagged it has acted on, and that is a fact about the
* bank. It is the number no bank self-reports and the one an apex supervisor is
* uniquely placed to see, so it is the column this page is sorted and coloured
* around.
*/
export function BankScorecardPage() {
const [open, setOpen] = useState<string | null>(null)
const { data, isError } = useQuery({
queryKey: ['apex-banks'],
queryFn: () => get<BankScorecardRow[]>('/nabard/banks'),
})
const banks = data ?? demoBankScorecard()
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">Partner banks</h1>
<p className="text-[11px] text-slate-500">
Institutions whose rural enterprise portfolios NABARD refinances. Expand a bank to see
its branches.
</p>
</div>
{isError && <StatusPill status="Demo data" />}
</div>
<Card title="Refinanced portfolios" badge="aggregates only">
<div className="p-4 overflow-x-auto">
{banks.length === 0 ? (
<Empty text="No partner banks onboarded yet." />
) : (
<table className="ul-table w-full">
<thead>
<tr>
<th>Institution</th>
<th>Type</th>
<th className="text-right">Branches</th>
<th className="text-right">Units</th>
<th className="text-right">Stressed</th>
<th className="text-right">Stress rate</th>
<th>Alerts acted on</th>
<th />
</tr>
</thead>
<tbody>
{banks.map((b) => (
<BankRow
key={b.institutionCode}
bank={b}
open={open === b.institutionCode}
onToggle={() => setOpen(open === b.institutionCode ? null : b.institutionCode)}
/>
))}
</tbody>
</table>
)}
<p className="text-[10.5px] text-slate-400 mt-2.5">
A bank with fewer borrowers than the platform's minimum is listed but not measured —
publishing a rate over three borrowers describes those three.
</p>
</div>
</Card>
</div>
)
}
function BankRow({ bank, open, onToggle }: { bank: BankScorecardRow; open: boolean; onToggle: () => void }) {
const branchQ = useQuery({
queryKey: ['apex-branches', bank.institutionCode],
queryFn: () => get<BranchCell[]>(`/nabard/banks/${bank.institutionCode}/branches`),
enabled: open && !bank.suppressed,
})
return (
<>
<tr className={bank.suppressed ? '' : 'cursor-pointer'} onClick={() => !bank.suppressed && onToggle()}>
<td>
<b className="text-[12.5px] text-slate-800 block leading-tight">{bank.shortName}</b>
<small className="text-[10.5px] text-slate-400">{bank.institutionName}</small>
</td>
<td className="text-slate-600 text-[11.5px]">{typeLabel(bank.type)}</td>
<td className="text-right tabular-nums">{bank.branches}</td>
<td className="text-right tabular-nums">{bank.enterprises}</td>
{bank.suppressed ? (
<td colSpan={3}>
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-500">
<EyeOff size={12} /> withheld — too few borrowers to measure
</span>
</td>
) : (
<>
<td className="text-right tabular-nums font-semibold text-rose-700">{bank.red}</td>
<td className="text-right tabular-nums">{bank.stressRatePct}%</td>
<td>
<ResponseBar pct={bank.responseRatePct ?? 0} raised={bank.alertsRaised ?? 0} actioned={bank.alertsActioned ?? 0} />
</td>
</>
)}
<td className="text-right">
{!bank.suppressed &&
(open ? (
<ChevronDown size={13} className="text-slate-400 inline" />
) : (
<ChevronRight size={13} className="text-slate-400 inline" />
))}
</td>
</tr>
{open && !bank.suppressed && (
<tr>
<td colSpan={8} className="bg-slate-50/70">
<div className="px-3 py-2">
<div className="text-[10.5px] uppercase tracking-wide text-slate-400 font-semibold mb-1.5">
Branches
</div>
{branchQ.isLoading ? (
<div className="text-[11.5px] text-slate-500 py-2">Loading…</div>
) : (branchQ.data ?? []).length === 0 ? (
<div className="text-[11.5px] text-slate-500 py-2">No branches recorded.</div>
) : (
<table className="w-full text-[11.5px]">
<tbody>
{(branchQ.data ?? []).map((br) => (
<tr key={br.branchCode} className="border-b border-slate-200 last:border-0">
<td className="py-1.5 pr-3">
<b className="text-slate-700">{br.name}</b>
<span className="text-slate-400"> · {br.branchCode}</span>
</td>
<td className="py-1.5 pr-3 text-slate-500">{br.district}</td>
<td className="py-1.5 pr-3 text-slate-500">{br.region}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{br.enterprises}</td>
{br.suppressed ? (
<td className="py-1.5 text-slate-500">
<span className="inline-flex items-center gap-1.5">
<EyeOff size={11} /> withheld
</span>
</td>
) : (
<td className="py-1.5 tabular-nums">
<span className="text-rose-700 font-semibold">{br.red}</span>
<span className="text-slate-400"> stressed · {br.stressRatePct}%</span>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
<p className="text-[10.5px] text-slate-400 mt-2">
Branch is as deep as this portal goes. Below it is a borrower, and that belongs to
the bank.
</p>
</div>
</td>
</tr>
)}
</>
)
}
/** Low response is the finding, so low response is what draws the eye. */
function ResponseBar({ pct, raised, actioned }: { pct: number; raised: number; actioned: number }) {
const tone = pct < 25 ? 'bg-rose-500' : pct < 60 ? 'bg-amber-500' : 'bg-emerald-500'
const text = pct < 25 ? 'text-rose-700' : pct < 60 ? 'text-amber-700' : 'text-emerald-700'
return (
<div className="flex items-center gap-2 min-w-[150px]">
<span className="flex-1 h-2 rounded-full bg-slate-100 overflow-hidden">
<span className={`block h-full rounded-full ${tone}`} style={{ width: `${Math.min(100, pct)}%` }} />
</span>
<span className={`text-[11px] tabular-nums font-semibold w-9 text-right ${text}`}>{pct}%</span>
<span className="text-[10.5px] text-slate-400 w-12">
{actioned}/{raised}
</span>
</div>
)
}
function typeLabel(type: string) {
return (
{
RRB: 'Regional Rural Bank',
COOPERATIVE: 'Cooperative Bank',
SFB: 'Small Finance Bank',
COMMERCIAL: 'Commercial Bank',
APEX: 'Apex',
}[type] ?? type
)
}
+126
View File
@@ -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>
)
}
+149
View File
@@ -0,0 +1,149 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ChevronRight, EyeOff } from 'lucide-react'
import { get } from '../../lib/api'
import type { GeoCell } from '../../lib/api'
import { Card, Empty } from '../../components/ui/bits'
type Level = 'ZONE' | 'STATE' | 'DISTRICT'
/**
* India → zone → state → district.
*
* <p>The drill-down exists because the questions are different at each level. A
* zone tells NABARD where to look, a state tells it whose regional office owns
* the problem, a district is the unit ground-level credit is actually planned
* in. Below the district is a branch, and below a branch is a borrower — which
* this tier does not get.
*
* <p>Suppression bites hardest at the bottom, and that is the point: the further
* in you drill, the closer you are to a person.
*/
export function GeographyPanel({ minCellSize }: { minCellSize: number }) {
const [zone, setZone] = useState<string | null>(null)
const [state, setState] = useState<string | null>(null)
const level: Level = state ? 'DISTRICT' : zone ? 'STATE' : 'ZONE'
const { data, isError } = useQuery({
queryKey: ['apex-geo', level, zone, state],
queryFn: () =>
get<GeoCell[]>('/nabard/geography', {
level,
...(zone ? { zone } : {}),
...(state ? { state } : {}),
}),
})
const rows = data ?? []
const worst = Math.max(1, ...rows.filter((r) => !r.suppressed).map((r) => r.stressRatePct ?? 0))
return (
<Card
title="Geography"
badge={level === 'ZONE' ? 'India, by zone' : level === 'STATE' ? 'by state' : 'by district'}
>
<div className="p-4">
<div className="flex items-center gap-1.5 text-[11.5px] text-slate-500 mb-3 flex-wrap">
<Crumb label="India" active={level === 'ZONE'} onClick={() => { setZone(null); setState(null) }} />
{zone && (
<>
<ChevronRight size={12} />
<Crumb
label={rows[0]?.zoneLabel ?? zone}
active={level === 'STATE'}
onClick={() => setState(null)}
/>
</>
)}
{state && (
<>
<ChevronRight size={12} />
<Crumb label={state} active onClick={() => {}} />
</>
)}
</div>
{isError ? (
<Empty text="Geography is unavailable — the console could not reach the platform." />
) : rows.length === 0 ? (
<Empty text="No portfolio recorded at this level yet." />
) : (
<table className="ul-table w-full">
<thead>
<tr>
<th>{level === 'ZONE' ? 'Zone' : level === 'STATE' ? 'State' : 'District'}</th>
<th className="text-right">Units</th>
<th className="text-right">Stressed</th>
<th>Stress rate</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((r) => {
const canDrill = level !== 'DISTRICT'
return (
<tr
key={r.key}
className={canDrill ? 'cursor-pointer' : ''}
onClick={() => {
if (level === 'ZONE') setZone(r.key)
else if (level === 'STATE') setState(r.key)
}}
>
<td className="font-medium text-slate-700">{r.name}</td>
<td className="text-right tabular-nums">{r.enterprises}</td>
{r.suppressed ? (
<td colSpan={2}>
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-500">
<EyeOff size={12} />
withheld — fewer than {minCellSize} borrowers
</span>
</td>
) : (
<>
<td className="text-right tabular-nums font-semibold text-rose-700">{r.red}</td>
<td>
<div className="flex items-center gap-2">
<span className="flex-1 h-2 rounded-full bg-slate-100 overflow-hidden min-w-[60px]">
<span
className="block h-full rounded-full bg-forest"
style={{ width: `${Math.round(((r.stressRatePct ?? 0) / worst) * 100)}%` }}
/>
</span>
<span className="text-[11px] tabular-nums text-slate-600 w-11 text-right">
{r.stressRatePct}%
</span>
</div>
</td>
</>
)}
<td className="text-right">
{canDrill && <ChevronRight size={13} className="text-slate-400 inline" />}
</td>
</tr>
)
})}
</tbody>
</table>
)}
<p className="text-[10.5px] text-slate-400 mt-2.5">
The pilot covers four districts of Madhya Pradesh, so only the Central zone carries data
today. Every other zone would appear here as partner banks onboard.
</p>
</div>
</Card>
)
}
function Crumb({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={active ? 'font-semibold text-slate-800' : 'text-forest font-semibold hover:underline'}
>
{label}
</button>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { useQuery } from '@tanstack/react-query'
import { Building2, Landmark } from 'lucide-react'
import { get } from '../../lib/api'
import type { InstitutionRow } from '../../lib/api'
import { Card, Empty, StatusPill } from '../../components/ui/bits'
import { demoInstitutions } from '../../lib/demo/apex'
/**
* Who is on the platform. The apex tier's own admin view — institutions and
* their branches, with no portfolio figures at all.
*
* <p>Kept separate from the scorecard on purpose: onboarding is a different
* question from performance, and mixing them invites reading a small new bank's
* absent numbers as a bad result rather than as a bank that started last week.
*/
export function InstitutionsPage() {
const { data, isError } = useQuery({
queryKey: ['apex-institutions'],
queryFn: () => get<InstitutionRow[]>('/nabard/institutions'),
})
const rows = data ?? demoInstitutions()
const apex = rows.filter((r) => r.type === 'APEX')
const lenders = rows.filter((r) => r.type !== 'APEX')
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">Institutions</h1>
<p className="text-[11px] text-slate-500">
The apex institution and the lenders whose portfolios it refinances.
</p>
</div>
{isError && <StatusPill status="Demo data" />}
</div>
{apex.map((i) => (
<Card key={i.code} title="Apex institution">
<div className="p-4 flex items-start gap-3">
<span className="w-10 h-10 rounded-[10px] bg-green-soft text-forest grid place-items-center shrink-0">
<Landmark size={20} />
</span>
<div>
<b className="text-[13px] text-slate-800 block">{i.name}</b>
<span className="text-[11px] text-slate-500">
{i.state} · refinances and supervises · carries no borrower of its own
</span>
</div>
</div>
</Card>
))}
<Card title="Partner banks" badge={`${lenders.length} onboarded`}>
<div className="p-4 space-y-3">
{lenders.length === 0 ? (
<Empty text="No partner banks onboarded yet." />
) : (
lenders.map((i) => (
<div key={i.code} className="border border-slate-200 rounded-[10px] p-3">
<div className="flex items-start gap-3">
<span className="w-9 h-9 rounded-[9px] bg-green-soft text-forest grid place-items-center shrink-0">
<Building2 size={17} />
</span>
<div className="flex-1 min-w-0">
<b className="text-[12.5px] text-slate-800 block">{i.name}</b>
<span className="text-[11px] text-slate-500">
{i.code} · {typeLabel(i.type)} · {i.state}
</span>
</div>
</div>
{i.branches.length > 0 && (
<ul className="mt-2.5 pl-12 space-y-1">
{i.branches.map((b) => (
<li key={b.code} className="text-[11.5px] text-slate-600">
<b className="text-slate-700">{b.name}</b>
<span className="text-slate-400">
{' '}
· {b.code} · {b.region} · blocks: {b.blocks}
</span>
</li>
))}
</ul>
)}
</div>
))
)}
</div>
</Card>
</div>
)
}
function typeLabel(type: string) {
return (
{
RRB: 'Regional Rural Bank',
COOPERATIVE: 'Cooperative Bank',
SFB: 'Small Finance Bank',
COMMERCIAL: 'Commercial Bank',
}[type] ?? type
)
}
+82
View File
@@ -0,0 +1,82 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { Building2, Gauge, Landmark, ShieldCheck, Siren } from 'lucide-react'
import { clearSessions, officerSession } from '../../lib/api'
const NAV = [
{ label: 'National view', to: '/nabard', end: true, icon: Gauge },
{ label: 'Partner banks', to: '/nabard/banks', icon: Landmark },
{ label: 'Early warning', to: '/nabard/digest', icon: Siren },
{ label: 'Institutions', to: '/nabard/institutions', icon: Building2 },
]
/**
* The apex portal shell.
*
* <p>Deliberately a different chrome from the bank console. A NABARD user and a
* branch officer are not two ranks of one job — they see different things and
* may do different things, and a portal that looked identical would invite the
* assumption that the apex user is simply a bank user with more rights.
*/
export function NabardShell() {
const navigate = useNavigate()
const session = officerSession()
return (
<div className="min-h-screen flex bg-slate-50">
<aside className="w-[210px] shrink-0 bg-[#123c2b] text-white flex flex-col px-3 py-5">
<div className="flex items-center gap-2.5 px-2 pb-5">
<span className="w-9 h-9 rounded-[9px] bg-[#0f6b4c] grid place-items-center shrink-0">
<ShieldCheck size={19} />
</span>
<span>
<b className="text-[16px] block leading-tight">UdyamLinc</b>
<span className="text-[10.5px] text-[#bcd6cb]">NABARD Supervisory View</span>
</span>
</div>
<nav className="flex flex-col gap-0.5">
{NAV.map((n) => (
<NavLink
key={n.to}
to={n.to}
end={n.end}
className={({ isActive }) =>
`flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[12.5px] transition-colors ${
isActive ? 'bg-white/15 text-white font-semibold' : 'text-[#cfe2da] hover:bg-white/10'
}`
}
>
<n.icon size={15} />
{n.label}
</NavLink>
))}
</nav>
{/*
Stated in the chrome, on every screen, rather than buried in a policy
page: this tier cannot reach a borrower. A supervisor should be able to
tell a partner bank what this portal can see without checking.
*/}
<div className="mt-auto pt-4 border-t border-white/15 text-[10px] text-[#9dbfb1] leading-relaxed">
<b className="text-[#dcebe4] block mb-0.5">Aggregate view only</b>
This portal holds no borrower identity. Figures are counts and rates
across refinanced portfolios; cells below the minimum size are withheld.
</div>
<button
onClick={() => {
clearSessions()
navigate('/login', { replace: true })
}}
className="mt-3 text-[11px] text-[#9dbfb1] hover:text-white text-left px-2"
>
Sign out{session ? ` · ${session.username}` : ''}
</button>
</aside>
<main className="flex-1 min-w-0 px-6 py-5 max-w-[1200px]">
<Outlet />
</main>
</div>
)
}
+165
View File
@@ -0,0 +1,165 @@
import { useQuery } from '@tanstack/react-query'
import { EyeOff } from 'lucide-react'
import { get } from '../../lib/api'
import type { ApexCell, ApexDashboard } from '../../lib/api'
import { Card, Empty, StatusPill } from '../../components/ui/bits'
import { demoApexDashboard, demoDistricts, demoSectors } from '../../lib/demo/apex'
import { GeographyPanel } from './GeographyPanel'
/**
* What NABARD sees first: the refinanced portfolio as a whole, then where the
* stress sits — by district, which is how ground-level credit is planned, and by
* sector, which is how a shock actually arrives.
*/
export function NationalDashboard() {
const dashQ = useQuery({ queryKey: ['apex-dashboard'], queryFn: () => get<ApexDashboard>('/nabard/dashboard') })
const distQ = useQuery({ queryKey: ['apex-districts'], queryFn: () => get<ApexCell[]>('/nabard/districts') })
const sectQ = useQuery({ queryKey: ['apex-sectors'], queryFn: () => get<ApexCell[]>('/nabard/sectors') })
const demo = dashQ.isError || distQ.isError || sectQ.isError
const d = dashQ.data ?? demoApexDashboard()
const districts = distQ.data ?? demoDistricts()
const sectors = sectQ.data ?? demoSectors()
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">National view</h1>
<p className="text-[11px] text-slate-500">
Across {d.partnerBanks} refinanced institutions and {d.branches} branches
{d.dataAgeDays >= 0 && ` · scored ${d.dataAgeDays === 0 ? 'today' : `${d.dataAgeDays} days ago`}`}
</p>
</div>
{demo && <StatusPill status="Demo data" />}
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Tile label="Enterprises refinanced" value={d.enterprises.toLocaleString('en-IN')} note={`${d.partnerBanks} partner banks`} />
<Tile label="Under stress" value={String(d.red)} note={`${d.stressRatePct}% of the book`} tone="text-rose-700" />
<Tile label="Open alerts" value={String(d.openAlerts)} note="raised and not yet closed" tone="text-amber-700" />
<Tile
label="Portfolio value"
value={`₹${(d.portfolioValue / 10_000_000).toFixed(2)} Cr`}
note="outstanding across partners"
/>
</div>
<GeographyPanel minCellSize={d.minCellSize} />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3 items-start">
<CellTable
title="By district"
badge="how ground-level credit is planned"
labelKey="districtName"
rows={districts}
minCellSize={d.minCellSize}
/>
<CellTable
title="By sector"
badge="how a shock arrives"
labelKey="sectorName"
rows={sectors}
minCellSize={d.minCellSize}
/>
</div>
</div>
)
}
function Tile({ label, value, note, tone = 'text-slate-800' }: { label: string; value: string; note: string; tone?: string }) {
return (
<div className="bg-white border border-slate-200 rounded-[11px] px-4 py-3">
<div className="text-[11px] text-slate-500">{label}</div>
<div className={`text-[24px] font-bold leading-none mt-1 ${tone}`}>{value}</div>
<div className="text-[10.5px] text-slate-400 mt-1">{note}</div>
</div>
)
}
/**
* A suppressed cell keeps its row and loses its numbers.
*
* <p>Dropping the row entirely would read as "no enterprises here", which is a
* different and wrong claim — and the one a reader would most easily act on.
*/
function CellTable({
title,
badge,
labelKey,
rows,
minCellSize,
}: {
title: string
badge: string
labelKey: 'districtName' | 'sectorName'
rows: ApexCell[]
minCellSize: number
}) {
const worst = Math.max(1, ...rows.filter((r) => !r.suppressed).map((r) => r.stressRatePct ?? 0))
return (
<Card title={title} badge={badge}>
<div className="p-4">
{rows.length === 0 ? (
<Empty text="Nothing to report yet." />
) : (
<table className="ul-table w-full">
<thead>
<tr>
<th>{labelKey === 'districtName' ? 'District' : 'Sector'}</th>
<th className="text-right">Units</th>
<th className="text-right">Stressed</th>
<th>Stress rate</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const label = r.districtName ?? r.sectorName ?? '—'
if (r.suppressed) {
return (
<tr key={label}>
<td className="font-medium text-slate-700">{label}</td>
<td className="text-right text-slate-500">{r.enterprises}</td>
<td colSpan={2}>
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-500">
<EyeOff size={12} />
withheld — fewer than {minCellSize} borrowers
</span>
</td>
</tr>
)
}
return (
<tr key={label}>
<td className="font-medium text-slate-700">{label}</td>
<td className="text-right tabular-nums">{r.enterprises}</td>
<td className="text-right tabular-nums font-semibold text-rose-700">{r.red}</td>
<td>
<div className="flex items-center gap-2">
<span className="flex-1 h-2 rounded-full bg-slate-100 overflow-hidden min-w-[60px]">
<span
className="block h-full rounded-full bg-forest"
style={{ width: `${Math.round(((r.stressRatePct ?? 0) / worst) * 100)}%` }}
/>
</span>
<span className="text-[11px] tabular-nums text-slate-600 w-11 text-right">
{r.stressRatePct}%
</span>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
)}
<p className="text-[10.5px] text-slate-400 mt-2.5">
Counts and rates only. No row here identifies a borrower, and a cell with fewer than{' '}
{minCellSize} of them is withheld rather than rounded — with two borrowers and one alert,
publishing the rate would name them.
</p>
</div>
</Card>
)
}
+121
View File
@@ -30,6 +30,24 @@ export interface EnterpriseSession {
lang: Lang
}
/**
* Which portal a console session belongs to.
*
* <p>Derived from the role rather than stored as its own field, so a tampered
* session cannot claim a tier its token does not carry. This only picks the
* landing page — the backend re-derives the tier from the signed JWT and refuses
* the other surface outright, so getting this wrong is a routing annoyance
* rather than an access-control hole.
*/
export function isApexRole(role: string | undefined): boolean {
return !!role && role.startsWith('NABARD_')
}
export function homeFor(session: OfficerSession | null): string {
if (!session?.token) return '/login'
return isApexRole(session.role) ? '/nabard' : '/officer'
}
export function officerSession(): OfficerSession | null {
const raw = sessionStorage.getItem('ul-officer-auth')
if (!raw) return null
@@ -456,3 +474,106 @@ export async function uploadManual(file: File, type: 'MARKET_SIGNAL' | 'LOAN_REC
const res = await api.post(`/admin/manual/upload?type=${type}`, form)
return res.data as { success: boolean; message: string; data: ManualEntryPreview }
}
// ── NABARD (apex) surface ────────────────────────────────────────
// Aggregates only. Nothing here carries an enterprise id, by design — see
// ApexService on the backend. A cell with fewer borrowers than the platform's
// minimum is returned with suppressed=true rather than omitted, because a
// missing district reads as "no activity" and that is a different claim.
export interface ApexDashboard {
partnerBanks: number
branches: number
enterprises: number
red: number
watch: number
standard: number
stressRatePct: number
portfolioValue: number
openAlerts: number
minCellSize: number
dataAgeDays: number
}
export interface BankScorecardRow {
institutionCode: string
institutionName: string
shortName: string
type: string
branches: number
enterprises: number
suppressed: boolean
red?: number
watch?: number
standard?: number
stressRatePct?: number
avgScore?: number
alertsRaised?: number
alertsActioned?: number
/** Share of raised alerts a bank has acted on — the number a bank never self-reports. */
responseRatePct?: number
outstanding?: number
}
export interface ApexCell {
districtName?: string
sectorName?: string
enterprises: number
suppressed: boolean
red?: number
watch?: number
standard?: number
stressRatePct?: number
avgScore?: number
}
export interface ApexDigest {
asOf: string
districtsUnderStress: ApexCell[]
sectorsUnderStress: ApexCell[]
slowestResponders: BankScorecardRow[]
}
export interface InstitutionRow {
code: string
name: string
shortName: string
type: string
state: string
branches: { code: string; name: string; region: string; blocks: string }[]
}
/** One row of the India → zone → state → district drill-down. */
export interface GeoCell {
key: string
name: string
level: 'ZONE' | 'STATE' | 'DISTRICT'
zone?: string
zoneLabel?: string
state?: string
enterprises: number
suppressed: boolean
red?: number
watch?: number
standard?: number
stressRatePct?: number
avgScore?: number
}
/** A partner bank's branch, as the apex tier sees it — aggregates, never borrowers. */
export interface BranchCell {
name: string
branchCode: string
region: string
blocks: string
district: string
institutionCode: string
institutionName: string
enterprises: number
suppressed: boolean
red?: number
watch?: number
standard?: number
stressRatePct?: number
avgScore?: number
}
+170
View File
@@ -0,0 +1,170 @@
import type { ApexCell, ApexDashboard, ApexDigest, BankScorecardRow, InstitutionRow } from '../api'
/**
* Demo fallback for the NABARD portal.
*
* <p>Mirrors the real shape closely, including a partner bank below the
* suppression threshold. A demo where every cell is publishable would hide the
* one behaviour this tier is judged on: that small cells are withheld rather
* than quietly rounded.
*/
export function demoApexDashboard(): ApexDashboard {
return {
partnerBanks: 3,
branches: 6,
enterprises: 142,
red: 34,
watch: 8,
standard: 100,
stressRatePct: 23.9,
portfolioValue: 32_320_000,
openAlerts: 75,
minCellSize: 5,
dataAgeDays: 0,
}
}
export function demoBankScorecard(): BankScorecardRow[] {
return [
{
institutionCode: 'MPGB',
institutionName: 'Madhya Pradesh Gramin Bank',
shortName: 'MP Gramin',
type: 'RRB',
branches: 2,
enterprises: 44,
suppressed: false,
red: 10,
watch: 3,
standard: 31,
stressRatePct: 22.7,
avgScore: 34.1,
alertsRaised: 24,
alertsActioned: 2,
responseRatePct: 8.3,
outstanding: 9_940_000,
},
{
institutionCode: 'SEHDCCB',
institutionName: 'Sehore District Central Cooperative Bank',
shortName: 'Sehore DCCB',
type: 'COOPERATIVE',
branches: 2,
enterprises: 39,
suppressed: false,
red: 7,
watch: 2,
standard: 30,
stressRatePct: 17.9,
avgScore: 30.6,
alertsRaised: 22,
alertsActioned: 8,
responseRatePct: 36.4,
outstanding: 8_610_000,
},
{
institutionCode: 'VSFB',
institutionName: 'Vindhya Small Finance Bank',
shortName: 'Vindhya SFB',
type: 'SFB',
branches: 2,
enterprises: 59,
suppressed: false,
red: 17,
watch: 3,
standard: 39,
stressRatePct: 28.8,
avgScore: 37.9,
alertsRaised: 34,
alertsActioned: 17,
responseRatePct: 50,
outstanding: 13_770_000,
},
{
institutionCode: 'NEWCOOP',
institutionName: 'Betul Cooperative Bank (onboarding)',
shortName: 'Betul Coop',
type: 'COOPERATIVE',
branches: 1,
enterprises: 3,
// Below the platform's minimum cell size — nothing else may be published.
suppressed: true,
},
]
}
export function demoDistricts(): ApexCell[] {
return [
{ districtName: 'Bhopal', enterprises: 35, suppressed: false, red: 9, watch: 2, standard: 24, stressRatePct: 25.7, avgScore: 35.2 },
{ districtName: 'Raisen', enterprises: 34, suppressed: false, red: 8, watch: 2, standard: 24, stressRatePct: 23.5, avgScore: 33.8 },
{ districtName: 'Sehore', enterprises: 37, suppressed: false, red: 9, watch: 2, standard: 26, stressRatePct: 24.3, avgScore: 34.5 },
{ districtName: 'Vidisha', enterprises: 36, suppressed: false, red: 8, watch: 2, standard: 26, stressRatePct: 22.2, avgScore: 33.1 },
]
}
export function demoSectors(): ApexCell[] {
return [
{ sectorName: 'Handicrafts', enterprises: 30, suppressed: false, red: 12, watch: 1, standard: 17, stressRatePct: 40, avgScore: 42.6 },
{ sectorName: 'Poultry', enterprises: 27, suppressed: false, red: 6, watch: 1, standard: 20, stressRatePct: 22.2, avgScore: 33.4 },
{ sectorName: 'Food processing', enterprises: 28, suppressed: false, red: 6, watch: 3, standard: 19, stressRatePct: 21.4, avgScore: 32.8 },
{ sectorName: 'Dairy', enterprises: 30, suppressed: false, red: 5, watch: 3, standard: 22, stressRatePct: 16.7, avgScore: 30.2 },
{ sectorName: 'Rural retail', enterprises: 27, suppressed: false, red: 5, watch: 0, standard: 22, stressRatePct: 18.5, avgScore: 31.1 },
]
}
export function demoDigest(): ApexDigest {
const banks = demoBankScorecard().filter((b) => !b.suppressed)
return {
asOf: '2026-08-06',
districtsUnderStress: demoDistricts().sort((a, b) => (b.red ?? 0) - (a.red ?? 0)),
sectorsUnderStress: demoSectors().sort((a, b) => (b.red ?? 0) - (a.red ?? 0)),
slowestResponders: banks.sort((a, b) => (a.responseRatePct ?? 0) - (b.responseRatePct ?? 0)),
}
}
export function demoInstitutions(): InstitutionRow[] {
return [
{
code: 'NABARD',
name: 'National Bank for Agriculture and Rural Development',
shortName: 'NABARD',
type: 'APEX',
state: 'Madhya Pradesh',
branches: [],
},
{
code: 'MPGB',
name: 'Madhya Pradesh Gramin Bank',
shortName: 'MP Gramin',
type: 'RRB',
state: 'Madhya Pradesh',
branches: [
{ code: 'SEH01', name: 'Sehore Main Branch', region: 'Bhopal Region', blocks: 'Sehore' },
{ code: 'VID01', name: 'Vidisha Main Branch', region: 'Bhopal Region', blocks: 'Vidisha' },
],
},
{
code: 'SEHDCCB',
name: 'Sehore District Central Cooperative Bank',
shortName: 'Sehore DCCB',
type: 'COOPERATIVE',
state: 'Madhya Pradesh',
branches: [
{ code: 'SEH02', name: 'Sehore Cooperative Branch', region: 'Sehore Region', blocks: 'Sehore' },
{ code: 'RAI01', name: 'Raisen Cooperative Branch', region: 'Sehore Region', blocks: 'Raisen' },
],
},
{
code: 'VSFB',
name: 'Vindhya Small Finance Bank',
shortName: 'Vindhya SFB',
type: 'SFB',
state: 'Madhya Pradesh',
branches: [
{ code: 'BPL01', name: 'Bhopal City Branch', region: 'Bhopal Region', blocks: 'Bhopal' },
{ code: 'RAI02', name: 'Raisen Branch', region: 'Bhopal Region', blocks: 'Raisen' },
],
},
]
}
+4 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { errMsg, post } from '../lib/api'
import { errMsg, homeFor, post } from '../lib/api'
import type { EnterpriseSession, OfficerSession } from '../lib/api'
import { UdyamMark, inputCls } from '../components/ui/bits'
import { ENTERPRISES, findEnterprise } from '../lib/demo/enterprises'
@@ -76,7 +76,9 @@ export function LoginPage() {
if (mode === 'OFFICER') {
const session = await post<OfficerSession>('/auth/officer-login', { username: id, password: secret })
sessionStorage.setItem('ul-officer-auth', JSON.stringify(session))
navigate('/officer', { replace: true })
// NABARD and bank staff share one sign-in form and land in different
// portals — the role on the session decides which.
navigate(homeFor(session), { replace: true })
} else {
const session = await post<EnterpriseSession>('/auth/app-login', { enterpriseId: id, pin: secret })
localStorage.setItem('ul-app-auth', JSON.stringify(session))