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:
+36
-2
@@ -1,10 +1,15 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||||
import { LoginPage } from './pages/LoginPage'
|
import { LoginPage } from './pages/LoginPage'
|
||||||
import { enterpriseSession, officerSession } from './lib/api'
|
import { enterpriseSession, officerSession, homeFor, isApexRole } from './lib/api'
|
||||||
|
|
||||||
// Field officer console
|
// Field officer console
|
||||||
import { OfficerShell } from './features/officer/OfficerShell'
|
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 { OfficerDashboard } from './features/officer/OfficerDashboard'
|
||||||
import { EnterprisesPage } from './features/officer/EnterprisesPage'
|
import { EnterprisesPage } from './features/officer/EnterprisesPage'
|
||||||
import { SectorPortfolioPage } from './features/officer/SectorPortfolioPage'
|
import { SectorPortfolioPage } from './features/officer/SectorPortfolioPage'
|
||||||
@@ -41,6 +46,19 @@ function RequireOfficer({ children }: { children: React.ReactNode }) {
|
|||||||
return children
|
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 }) {
|
function RequireEnterprise({ children }: { children: React.ReactNode }) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
if (!enterpriseSession()?.token) return <Navigate to="/login" replace state={{ from: location }} />
|
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. */
|
/** Land on whichever surface already has a session. */
|
||||||
function RootRedirect() {
|
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 />
|
if (enterpriseSession()?.token) return <Navigate to="/app" replace />
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
@@ -85,6 +104,21 @@ export default function App() {
|
|||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</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 */}
|
{/* Enterprise PWA — offline, Hindi-first */}
|
||||||
<Route
|
<Route
|
||||||
path="/app"
|
path="/app"
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -30,6 +30,24 @@ export interface EnterpriseSession {
|
|||||||
lang: Lang
|
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 {
|
export function officerSession(): OfficerSession | null {
|
||||||
const raw = sessionStorage.getItem('ul-officer-auth')
|
const raw = sessionStorage.getItem('ul-officer-auth')
|
||||||
if (!raw) return null
|
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)
|
const res = await api.post(`/admin/manual/upload?type=${type}`, form)
|
||||||
return res.data as { success: boolean; message: string; data: ManualEntryPreview }
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 type { EnterpriseSession, OfficerSession } from '../lib/api'
|
||||||
import { UdyamMark, inputCls } from '../components/ui/bits'
|
import { UdyamMark, inputCls } from '../components/ui/bits'
|
||||||
import { ENTERPRISES, findEnterprise } from '../lib/demo/enterprises'
|
import { ENTERPRISES, findEnterprise } from '../lib/demo/enterprises'
|
||||||
@@ -76,7 +76,9 @@ export function LoginPage() {
|
|||||||
if (mode === 'OFFICER') {
|
if (mode === 'OFFICER') {
|
||||||
const session = await post<OfficerSession>('/auth/officer-login', { username: id, password: secret })
|
const session = await post<OfficerSession>('/auth/officer-login', { username: id, password: secret })
|
||||||
sessionStorage.setItem('ul-officer-auth', JSON.stringify(session))
|
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 {
|
} else {
|
||||||
const session = await post<EnterpriseSession>('/auth/app-login', { enterpriseId: id, pin: secret })
|
const session = await post<EnterpriseSession>('/auth/app-login', { enterpriseId: id, pin: secret })
|
||||||
localStorage.setItem('ul-app-auth', JSON.stringify(session))
|
localStorage.setItem('ul-app-auth', JSON.stringify(session))
|
||||||
|
|||||||
Reference in New Issue
Block a user