diff --git a/src/App.tsx b/src/App.tsx index 4bfe52b..59b3e9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,15 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom' import { LoginPage } from './pages/LoginPage' -import { enterpriseSession, officerSession } from './lib/api' +import { enterpriseSession, officerSession, homeFor, isApexRole } from './lib/api' // Field officer console import { OfficerShell } from './features/officer/OfficerShell' +import { NabardShell } from './features/nabard/NabardShell' +import { NationalDashboard } from './features/nabard/NationalDashboard' +import { BankScorecardPage } from './features/nabard/BankScorecardPage' +import { DigestPage } from './features/nabard/DigestPage' +import { InstitutionsPage } from './features/nabard/InstitutionsPage' import { OfficerDashboard } from './features/officer/OfficerDashboard' import { EnterprisesPage } from './features/officer/EnterprisesPage' import { SectorPortfolioPage } from './features/officer/SectorPortfolioPage' @@ -41,6 +46,19 @@ function RequireOfficer({ children }: { children: React.ReactNode }) { return children } +/** + * The apex portal is not a more-privileged officer console, so a bank session + * reaching it is bounced to its own portal rather than to the login page — + * being signed in as the wrong tier is not the same as being signed out. + */ +function RequireApex({ children }: { children: React.ReactNode }) { + const location = useLocation() + const session = officerSession() + if (!session?.token) return + if (!isApexRole(session.role)) return + return children +} + function RequireEnterprise({ children }: { children: React.ReactNode }) { const location = useLocation() if (!enterpriseSession()?.token) return @@ -49,7 +67,8 @@ function RequireEnterprise({ children }: { children: React.ReactNode }) { /** Land on whichever surface already has a session. */ function RootRedirect() { - if (officerSession()?.token) return + const session = officerSession() + if (session?.token) return if (enterpriseSession()?.token) return return } @@ -85,6 +104,21 @@ export default function App() { } /> + {/* NABARD supervisory portal — aggregates only, no borrower reachable */} + + + + } + > + } /> + } /> + } /> + } /> + + {/* Enterprise PWA — offline, Hindi-first */} Stress rate says how hard a bank's borrowers are being hit — largely a fact + * about its districts and sectors, not about the bank. Response rate says + * how much of what was flagged it has acted on, and that is a fact about the + * bank. It is the number no bank self-reports and the one an apex supervisor is + * uniquely placed to see, so it is the column this page is sorted and coloured + * around. + */ +export function BankScorecardPage() { + const [open, setOpen] = useState(null) + + const { data, isError } = useQuery({ + queryKey: ['apex-banks'], + queryFn: () => get('/nabard/banks'), + }) + const banks = data ?? demoBankScorecard() + + return ( +
+
+
+

Partner banks

+

+ Institutions whose rural enterprise portfolios NABARD refinances. Expand a bank to see + its branches. +

+
+ {isError && } +
+ + +
+ {banks.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + {banks.map((b) => ( + setOpen(open === b.institutionCode ? null : b.institutionCode)} + /> + ))} + +
InstitutionTypeBranchesUnitsStressedStress rateAlerts acted on +
+ )} +

+ A bank with fewer borrowers than the platform's minimum is listed but not measured — + publishing a rate over three borrowers describes those three. +

+
+
+
+ ) +} + +function BankRow({ bank, open, onToggle }: { bank: BankScorecardRow; open: boolean; onToggle: () => void }) { + const branchQ = useQuery({ + queryKey: ['apex-branches', bank.institutionCode], + queryFn: () => get(`/nabard/banks/${bank.institutionCode}/branches`), + enabled: open && !bank.suppressed, + }) + + return ( + <> + !bank.suppressed && onToggle()}> + + {bank.shortName} + {bank.institutionName} + + {typeLabel(bank.type)} + {bank.branches} + {bank.enterprises} + + {bank.suppressed ? ( + + + withheld — too few borrowers to measure + + + ) : ( + <> + {bank.red} + {bank.stressRatePct}% + + + + + )} + + {!bank.suppressed && + (open ? ( + + ) : ( + + ))} + + + + {open && !bank.suppressed && ( + + +
+
+ Branches +
+ {branchQ.isLoading ? ( +
Loading…
+ ) : (branchQ.data ?? []).length === 0 ? ( +
No branches recorded.
+ ) : ( + + + {(branchQ.data ?? []).map((br) => ( + + + + + + {br.suppressed ? ( + + ) : ( + + )} + + ))} + +
+ {br.name} + · {br.branchCode} + {br.district}{br.region}{br.enterprises} + + withheld + + + {br.red} + stressed · {br.stressRatePct}% +
+ )} +

+ Branch is as deep as this portal goes. Below it is a borrower, and that belongs to + the bank. +

+
+ + + )} + + ) +} + +/** Low response is the finding, so low response is what draws the eye. */ +function ResponseBar({ pct, raised, actioned }: { pct: number; raised: number; actioned: number }) { + const tone = pct < 25 ? 'bg-rose-500' : pct < 60 ? 'bg-amber-500' : 'bg-emerald-500' + const text = pct < 25 ? 'text-rose-700' : pct < 60 ? 'text-amber-700' : 'text-emerald-700' + return ( +
+ + + + {pct}% + + {actioned}/{raised} + +
+ ) +} + +function typeLabel(type: string) { + return ( + { + RRB: 'Regional Rural Bank', + COOPERATIVE: 'Cooperative Bank', + SFB: 'Small Finance Bank', + COMMERCIAL: 'Commercial Bank', + APEX: 'Apex', + }[type] ?? type + ) +} diff --git a/src/features/nabard/DigestPage.tsx b/src/features/nabard/DigestPage.tsx new file mode 100644 index 0000000..e0c715b --- /dev/null +++ b/src/features/nabard/DigestPage.tsx @@ -0,0 +1,126 @@ +import { useQuery } from '@tanstack/react-query' +import { Siren, TrendingDown } from 'lucide-react' +import { get } from '../../lib/api' +import type { ApexDigest } from '../../lib/api' +import { Card, Empty, StatusPill } from '../../components/ui/bits' +import { demoDigest } from '../../lib/demo/apex' + +/** + * Where the refinanced portfolio is deteriorating — as districts, sectors and + * partner banks, never as borrowers. + * + *

Ranked by the count of stressed enterprises rather than by rate. A 40% + * stress rate over eight borrowers is noise; a 12% rate over three hundred is a + * programme. Both numbers are shown, but the ordering says which to read first. + */ +export function DigestPage() { + const { data, isError } = useQuery({ queryKey: ['apex-digest'], queryFn: () => get('/nabard/digest') }) + const d = data ?? demoDigest() + + return ( +

+
+
+

Early warning

+

+ As at {d.asOf} · ranked by how many enterprises are affected, not by rate +

+
+ {isError && } +
+ +
+ +
+ {d.districtsUnderStress.length === 0 ? ( + + ) : ( +
    + {d.districtsUnderStress.map((c) => ( +
  1. + + + {c.districtName} + + {' '} + · {c.red} of {c.enterprises} stressed + + + {c.stressRatePct}% +
  2. + ))} +
+ )} +
+
+ + +
+ {d.sectorsUnderStress.length === 0 ? ( + + ) : ( +
    + {d.sectorsUnderStress.map((c) => ( +
  1. + + + {c.sectorName} + + {' '} + · {c.red} of {c.enterprises} stressed + + + {c.stressRatePct}% +
  2. + ))} +
+ )} +
+
+
+ + +
+ {d.slowestResponders.length === 0 ? ( + + ) : ( + + + + + + + + + + + {d.slowestResponders.map((b) => ( + + + + + + + ))} + +
InstitutionAlerts raisedActed onResponse rate
{b.shortName}{b.alertsRaised}{b.alertsActioned} + {b.responseRatePct}% +
+ )} +

+ An alert nobody acts on is the failure this tier is uniquely placed to catch — the + borrower's own bank has every reason not to report it, and the borrower cannot. +

+
+
+
+ ) +} diff --git a/src/features/nabard/GeographyPanel.tsx b/src/features/nabard/GeographyPanel.tsx new file mode 100644 index 0000000..581ffb5 --- /dev/null +++ b/src/features/nabard/GeographyPanel.tsx @@ -0,0 +1,149 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ChevronRight, EyeOff } from 'lucide-react' +import { get } from '../../lib/api' +import type { GeoCell } from '../../lib/api' +import { Card, Empty } from '../../components/ui/bits' + +type Level = 'ZONE' | 'STATE' | 'DISTRICT' + +/** + * India → zone → state → district. + * + *

The drill-down exists because the questions are different at each level. A + * zone tells NABARD where to look, a state tells it whose regional office owns + * the problem, a district is the unit ground-level credit is actually planned + * in. Below the district is a branch, and below a branch is a borrower — which + * this tier does not get. + * + *

Suppression bites hardest at the bottom, and that is the point: the further + * in you drill, the closer you are to a person. + */ +export function GeographyPanel({ minCellSize }: { minCellSize: number }) { + const [zone, setZone] = useState(null) + const [state, setState] = useState(null) + + const level: Level = state ? 'DISTRICT' : zone ? 'STATE' : 'ZONE' + + const { data, isError } = useQuery({ + queryKey: ['apex-geo', level, zone, state], + queryFn: () => + get('/nabard/geography', { + level, + ...(zone ? { zone } : {}), + ...(state ? { state } : {}), + }), + }) + const rows = data ?? [] + + const worst = Math.max(1, ...rows.filter((r) => !r.suppressed).map((r) => r.stressRatePct ?? 0)) + + return ( + +

+
+ { setZone(null); setState(null) }} /> + {zone && ( + <> + + setState(null)} + /> + + )} + {state && ( + <> + + {}} /> + + )} +
+ + {isError ? ( + + ) : rows.length === 0 ? ( + + ) : ( + + + + + + + + + + + {rows.map((r) => { + const canDrill = level !== 'DISTRICT' + return ( + { + if (level === 'ZONE') setZone(r.key) + else if (level === 'STATE') setState(r.key) + }} + > + + + {r.suppressed ? ( + + ) : ( + <> + + + + )} + + + ) + })} + +
{level === 'ZONE' ? 'Zone' : level === 'STATE' ? 'State' : 'District'}UnitsStressedStress rate +
{r.name}{r.enterprises} + + + withheld — fewer than {minCellSize} borrowers + + {r.red} +
+ + + + + {r.stressRatePct}% + +
+
+ {canDrill && } +
+ )} + +

+ The pilot covers four districts of Madhya Pradesh, so only the Central zone carries data + today. Every other zone would appear here as partner banks onboard. +

+
+ + ) +} + +function Crumb({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { + return ( + + ) +} diff --git a/src/features/nabard/InstitutionsPage.tsx b/src/features/nabard/InstitutionsPage.tsx new file mode 100644 index 0000000..5dc7bb6 --- /dev/null +++ b/src/features/nabard/InstitutionsPage.tsx @@ -0,0 +1,104 @@ +import { useQuery } from '@tanstack/react-query' +import { Building2, Landmark } from 'lucide-react' +import { get } from '../../lib/api' +import type { InstitutionRow } from '../../lib/api' +import { Card, Empty, StatusPill } from '../../components/ui/bits' +import { demoInstitutions } from '../../lib/demo/apex' + +/** + * Who is on the platform. The apex tier's own admin view — institutions and + * their branches, with no portfolio figures at all. + * + *

Kept separate from the scorecard on purpose: onboarding is a different + * question from performance, and mixing them invites reading a small new bank's + * absent numbers as a bad result rather than as a bank that started last week. + */ +export function InstitutionsPage() { + const { data, isError } = useQuery({ + queryKey: ['apex-institutions'], + queryFn: () => get('/nabard/institutions'), + }) + const rows = data ?? demoInstitutions() + + const apex = rows.filter((r) => r.type === 'APEX') + const lenders = rows.filter((r) => r.type !== 'APEX') + + return ( +

+
+
+

Institutions

+

+ The apex institution and the lenders whose portfolios it refinances. +

+
+ {isError && } +
+ + {apex.map((i) => ( + +
+ + + +
+ {i.name} + + {i.state} · refinances and supervises · carries no borrower of its own + +
+
+
+ ))} + + +
+ {lenders.length === 0 ? ( + + ) : ( + lenders.map((i) => ( +
+
+ + + +
+ {i.name} + + {i.code} · {typeLabel(i.type)} · {i.state} + +
+
+ + {i.branches.length > 0 && ( +
    + {i.branches.map((b) => ( +
  • + {b.name} + + {' '} + · {b.code} · {b.region} · blocks: {b.blocks} + +
  • + ))} +
+ )} +
+ )) + )} +
+
+
+ ) +} + +function typeLabel(type: string) { + return ( + { + RRB: 'Regional Rural Bank', + COOPERATIVE: 'Cooperative Bank', + SFB: 'Small Finance Bank', + COMMERCIAL: 'Commercial Bank', + }[type] ?? type + ) +} diff --git a/src/features/nabard/NabardShell.tsx b/src/features/nabard/NabardShell.tsx new file mode 100644 index 0000000..a6d36f8 --- /dev/null +++ b/src/features/nabard/NabardShell.tsx @@ -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. + * + *

Deliberately a different chrome from the bank console. A NABARD user and a + * branch officer are not two ranks of one job — they see different things and + * may do different things, and a portal that looked identical would invite the + * assumption that the apex user is simply a bank user with more rights. + */ +export function NabardShell() { + const navigate = useNavigate() + const session = officerSession() + + return ( +

+ + +
+ +
+
+ ) +} diff --git a/src/features/nabard/NationalDashboard.tsx b/src/features/nabard/NationalDashboard.tsx new file mode 100644 index 0000000..b0bfe64 --- /dev/null +++ b/src/features/nabard/NationalDashboard.tsx @@ -0,0 +1,165 @@ +import { useQuery } from '@tanstack/react-query' +import { EyeOff } from 'lucide-react' +import { get } from '../../lib/api' +import type { ApexCell, ApexDashboard } from '../../lib/api' +import { Card, Empty, StatusPill } from '../../components/ui/bits' +import { demoApexDashboard, demoDistricts, demoSectors } from '../../lib/demo/apex' +import { GeographyPanel } from './GeographyPanel' + +/** + * What NABARD sees first: the refinanced portfolio as a whole, then where the + * stress sits — by district, which is how ground-level credit is planned, and by + * sector, which is how a shock actually arrives. + */ +export function NationalDashboard() { + const dashQ = useQuery({ queryKey: ['apex-dashboard'], queryFn: () => get('/nabard/dashboard') }) + const distQ = useQuery({ queryKey: ['apex-districts'], queryFn: () => get('/nabard/districts') }) + const sectQ = useQuery({ queryKey: ['apex-sectors'], queryFn: () => get('/nabard/sectors') }) + + const demo = dashQ.isError || distQ.isError || sectQ.isError + const d = dashQ.data ?? demoApexDashboard() + const districts = distQ.data ?? demoDistricts() + const sectors = sectQ.data ?? demoSectors() + + return ( +
+
+
+

National view

+

+ Across {d.partnerBanks} refinanced institutions and {d.branches} branches + {d.dataAgeDays >= 0 && ` · scored ${d.dataAgeDays === 0 ? 'today' : `${d.dataAgeDays} days ago`}`} +

+
+ {demo && } +
+ +
+ + + + +
+ + + +
+ + +
+
+ ) +} + +function Tile({ label, value, note, tone = 'text-slate-800' }: { label: string; value: string; note: string; tone?: string }) { + return ( +
+
{label}
+
{value}
+
{note}
+
+ ) +} + +/** + * A suppressed cell keeps its row and loses its numbers. + * + *

Dropping the row entirely would read as "no enterprises here", which is a + * different and wrong claim — and the one a reader would most easily act on. + */ +function CellTable({ + title, + badge, + labelKey, + rows, + minCellSize, +}: { + title: string + badge: string + labelKey: 'districtName' | 'sectorName' + rows: ApexCell[] + minCellSize: number +}) { + const worst = Math.max(1, ...rows.filter((r) => !r.suppressed).map((r) => r.stressRatePct ?? 0)) + + return ( + +

+ {rows.length === 0 ? ( + + ) : ( + + + + + + + + + + + {rows.map((r) => { + const label = r.districtName ?? r.sectorName ?? '—' + if (r.suppressed) { + return ( + + + + + + ) + } + return ( + + + + + + + ) + })} + +
{labelKey === 'districtName' ? 'District' : 'Sector'}UnitsStressedStress rate
{label}{r.enterprises} + + + withheld — fewer than {minCellSize} borrowers + +
{label}{r.enterprises}{r.red} +
+ + + + + {r.stressRatePct}% + +
+
+ )} +

+ Counts and rates only. No row here identifies a borrower, and a cell with fewer than{' '} + {minCellSize} of them is withheld rather than rounded — with two borrowers and one alert, + publishing the rate would name them. +

+
+ + ) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 34c236f..9a0ab2f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -30,6 +30,24 @@ export interface EnterpriseSession { lang: Lang } +/** + * Which portal a console session belongs to. + * + *

Derived from the role rather than stored as its own field, so a tampered + * session cannot claim a tier its token does not carry. This only picks the + * landing page — the backend re-derives the tier from the signed JWT and refuses + * the other surface outright, so getting this wrong is a routing annoyance + * rather than an access-control hole. + */ +export function isApexRole(role: string | undefined): boolean { + return !!role && role.startsWith('NABARD_') +} + +export function homeFor(session: OfficerSession | null): string { + if (!session?.token) return '/login' + return isApexRole(session.role) ? '/nabard' : '/officer' +} + export function officerSession(): OfficerSession | null { const raw = sessionStorage.getItem('ul-officer-auth') if (!raw) return null @@ -456,3 +474,106 @@ export async function uploadManual(file: File, type: 'MARKET_SIGNAL' | 'LOAN_REC const res = await api.post(`/admin/manual/upload?type=${type}`, form) return res.data as { success: boolean; message: string; data: ManualEntryPreview } } + +// ── NABARD (apex) surface ──────────────────────────────────────── +// Aggregates only. Nothing here carries an enterprise id, by design — see +// ApexService on the backend. A cell with fewer borrowers than the platform's +// minimum is returned with suppressed=true rather than omitted, because a +// missing district reads as "no activity" and that is a different claim. + +export interface ApexDashboard { + partnerBanks: number + branches: number + enterprises: number + red: number + watch: number + standard: number + stressRatePct: number + portfolioValue: number + openAlerts: number + minCellSize: number + dataAgeDays: number +} + +export interface BankScorecardRow { + institutionCode: string + institutionName: string + shortName: string + type: string + branches: number + enterprises: number + suppressed: boolean + red?: number + watch?: number + standard?: number + stressRatePct?: number + avgScore?: number + alertsRaised?: number + alertsActioned?: number + /** Share of raised alerts a bank has acted on — the number a bank never self-reports. */ + responseRatePct?: number + outstanding?: number +} + +export interface ApexCell { + districtName?: string + sectorName?: string + enterprises: number + suppressed: boolean + red?: number + watch?: number + standard?: number + stressRatePct?: number + avgScore?: number +} + +export interface ApexDigest { + asOf: string + districtsUnderStress: ApexCell[] + sectorsUnderStress: ApexCell[] + slowestResponders: BankScorecardRow[] +} + +export interface InstitutionRow { + code: string + name: string + shortName: string + type: string + state: string + branches: { code: string; name: string; region: string; blocks: string }[] +} + +/** One row of the India → zone → state → district drill-down. */ +export interface GeoCell { + key: string + name: string + level: 'ZONE' | 'STATE' | 'DISTRICT' + zone?: string + zoneLabel?: string + state?: string + enterprises: number + suppressed: boolean + red?: number + watch?: number + standard?: number + stressRatePct?: number + avgScore?: number +} + +/** A partner bank's branch, as the apex tier sees it — aggregates, never borrowers. */ +export interface BranchCell { + name: string + branchCode: string + region: string + blocks: string + district: string + institutionCode: string + institutionName: string + enterprises: number + suppressed: boolean + red?: number + watch?: number + standard?: number + stressRatePct?: number + avgScore?: number +} diff --git a/src/lib/demo/apex.ts b/src/lib/demo/apex.ts new file mode 100644 index 0000000..071be14 --- /dev/null +++ b/src/lib/demo/apex.ts @@ -0,0 +1,170 @@ +import type { ApexCell, ApexDashboard, ApexDigest, BankScorecardRow, InstitutionRow } from '../api' + +/** + * Demo fallback for the NABARD portal. + * + *

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' }, + ], + }, + ] +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 9cac9eb..7617934 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -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('/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('/auth/app-login', { enterpriseId: id, pin: secret }) localStorage.setItem('ul-app-auth', JSON.stringify(session))