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 { 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 <Navigate to="/login" replace state={{ from: location }} />
|
||||
if (!isApexRole(session.role)) return <Navigate to="/officer" replace />
|
||||
return children
|
||||
}
|
||||
|
||||
function RequireEnterprise({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation()
|
||||
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. */
|
||||
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 />
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
@@ -85,6 +104,21 @@ export default function App() {
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</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 */}
|
||||
<Route
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -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 { 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 { UdyamMark, inputCls } from '../components/ui/bits'
|
||||
import { ENTERPRISES, findEnterprise } from '../lib/demo/enterprises'
|
||||
@@ -76,7 +76,9 @@ export function LoginPage() {
|
||||
if (mode === 'OFFICER') {
|
||||
const session = await post<OfficerSession>('/auth/officer-login', { username: id, password: secret })
|
||||
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 {
|
||||
const session = await post<EnterpriseSession>('/auth/app-login', { enterpriseId: id, pin: secret })
|
||||
localStorage.setItem('ul-app-auth', JSON.stringify(session))
|
||||
|
||||
Reference in New Issue
Block a user