diff --git a/src/App.tsx b/src/App.tsx index 41f72aa..4024482 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { enterpriseSession, officerSession, homeFor, isApexRole } from './lib/ap import { OfficerShell } from './features/officer/OfficerShell' import { NabardShell } from './features/nabard/NabardShell' import { NationalDashboard } from './features/nabard/NationalDashboard' +import { RegionalOfficesPage } from './features/nabard/RegionalOfficesPage' import { BankScorecardPage } from './features/nabard/BankScorecardPage' import { DigestPage } from './features/nabard/DigestPage' import { InstitutionsPage } from './features/nabard/InstitutionsPage' @@ -116,6 +117,7 @@ export default function App() { } > } /> + } /> } /> } /> } /> diff --git a/src/features/nabard/GeographyPanel.tsx b/src/features/nabard/GeographyPanel.tsx index 4df1f15..c4ef49c 100644 --- a/src/features/nabard/GeographyPanel.tsx +++ b/src/features/nabard/GeographyPanel.tsx @@ -7,7 +7,8 @@ import { Card, Empty } from '../../components/ui/bits' import { IndiaMap } from './IndiaMap' import type { StateDatum } from './IndiaMap' -type Level = 'ZONE' | 'STATE' | 'DISTRICT' +type Level = 'RO' | 'ZONE' | 'STATE' | 'DISTRICT' +type Root = 'RO' | 'ZONE' /** * India → zone → state → district. @@ -24,8 +25,16 @@ type Level = 'ZONE' | 'STATE' | 'DISTRICT' export function GeographyPanel({ minCellSize }: { minCellSize: number }) { const [zone, setZone] = useState(null) const [state, setState] = useState(null) + /* + * Two ways into the same portfolio, and they are genuinely different + * questions. Zone is where the weather and the cropping pattern are; regional + * office is who is accountable for the bank. They do not nest — Chandigarh + * supervises Punjab and Haryana across a zone boundary — so this is a choice + * of root, not another rung on one ladder. + */ + const [root, setRoot] = useState('RO') - const level: Level = state ? 'DISTRICT' : zone ? 'STATE' : 'ZONE' + const level: Level = state ? 'DISTRICT' : zone ? 'STATE' : root const { data, isError } = useQuery({ queryKey: ['apex-geo', level, zone, state], @@ -45,29 +54,88 @@ export function GeographyPanel({ minCellSize }: { minCellSize: number }) { queryKey: ['apex-geo-map'], queryFn: () => get('/nabard/geography', { level: 'STATE' }), }) - const mapData: StateDatum[] = (mapQ.data ?? []).map((r) => ({ - state: r.name, - enterprises: r.enterprises, - red: r.red, - stressRatePct: r.stressRatePct, - suppressed: r.suppressed, - })) + + // Office rollups, so the map can shade by supervisory boundary rather than by + // state. Fetched rather than summed from the state rows because suppression + // is decided on the whole office: two states of three borrowers each are + // publishable as one office of six, and adding up suppressed cells client-side + // would either leak that or withhold something the API is willing to publish. + const officeQ = useQuery({ + queryKey: ['apex-geo-offices'], + queryFn: () => get('/nabard/geography', { level: 'RO' }), + }) + + const mapData: StateDatum[] = (mapQ.data ?? []).map((r) => { + if (root === 'RO') { + const office = (officeQ.data ?? []).find((o) => o.key === r.office) + if (office) { + return { + state: r.name, + enterprises: office.enterprises, + red: office.red, + stressRatePct: office.stressRatePct, + suppressed: office.suppressed, + groupLabel: office.name, + } + } + } + return { + state: r.name, + enterprises: r.enterprises, + red: r.red, + stressRatePct: r.stressRatePct, + suppressed: r.suppressed, + } + }) const worst = Math.max(1, ...rows.filter((r) => !r.suppressed).map((r) => r.stressRatePct ?? 0)) return (
+
+ {(['RO', 'ZONE'] as Root[]).map((r) => ( + + ))} +
+
- { setZone(null); setState(null) }} /> + { setZone(null); setState(null) }} + /> {zone && ( <> setState(null)} /> @@ -89,10 +157,12 @@ export function GeographyPanel({ minCellSize }: { minCellSize: number }) { selected={state} onSelect={(picked) => { // Selecting on the map jumps straight to that state's districts, - // and sets the zone crumb so the trail still makes sense. + // and sets the crumb above it so the trail still makes sense — + // the office in office mode, the zone in zone mode. const row = (mapQ.data ?? []).find((r) => r.name === picked) if (!row) return - if (row.zone) setZone(row.zone) + const parent = root === 'RO' ? row.office : row.zone + if (parent) setZone(parent) setState(picked) }} /> @@ -107,7 +177,15 @@ export function GeographyPanel({ minCellSize }: { minCellSize: number }) { - + @@ -122,7 +200,9 @@ export function GeographyPanel({ minCellSize }: { minCellSize: number }) { key={r.key} className={canDrill ? 'cursor-pointer' : ''} onClick={() => { - if (level === 'ZONE') setZone(r.key) + // Both roots narrow through the same parameter — the + // backend accepts a zone name or an office code there. + if (level === 'RO' || level === 'ZONE') setZone(r.key) else if (level === 'STATE') setState(r.key) }} > diff --git a/src/features/nabard/IndiaMap.tsx b/src/features/nabard/IndiaMap.tsx index 4bca198..bc00823 100644 --- a/src/features/nabard/IndiaMap.tsx +++ b/src/features/nabard/IndiaMap.tsx @@ -8,6 +8,15 @@ export interface StateDatum { red?: number stressRatePct?: number suppressed: boolean + /** + * What this shape's figures actually describe, when that is not the state + * itself — the supervising regional office, in office mode. + * + *

Without it the map would shade Punjab and Haryana identically with no + * indication why, which reads as a coincidence rather than as one office. The + * label is what turns a repeated colour into a boundary. + */ + groupLabel?: string } /** @@ -111,7 +120,12 @@ export function IndiaMap({

{activeDatum ? ( <> - {active} + + {activeDatum.groupLabel ?? active} + + {activeDatum.groupLabel && ( + {active} + )} {activeDatum.suppressed ? ( {activeDatum.enterprises} enterprises · figures withheld below {minCellSize} @@ -201,10 +215,14 @@ function fillFor(datum: StateDatum | undefined, worst: number): string { function titleFor(state: string, datum: StateDatum | undefined, minCellSize: number): string { if (!datum) return `${state} — no refinanced portfolio` + // In office mode the figures belong to the office, so the tooltip names it + // first and the state second — otherwise Punjab would appear to have + // Haryana's borrowers. + const subject = datum.groupLabel ? `${datum.groupLabel} (${state})` : state if (datum.suppressed) { - return `${state} — ${datum.enterprises} enterprises, figures withheld below ${minCellSize}` + return `${subject} — ${datum.enterprises} enterprises, figures withheld below ${minCellSize}` } - return `${state} — ${datum.red} of ${datum.enterprises} stressed (${datum.stressRatePct}%)` + return `${subject} — ${datum.red} of ${datum.enterprises} stressed (${datum.stressRatePct}%)` } /** The dataset and the platform spell a few states differently. */ diff --git a/src/features/nabard/NabardShell.tsx b/src/features/nabard/NabardShell.tsx index a6d36f8..b8f6e2a 100644 --- a/src/features/nabard/NabardShell.tsx +++ b/src/features/nabard/NabardShell.tsx @@ -1,9 +1,10 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom' -import { Building2, Gauge, Landmark, ShieldCheck, Siren } from 'lucide-react' -import { clearSessions, officerSession } from '../../lib/api' +import { Building2, Gauge, Landmark, MapPinned, ShieldCheck, Siren } from 'lucide-react' +import { clearSessions, isRegionalOfficeRole, officerSession } from '../../lib/api' const NAV = [ { label: 'National view', to: '/nabard', end: true, icon: Gauge }, + { label: 'Regional offices', to: '/nabard/regional-offices', icon: MapPinned }, { label: 'Partner banks', to: '/nabard/banks', icon: Landmark }, { label: 'Early warning', to: '/nabard/digest', icon: Siren }, { label: 'Institutions', to: '/nabard/institutions', icon: Building2 }, @@ -30,7 +31,15 @@ export function NabardShell() { UdyamLinc - NABARD Supervisory View + {/* + Head office and a regional office are both NABARD and see the same + kinds of figures over different territory. Naming which one is + signed in stops a supervisor reading a single office's numbers as + the national picture. + */} + + {isRegionalOfficeRole(session?.role) ? 'NABARD Regional Office' : 'NABARD Head Office'} +
diff --git a/src/features/nabard/RegionalOfficesPage.tsx b/src/features/nabard/RegionalOfficesPage.tsx new file mode 100644 index 0000000..a3aab7a --- /dev/null +++ b/src/features/nabard/RegionalOfficesPage.tsx @@ -0,0 +1,226 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ChevronDown, ChevronRight, EyeOff, MapPin } from 'lucide-react' +import { get, isRegionalOfficeRole, officerSession } from '../../lib/api' +import type { RegionalOfficeBankRow, RegionalOfficeRow } from '../../lib/api' +import { Card, Empty, StatusPill } from '../../components/ui/bits' + +/** + * NABARD's Regional Offices, and the banks each one supervises. + * + *

The tier between head office and a partner bank. An office is scoped by the + * states it covers, so a bank belongs to it through its branches rather than + * through anything recorded against the bank itself — which is why an SFB + * operating in three states appears under three offices and an RRB under one. + * + *

The same page serves both audiences. Head office sees every office and can + * drill into any of them; a Regional Office user sees exactly one, because the + * office on their token is the only one the API will answer for. Nothing here + * asks which office to show — a tenancy key the screen can name is not a tenancy + * key. + */ +export function RegionalOfficesPage() { + const [open, setOpen] = useState(null) + const session = officerSession() + const scoped = isRegionalOfficeRole(session?.role) + + const { data, isError, isLoading } = useQuery({ + queryKey: ['apex-regional-offices'], + queryFn: () => get('/nabard/regional-offices'), + }) + const offices = data ?? [] + + return ( +

+
+
+

+ {scoped ? 'Your regional office' : 'Regional offices'} +

+

+ {scoped + ? 'The banks your office supervises, and how their portfolios are holding up.' + : 'NABARD supervises partner banks through its regional offices. Expand one to see its banks.'} +

+
+ {isError && } +
+ + +
+ {isLoading ? ( + + ) : offices.length === 0 ? ( + + ) : ( +
{level === 'ZONE' ? 'Zone' : level === 'STATE' ? 'State' : 'District'} + {level === 'RO' + ? 'Regional office' + : level === 'ZONE' + ? 'Zone' + : level === 'STATE' + ? 'State' + : 'District'} + Units Stressed Stress rate
+ + + + + + + + + + + + + {offices.map((o) => ( + setOpen(open === o.code ? null : o.code)} + /> + ))} + +
OfficeStates coveredBanksBranchesUnitsStressedStress rate +
+ )} +
+ +
+ ) +} + +function OfficeRow({ + office, + expanded, + onToggle, +}: { + office: RegionalOfficeRow + expanded: boolean + onToggle: () => void +}) { + return ( + <> + + +
+ {expanded ? : } + + {/* + The office name, not the city. NABARD's head office and its + Maharashtra regional office are both in Mumbai, so a column + showing only the city makes the drill-down read as circular. + */} + {office.name} + + + {office.city} + {office.officeType === 'LOCAL_OFFICE' && ' · Local Office'} + + +
+ + {office.states.join(', ')} + {office.banks} + {office.branches} + {office.enterprises} + + + + + {expanded && ( + + + + + + )} + + ) +} + +/** + * A cell that may be withheld. + * + *

Rendered as an explicit "withheld" marker rather than a dash or a zero. + * Suppression bites harder at office level than nationally — an office + * supervising one bank of twenty borrowers has genuinely small cells — and a + * blank would read as "no stress here" when it means "too few borrowers to say + * without pointing at one of them". + */ +function Suppressible({ + value, + suppressed, + suffix = '', +}: { + value?: number | null + suppressed: boolean + suffix?: string +}) { + if (suppressed || value == null) { + return ( + + + + withheld + + + ) + } + return ( + + {value} + {suffix} + + ) +} + +function OfficeBanks({ code }: { code: string }) { + const { data, isLoading, isError } = useQuery({ + queryKey: ['apex-ro-banks', code], + queryFn: () => get(`/nabard/regional-offices/${code}/banks`), + }) + + if (isLoading) return

Loading banks…
+ if (isError) + return
Banks unavailable.
+ const banks = data ?? [] + if (banks.length === 0) + return
No banks in this office yet.
+ + return ( +
+ + + + + + + + + + + + + {banks.map((b) => ( + + + + + + + + + ))} + +
BankTypeUnitsStressedWatchStress rate
+ {b.institutionName} + {b.institutionCode} + {b.type}{b.enterprises}
+
+ ) +} diff --git a/src/features/officer/SectorPortfolioPage.tsx b/src/features/officer/SectorPortfolioPage.tsx index b01e975..45e65a7 100644 --- a/src/features/officer/SectorPortfolioPage.tsx +++ b/src/features/officer/SectorPortfolioPage.tsx @@ -7,6 +7,7 @@ import { Scissors, Search, ShieldAlert, + Sprout, Store, TriangleAlert, Wheat, @@ -38,6 +39,7 @@ const SECTOR_ICON: Record = { FOOD_PROC: Wheat, HANDICRAFTS: Scissors, RURAL_RETAIL: Store, + HORTICULTURE: Sprout, } /** What drives risk in each sector — the sentence under the name. */ @@ -47,6 +49,7 @@ const SECTOR_HINT: Record = { FOOD_PROC: 'mandi linkage · wheat and gram input prices', HANDICRAFTS: 'buyer concentration · seasonal order flow', RURAL_RETAIL: 'UPI inflow signals · agri-input demand', + HORTICULTURE: 'mandi price collapse · unseasonal rain in the picking window', } type Filter = 'all' | Band diff --git a/src/lib/api.ts b/src/lib/api.ts index 3367192..421896c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -40,7 +40,17 @@ export interface EnterpriseSession { * rather than an access-control hole. */ export function isApexRole(role: string | undefined): boolean { - return !!role && role.startsWith('NABARD_') + // RO_* belongs here too. A Regional Office is the apex tier narrowed to one + // office, not a bank — sending an RO user to the bank console would land the + // one kind of user who may never see a borrower on the screens made of them. + // The backend would refuse every call, so the symptom would be an empty + // console rather than a leak, but it is still the wrong door. + return !!role && (role.startsWith('NABARD_') || role.startsWith('RO_')) +} + +/** True for a Regional Office session — apex, but pinned to one office. */ +export function isRegionalOfficeRole(role: string | undefined): boolean { + return !!role && role.startsWith('RO_') } export function homeFor(session: OfficerSession | null): string { @@ -132,7 +142,13 @@ export function errMsg(e: unknown): string { export type Lang = 'hi' | 'en' /** The five sector risk packs from the deck. */ -export type Sector = 'DAIRY' | 'POULTRY' | 'FOOD_PROC' | 'HANDICRAFTS' | 'RURAL_RETAIL' +export type Sector = + | 'DAIRY' + | 'POULTRY' + | 'FOOD_PROC' + | 'HANDICRAFTS' + | 'RURAL_RETAIL' + | 'HORTICULTURE' /** Composite Stress Score bands. Red escalates, Watch is monitored, Standard is healthy. */ export type Band = 'RED' | 'WATCH' | 'STANDARD' @@ -642,6 +658,44 @@ export interface ApexDashboard { dataAgeDays: number } +/** + * A NABARD Regional Office and the portfolio it supervises. + * + *

{@link states} is a list because an office can cover more than one — + * Chandigarh takes Punjab and Haryana, Mumbai takes Maharashtra and Goa. + */ +export interface RegionalOfficeRow { + code: string + name: string + nameHi: string + city: string + officeType: string + states: string[] + banks: number + branches: number + enterprises: number + suppressed: boolean + red?: number | null + watch?: number | null + standard?: number | null + stressRatePct?: number | null + outstanding?: number | null +} + +/** One bank as seen from the office that supervises it. */ +export interface RegionalOfficeBankRow { + institutionCode: string + institutionName: string + type: string + enterprises: number + suppressed: boolean + red?: number | null + watch?: number | null + standard?: number | null + stressRatePct?: number | null + outstanding?: number | null +} + export interface BankScorecardRow { institutionCode: string institutionName: string @@ -694,9 +748,11 @@ export interface InstitutionRow { export interface GeoCell { key: string name: string - level: 'ZONE' | 'STATE' | 'DISTRICT' + level: 'RO' | 'ZONE' | 'STATE' | 'DISTRICT' zone?: string zoneLabel?: string + /** Regional office code the row rolls up into — present at every level. */ + office?: string state?: string enterprises: number suppressed: boolean diff --git a/src/lib/demo/enterprises.ts b/src/lib/demo/enterprises.ts index 100270d..be19086 100644 --- a/src/lib/demo/enterprises.ts +++ b/src/lib/demo/enterprises.ts @@ -38,6 +38,7 @@ export const SECTORS: Record = { FOOD_PROC: { en: ['Food Products', 'Flour Mill', 'Masala Udyog', 'Agro Foods'], hi: ['फ़ूड प्रोडक्ट्स', 'आटा चक्की', 'मसाला उद्योग', 'एग्रो फ़ूड्स'] }, HANDICRAFTS: { en: ['Handicrafts', 'Handloom', 'Craft Works'], hi: ['हस्तशिल्प', 'हथकरघा', 'क्राफ़्ट वर्क्स'] }, RURAL_RETAIL: { en: ['Kirana Store', 'General Store', 'Provision Store'], hi: ['किराना स्टोर', 'जनरल स्टोर', 'प्रोविज़न स्टोर'] }, + HORTICULTURE: { en: ['Farms', 'Bagwani Udyan', 'Nursery', 'Fruit Farm'], hi: ['फ़ार्म्स', 'बागवानी उद्यान', 'नर्सरी', 'फ़्रूट फ़ार्म'] }, } /** Sector weights applied to the five sub-scores (deck feature 2). */ @@ -85,6 +87,11 @@ export const SECTOR_WEIGHTS: Record = { FOOD_PROC: { liquidity: 0.26, behavioural: 0.22, trendVolatility: 0.2, obligationLoad: 0.18, externalShock: 0.14 }, HANDICRAFTS: { liquidity: 0.24, behavioural: 0.18, trendVolatility: 0.2, obligationLoad: 0.16, externalShock: 0.22 }, RURAL_RETAIL: { liquidity: 0.3, behavioural: 0.24, trendVolatility: 0.18, obligationLoad: 0.18, externalShock: 0.1 }, + // Mirrors the backend pack: the heaviest external-shock weight of the six and + // the lightest obligation load. A grower's stress arrives as a price collapse + // or a hailstorm inside one week, where a dairy's arrives as a margin that + // thins over a season. + HORTICULTURE: { liquidity: 0.26, behavioural: 0.16, trendVolatility: 0.2, obligationLoad: 0.12, externalShock: 0.26 }, } /** Band plan so the totals always match the deck: 9 red, 31 watch, 102 standard. */ @@ -279,6 +286,13 @@ const REASON_TEXT: RecordGrouped by what each tier may see rather than by rank, because they are not + * ranks: head office reads the whole country in aggregate and no borrower at + * all; a regional office reads the banks in its states and no borrower at all; a + * bank reads its own book in full. The subtitle on each row says which, so the + * restriction is visible before you sign in rather than inferred from an empty + * screen afterwards. + */ +const PERSONAS: { + tier: string + note: string + accounts: { label: string; username: string; secret: string; mode: Mode }[] +}[] = [ + { + tier: 'NABARD head office', + note: 'every partner bank, aggregates only — no borrower reachable', + accounts: [ + { label: 'Administrator', username: 'nabard.admin', secret: 'Udyam@123', mode: 'OFFICER' }, + { label: 'Officer', username: 'nabard.officer', secret: 'Udyam@123', mode: 'OFFICER' }, + ], + }, + { + tier: 'NABARD regional office', + note: 'only the banks in its own states — no borrower reachable', + accounts: [ + { label: 'Bhopal · MP', username: 'bhopal.ro', secret: 'Udyam@123', mode: 'OFFICER' }, + { label: 'Bengaluru · Karnataka', username: 'bengaluru.ro', secret: 'Udyam@123', mode: 'OFFICER' }, + { label: 'Patna · Bihar', username: 'patna.ro', secret: 'Udyam@123', mode: 'OFFICER' }, + ], + }, + { + tier: 'Partner bank', + note: 'its own book in full, borrower records included', + accounts: [ + { label: 'MP Gramin · head office', username: 'mpgb.admin', secret: 'Udyam@123', mode: 'OFFICER' }, + { label: 'Sehore DCCB · head office', username: 'dccb.admin', secret: 'Udyam@123', mode: 'OFFICER' }, + { label: 'Field officer', username: 'aditya.vyas', secret: 'Udyam@123', mode: 'OFFICER' }, + ], + }, + { + tier: 'Enterprise', + note: 'one borrower, itself', + accounts: [{ label: 'MSME-4417', username: 'MSME-4417', secret: '1234', mode: 'ENTERPRISE' }], + }, +] + +interface OfficeOption { + code: string + name: string + city: string + username: string +} + export function LoginPage() { const navigate = useNavigate() const [mode, setMode] = useState('OFFICER') @@ -53,39 +109,54 @@ export function LoginPage() { const [error, setError] = useState('') const [busy, setBusy] = useState(false) const [slide, setSlide] = useState(0) + const [offices, setOffices] = useState([]) + const [office, setOffice] = useState('') useEffect(() => { const id = setInterval(() => setSlide((s) => (s + 1) % SLIDES.length), 6500) return () => clearInterval(id) }, []) + // The office directory is public and carries no portfolio figures, so it can + // be fetched before anyone has signed in. + useEffect(() => { + get('/auth/regional-offices') + .then(setOffices) + .catch(() => setOffices([])) + }, []) + /** * Sign in against the backend; if it is unreachable (the UI-first default), * fall back to the seeded demo credentials so every screen stays walkable. */ async function handleLogin(e: React.FormEvent) { e.preventDefault() + await signIn(identifier.trim(), secret, mode) + } + + async function signIn(id: string, pass: string, m: Mode) { setError('') - const id = identifier.trim() - if (!id || !secret.trim()) { - setError(mode === 'OFFICER' ? 'Enter username and password' : 'उद्यम आईडी और पिन डालें') + if (!id || !pass.trim()) { + setError(m === 'OFFICER' ? 'Enter username and password' : 'उद्यम आईडी और पिन डालें') return } setBusy(true) try { - if (mode === 'OFFICER') { - const session = await post('/auth/officer-login', { username: id, password: secret }) + if (m === 'OFFICER') { + const session = await post('/auth/officer-login', { username: id, password: pass }) sessionStorage.setItem('ul-officer-auth', JSON.stringify(session)) - // NABARD and bank staff share one sign-in form and land in different - // portals — the role on the session decides which. + // NABARD staff, regional-office staff and bank staff share one sign-in + // form and land in different portals — the role on the session decides + // which, and the backend re-derives the tier from the signed token + // regardless of where the browser ends up. navigate(homeFor(session), { replace: true }) } else { - const session = await post('/auth/app-login', { enterpriseId: id, pin: secret }) + const session = await post('/auth/app-login', { enterpriseId: id, pin: pass }) localStorage.setItem('ul-app-auth', JSON.stringify(session)) navigate('/app', { replace: true }) } } catch (err) { - const fallback = demoLogin(mode, id, secret) + const fallback = demoLogin(m, id, pass) if (fallback) { navigate(fallback, { replace: true }) return @@ -199,6 +270,41 @@ export function LoginPage() {

+ {/* + Office picker, shown only on the console form. It fills the username + and nothing else: what the session may read comes from the office + claim inside the signed token, so picking Bhopal here and signing in + with Bengaluru's credentials shows Bengaluru. An office a caller can + select from a list is not a tenancy key. + */} + {mode === 'OFFICER' && offices.length > 0 && ( + + )} +