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
+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' },
],
},
]
}