From 627e11e9a97affb4adc0a3c2af72f91a546cd982 Mon Sep 17 00:00:00 2001 From: LincPay Date: Sat, 15 Aug 2026 15:29:36 +0530 Subject: [PATCH] feat(nabard): regional office console, and the horticulture sector pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the fourth supervisory tier to the console — RegionalOfficesPage at /nabard/regional-offices, a nav entry, and a sidebar that names the tier (NABARD Head Office vs Regional Office) because NABARD HO and the Maharashtra RO are both in Mumbai and a drill-down labelled by city reads as circular. The login page gains a public /auth/regional-offices dropdown and a one-click persona panel grouped by tier. The dropdown only pre-fills credentials: scope still comes from the token's office claim, so picking Bhopal and signing in as Bengaluru shows Bengaluru. The geography drill gets a By regional office / By zone root toggle rather than an RO rung inserted into the zone ladder. They do not nest — Chandigarh supervises Punjab and Haryana across a zone boundary — and they answer different questions: a zone is weather and cropping pattern, an office is who is accountable. Both roots narrow through the same zone parameter. Two bugs worth naming, since both were invisible until they weren't: - isApexRole() matched only NABARD_*, so RO_ADMIN routed to the bank console — the borrower-facing surface, for the one user who may never see a borrower. The backend refused every call, so it read as an empty screen rather than a leak, but it was still the wrong door. - Adding a sector to the backend without the UI's Record maps white-screens the whole officer console on SECTORS[e.sector].short of undefined. HORTICULTURE needed seven maps. Widen the Sector type first and TypeScript finds every one of them. Bands read from the stored value, never recomputed from score. They differ because a fired trip-wire escalates the band without rewriting the score — Madhya Pradesh reads 35 RED by band and 5 by threshold, so recomputing made RED collapse when drilling HO -> RO on the same state. Co-Authored-By: Claude Opus 5 (1M context) --- src/App.tsx | 2 + src/features/nabard/GeographyPanel.tsx | 112 +++++++-- src/features/nabard/IndiaMap.tsx | 24 +- src/features/nabard/NabardShell.tsx | 15 +- src/features/nabard/RegionalOfficesPage.tsx | 226 +++++++++++++++++++ src/features/officer/SectorPortfolioPage.tsx | 3 + src/lib/api.ts | 62 ++++- src/lib/demo/enterprises.ts | 14 ++ src/pages/LoginPage.tsx | 178 +++++++++++++-- 9 files changed, 586 insertions(+), 50 deletions(-) create mode 100644 src/features/nabard/RegionalOfficesPage.tsx 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 && ( + + )} +