diff --git a/src/App.tsx b/src/App.tsx
index 4bfe52b..59b3e9e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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
+ if (!isApexRole(session.role)) return
+ return children
+}
+
function RequireEnterprise({ children }: { children: React.ReactNode }) {
const location = useLocation()
if (!enterpriseSession()?.token) return
@@ -49,7 +67,8 @@ function RequireEnterprise({ children }: { children: React.ReactNode }) {
/** Land on whichever surface already has a session. */
function RootRedirect() {
- if (officerSession()?.token) return
+ const session = officerSession()
+ if (session?.token) return
if (enterpriseSession()?.token) return
return
}
@@ -85,6 +104,21 @@ export default function App() {
} />
+ {/* NABARD supervisory portal — aggregates only, no borrower reachable */}
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+
+
{/* Enterprise PWA — offline, Hindi-first */}
Stress rate says how hard a bank's borrowers are being hit — largely a fact
+ * about its districts and sectors, not about the bank. Response rate 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(null)
+
+ const { data, isError } = useQuery({
+ queryKey: ['apex-banks'],
+ queryFn: () => get('/nabard/banks'),
+ })
+ const banks = data ?? demoBankScorecard()
+
+ return (
+
+
+
+
Partner banks
+
+ Institutions whose rural enterprise portfolios NABARD refinances. Expand a bank to see
+ its branches.
+
+ A bank with fewer borrowers than the platform's minimum is listed but not measured —
+ publishing a rate over three borrowers describes those three.
+
+ Branch is as deep as this portal goes. Below it is a borrower, and that belongs to
+ the bank.
+
+
+
+
+ )}
+ >
+ )
+}
+
+/** 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 (
+
+
+
+
+ {pct}%
+
+ {actioned}/{raised}
+
+
+ )
+}
+
+function typeLabel(type: string) {
+ return (
+ {
+ RRB: 'Regional Rural Bank',
+ COOPERATIVE: 'Cooperative Bank',
+ SFB: 'Small Finance Bank',
+ COMMERCIAL: 'Commercial Bank',
+ APEX: 'Apex',
+ }[type] ?? type
+ )
+}
diff --git a/src/features/nabard/DigestPage.tsx b/src/features/nabard/DigestPage.tsx
new file mode 100644
index 0000000..e0c715b
--- /dev/null
+++ b/src/features/nabard/DigestPage.tsx
@@ -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.
+ *
+ *
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('/nabard/digest') })
+ const d = data ?? demoDigest()
+
+ return (
+
+
+
+
Early warning
+
+ As at {d.asOf} · ranked by how many enterprises are affected, not by rate
+
+ 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.
+
+
+
+
+ )
+}
diff --git a/src/features/nabard/GeographyPanel.tsx b/src/features/nabard/GeographyPanel.tsx
new file mode 100644
index 0000000..581ffb5
--- /dev/null
+++ b/src/features/nabard/GeographyPanel.tsx
@@ -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.
+ *
+ *
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.
+ *
+ *
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(null)
+ const [state, setState] = useState(null)
+
+ const level: Level = state ? 'DISTRICT' : zone ? 'STATE' : 'ZONE'
+
+ const { data, isError } = useQuery({
+ queryKey: ['apex-geo', level, zone, state],
+ queryFn: () =>
+ get('/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 (
+
+
+ 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.
+
+
+
+ )
+}
+
+function Crumb({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
+ return (
+
+ )
+}
diff --git a/src/features/nabard/InstitutionsPage.tsx b/src/features/nabard/InstitutionsPage.tsx
new file mode 100644
index 0000000..5dc7bb6
--- /dev/null
+++ b/src/features/nabard/InstitutionsPage.tsx
@@ -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.
+ *
+ *
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('/nabard/institutions'),
+ })
+ const rows = data ?? demoInstitutions()
+
+ const apex = rows.filter((r) => r.type === 'APEX')
+ const lenders = rows.filter((r) => r.type !== 'APEX')
+
+ return (
+
+
+
+
Institutions
+
+ The apex institution and the lenders whose portfolios it refinances.
+
+
+ {isError && }
+
+
+ {apex.map((i) => (
+
+
+
+
+
+
+ {i.name}
+
+ {i.state} · refinances and supervises · carries no borrower of its own
+
+
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 (
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/features/nabard/NationalDashboard.tsx b/src/features/nabard/NationalDashboard.tsx
new file mode 100644
index 0000000..b0bfe64
--- /dev/null
+++ b/src/features/nabard/NationalDashboard.tsx
@@ -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('/nabard/dashboard') })
+ const distQ = useQuery({ queryKey: ['apex-districts'], queryFn: () => get('/nabard/districts') })
+ const sectQ = useQuery({ queryKey: ['apex-sectors'], queryFn: () => get('/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 (
+
+
+
+
National view
+
+ Across {d.partnerBanks} refinanced institutions and {d.branches} branches
+ {d.dataAgeDays >= 0 && ` · scored ${d.dataAgeDays === 0 ? 'today' : `${d.dataAgeDays} days ago`}`}
+
+ )
+}
+
+/**
+ * A suppressed cell keeps its row and loses its numbers.
+ *
+ *
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 (
+
+
+ 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.
+
+
+
+ )
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 34c236f..9a0ab2f 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -30,6 +30,24 @@ export interface EnterpriseSession {
lang: Lang
}
+/**
+ * Which portal a console session belongs to.
+ *
+ *
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
+}
diff --git a/src/lib/demo/apex.ts b/src/lib/demo/apex.ts
new file mode 100644
index 0000000..071be14
--- /dev/null
+++ b/src/lib/demo/apex.ts
@@ -0,0 +1,170 @@
+import type { ApexCell, ApexDashboard, ApexDigest, BankScorecardRow, InstitutionRow } from '../api'
+
+/**
+ * Demo fallback for the NABARD portal.
+ *
+ *