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:
+121
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user