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
}