feat(nabard): regional office console, and the horticulture sector pack
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<Sector, ...> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { enterpriseSession, officerSession, homeFor, isApexRole } from './lib/ap
|
|||||||
import { OfficerShell } from './features/officer/OfficerShell'
|
import { OfficerShell } from './features/officer/OfficerShell'
|
||||||
import { NabardShell } from './features/nabard/NabardShell'
|
import { NabardShell } from './features/nabard/NabardShell'
|
||||||
import { NationalDashboard } from './features/nabard/NationalDashboard'
|
import { NationalDashboard } from './features/nabard/NationalDashboard'
|
||||||
|
import { RegionalOfficesPage } from './features/nabard/RegionalOfficesPage'
|
||||||
import { BankScorecardPage } from './features/nabard/BankScorecardPage'
|
import { BankScorecardPage } from './features/nabard/BankScorecardPage'
|
||||||
import { DigestPage } from './features/nabard/DigestPage'
|
import { DigestPage } from './features/nabard/DigestPage'
|
||||||
import { InstitutionsPage } from './features/nabard/InstitutionsPage'
|
import { InstitutionsPage } from './features/nabard/InstitutionsPage'
|
||||||
@@ -116,6 +117,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<NationalDashboard />} />
|
<Route index element={<NationalDashboard />} />
|
||||||
|
<Route path="regional-offices" element={<RegionalOfficesPage />} />
|
||||||
<Route path="banks" element={<BankScorecardPage />} />
|
<Route path="banks" element={<BankScorecardPage />} />
|
||||||
<Route path="digest" element={<DigestPage />} />
|
<Route path="digest" element={<DigestPage />} />
|
||||||
<Route path="institutions" element={<InstitutionsPage />} />
|
<Route path="institutions" element={<InstitutionsPage />} />
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { Card, Empty } from '../../components/ui/bits'
|
|||||||
import { IndiaMap } from './IndiaMap'
|
import { IndiaMap } from './IndiaMap'
|
||||||
import type { StateDatum } 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.
|
* India → zone → state → district.
|
||||||
@@ -24,8 +25,16 @@ type Level = 'ZONE' | 'STATE' | 'DISTRICT'
|
|||||||
export function GeographyPanel({ minCellSize }: { minCellSize: number }) {
|
export function GeographyPanel({ minCellSize }: { minCellSize: number }) {
|
||||||
const [zone, setZone] = useState<string | null>(null)
|
const [zone, setZone] = useState<string | null>(null)
|
||||||
const [state, setState] = useState<string | null>(null)
|
const [state, setState] = useState<string | null>(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<Root>('RO')
|
||||||
|
|
||||||
const level: Level = state ? 'DISTRICT' : zone ? 'STATE' : 'ZONE'
|
const level: Level = state ? 'DISTRICT' : zone ? 'STATE' : root
|
||||||
|
|
||||||
const { data, isError } = useQuery({
|
const { data, isError } = useQuery({
|
||||||
queryKey: ['apex-geo', level, zone, state],
|
queryKey: ['apex-geo', level, zone, state],
|
||||||
@@ -45,29 +54,88 @@ export function GeographyPanel({ minCellSize }: { minCellSize: number }) {
|
|||||||
queryKey: ['apex-geo-map'],
|
queryKey: ['apex-geo-map'],
|
||||||
queryFn: () => get<GeoCell[]>('/nabard/geography', { level: 'STATE' }),
|
queryFn: () => get<GeoCell[]>('/nabard/geography', { level: 'STATE' }),
|
||||||
})
|
})
|
||||||
const mapData: StateDatum[] = (mapQ.data ?? []).map((r) => ({
|
|
||||||
state: r.name,
|
// Office rollups, so the map can shade by supervisory boundary rather than by
|
||||||
enterprises: r.enterprises,
|
// state. Fetched rather than summed from the state rows because suppression
|
||||||
red: r.red,
|
// is decided on the whole office: two states of three borrowers each are
|
||||||
stressRatePct: r.stressRatePct,
|
// publishable as one office of six, and adding up suppressed cells client-side
|
||||||
suppressed: r.suppressed,
|
// would either leak that or withhold something the API is willing to publish.
|
||||||
}))
|
const officeQ = useQuery({
|
||||||
|
queryKey: ['apex-geo-offices'],
|
||||||
|
queryFn: () => get<GeoCell[]>('/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))
|
const worst = Math.max(1, ...rows.filter((r) => !r.suppressed).map((r) => r.stressRatePct ?? 0))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title="Geography"
|
title="Geography"
|
||||||
badge={level === 'ZONE' ? 'India, by zone' : level === 'STATE' ? 'by state' : 'by district'}
|
badge={
|
||||||
|
level === 'RO'
|
||||||
|
? 'India, by regional office'
|
||||||
|
: level === 'ZONE'
|
||||||
|
? 'India, by zone'
|
||||||
|
: level === 'STATE'
|
||||||
|
? 'by state'
|
||||||
|
: 'by district'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
<div className="flex items-center gap-1.5 mb-3">
|
||||||
|
{(['RO', 'ZONE'] as Root[]).map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setRoot(r)
|
||||||
|
setZone(null)
|
||||||
|
setState(null)
|
||||||
|
}}
|
||||||
|
className={`text-[11px] px-2.5 py-1 rounded-md border transition-colors ${
|
||||||
|
root === r
|
||||||
|
? 'border-forest text-forest bg-forest/5 font-semibold'
|
||||||
|
: 'border-slate-200 text-slate-500 hover:border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r === 'RO' ? 'By regional office' : 'By zone'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 text-[11.5px] text-slate-500 mb-3 flex-wrap">
|
<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) }} />
|
<Crumb
|
||||||
|
label="India"
|
||||||
|
active={level === 'RO' || level === 'ZONE'}
|
||||||
|
onClick={() => { setZone(null); setState(null) }}
|
||||||
|
/>
|
||||||
{zone && (
|
{zone && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight size={12} />
|
<ChevronRight size={12} />
|
||||||
<Crumb
|
<Crumb
|
||||||
label={rows[0]?.zoneLabel ?? zone}
|
label={root === 'RO' ? (rows[0]?.office ?? zone) : (rows[0]?.zoneLabel ?? zone)}
|
||||||
active={level === 'STATE'}
|
active={level === 'STATE'}
|
||||||
onClick={() => setState(null)}
|
onClick={() => setState(null)}
|
||||||
/>
|
/>
|
||||||
@@ -89,10 +157,12 @@ export function GeographyPanel({ minCellSize }: { minCellSize: number }) {
|
|||||||
selected={state}
|
selected={state}
|
||||||
onSelect={(picked) => {
|
onSelect={(picked) => {
|
||||||
// Selecting on the map jumps straight to that state's districts,
|
// 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)
|
const row = (mapQ.data ?? []).find((r) => r.name === picked)
|
||||||
if (!row) return
|
if (!row) return
|
||||||
if (row.zone) setZone(row.zone)
|
const parent = root === 'RO' ? row.office : row.zone
|
||||||
|
if (parent) setZone(parent)
|
||||||
setState(picked)
|
setState(picked)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -107,7 +177,15 @@ export function GeographyPanel({ minCellSize }: { minCellSize: number }) {
|
|||||||
<table className="ul-table w-full">
|
<table className="ul-table w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{level === 'ZONE' ? 'Zone' : level === 'STATE' ? 'State' : 'District'}</th>
|
<th>
|
||||||
|
{level === 'RO'
|
||||||
|
? 'Regional office'
|
||||||
|
: level === 'ZONE'
|
||||||
|
? 'Zone'
|
||||||
|
: level === 'STATE'
|
||||||
|
? 'State'
|
||||||
|
: 'District'}
|
||||||
|
</th>
|
||||||
<th className="text-right">Units</th>
|
<th className="text-right">Units</th>
|
||||||
<th className="text-right">Stressed</th>
|
<th className="text-right">Stressed</th>
|
||||||
<th>Stress rate</th>
|
<th>Stress rate</th>
|
||||||
@@ -122,7 +200,9 @@ export function GeographyPanel({ minCellSize }: { minCellSize: number }) {
|
|||||||
key={r.key}
|
key={r.key}
|
||||||
className={canDrill ? 'cursor-pointer' : ''}
|
className={canDrill ? 'cursor-pointer' : ''}
|
||||||
onClick={() => {
|
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)
|
else if (level === 'STATE') setState(r.key)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ export interface StateDatum {
|
|||||||
red?: number
|
red?: number
|
||||||
stressRatePct?: number
|
stressRatePct?: number
|
||||||
suppressed: boolean
|
suppressed: boolean
|
||||||
|
/**
|
||||||
|
* What this shape's figures actually describe, when that is not the state
|
||||||
|
* itself — the supervising regional office, in office mode.
|
||||||
|
*
|
||||||
|
* <p>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({
|
|||||||
<div className="absolute top-1 left-1 bg-white/95 border border-slate-200 rounded-[9px] px-3 py-2 min-w-[168px] pointer-events-none">
|
<div className="absolute top-1 left-1 bg-white/95 border border-slate-200 rounded-[9px] px-3 py-2 min-w-[168px] pointer-events-none">
|
||||||
{activeDatum ? (
|
{activeDatum ? (
|
||||||
<>
|
<>
|
||||||
<b className="text-[12.5px] text-slate-800 block">{active}</b>
|
<b className="text-[12.5px] text-slate-800 block">
|
||||||
|
{activeDatum.groupLabel ?? active}
|
||||||
|
</b>
|
||||||
|
{activeDatum.groupLabel && (
|
||||||
|
<span className="text-[10px] text-slate-400 block leading-tight">{active}</span>
|
||||||
|
)}
|
||||||
{activeDatum.suppressed ? (
|
{activeDatum.suppressed ? (
|
||||||
<span className="text-[11px] text-slate-500">
|
<span className="text-[11px] text-slate-500">
|
||||||
{activeDatum.enterprises} enterprises · figures withheld below {minCellSize}
|
{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 {
|
function titleFor(state: string, datum: StateDatum | undefined, minCellSize: number): string {
|
||||||
if (!datum) return `${state} — no refinanced portfolio`
|
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) {
|
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. */
|
/** The dataset and the platform spell a few states differently. */
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||||
import { Building2, Gauge, Landmark, ShieldCheck, Siren } from 'lucide-react'
|
import { Building2, Gauge, Landmark, MapPinned, ShieldCheck, Siren } from 'lucide-react'
|
||||||
import { clearSessions, officerSession } from '../../lib/api'
|
import { clearSessions, isRegionalOfficeRole, officerSession } from '../../lib/api'
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ label: 'National view', to: '/nabard', end: true, icon: Gauge },
|
{ 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: 'Partner banks', to: '/nabard/banks', icon: Landmark },
|
||||||
{ label: 'Early warning', to: '/nabard/digest', icon: Siren },
|
{ label: 'Early warning', to: '/nabard/digest', icon: Siren },
|
||||||
{ label: 'Institutions', to: '/nabard/institutions', icon: Building2 },
|
{ label: 'Institutions', to: '/nabard/institutions', icon: Building2 },
|
||||||
@@ -30,7 +31,15 @@ export function NabardShell() {
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<b className="text-[16px] block leading-tight">UdyamLinc</b>
|
<b className="text-[16px] block leading-tight">UdyamLinc</b>
|
||||||
<span className="text-[10.5px] text-[#bcd6cb]">NABARD Supervisory View</span>
|
{/*
|
||||||
|
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.
|
||||||
|
*/}
|
||||||
|
<span className="text-[10.5px] text-[#bcd6cb]">
|
||||||
|
{isRegionalOfficeRole(session?.role) ? 'NABARD Regional Office' : 'NABARD Head Office'}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<string | null>(null)
|
||||||
|
const session = officerSession()
|
||||||
|
const scoped = isRegionalOfficeRole(session?.role)
|
||||||
|
|
||||||
|
const { data, isError, isLoading } = useQuery({
|
||||||
|
queryKey: ['apex-regional-offices'],
|
||||||
|
queryFn: () => get<RegionalOfficeRow[]>('/nabard/regional-offices'),
|
||||||
|
})
|
||||||
|
const offices = data ?? []
|
||||||
|
|
||||||
|
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">
|
||||||
|
{scoped ? 'Your regional office' : 'Regional offices'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isError && <StatusPill status="Unavailable" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={scoped ? 'Supervised portfolio' : 'Offices with a portfolio'}
|
||||||
|
badge="aggregates only"
|
||||||
|
>
|
||||||
|
<div className="p-4 overflow-x-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<Empty text="Loading offices…" />
|
||||||
|
) : offices.length === 0 ? (
|
||||||
|
<Empty text="No regional office currently supervises a portfolio." />
|
||||||
|
) : (
|
||||||
|
<table className="ul-table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Office</th>
|
||||||
|
<th>States covered</th>
|
||||||
|
<th className="text-right">Banks</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 />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{offices.map((o) => (
|
||||||
|
<OfficeRow
|
||||||
|
key={o.code}
|
||||||
|
office={o}
|
||||||
|
expanded={open === o.code}
|
||||||
|
onToggle={() => setOpen(open === o.code ? null : o.code)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OfficeRow({
|
||||||
|
office,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
office: RegionalOfficeRow
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr className="cursor-pointer hover:bg-slate-50" onClick={onToggle}>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||||
|
<span>
|
||||||
|
{/*
|
||||||
|
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.
|
||||||
|
*/}
|
||||||
|
<b className="block leading-tight">{office.name}</b>
|
||||||
|
<span className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||||
|
<MapPin size={9} />
|
||||||
|
{office.city}
|
||||||
|
{office.officeType === 'LOCAL_OFFICE' && ' · Local Office'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-[11px] text-slate-600">{office.states.join(', ')}</td>
|
||||||
|
<td className="text-right tabular-nums">{office.banks}</td>
|
||||||
|
<td className="text-right tabular-nums">{office.branches}</td>
|
||||||
|
<td className="text-right tabular-nums">{office.enterprises}</td>
|
||||||
|
<Suppressible value={office.red} suppressed={office.suppressed} />
|
||||||
|
<Suppressible
|
||||||
|
value={office.stressRatePct}
|
||||||
|
suppressed={office.suppressed}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
{expanded && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="bg-slate-50/70 p-0">
|
||||||
|
<OfficeBanks code={office.code} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cell that may be withheld.
|
||||||
|
*
|
||||||
|
* <p>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 (
|
||||||
|
<td className="text-right">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-[10px] text-slate-400"
|
||||||
|
title="Withheld — fewer borrowers than the minimum cell size"
|
||||||
|
>
|
||||||
|
<EyeOff size={10} />
|
||||||
|
withheld
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<td className="text-right tabular-nums">
|
||||||
|
{value}
|
||||||
|
{suffix}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OfficeBanks({ code }: { code: string }) {
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['apex-ro-banks', code],
|
||||||
|
queryFn: () => get<RegionalOfficeBankRow[]>(`/nabard/regional-offices/${code}/banks`),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <div className="px-6 py-3 text-[11px] text-slate-500">Loading banks…</div>
|
||||||
|
if (isError)
|
||||||
|
return <div className="px-6 py-3 text-[11px] text-slate-500">Banks unavailable.</div>
|
||||||
|
const banks = data ?? []
|
||||||
|
if (banks.length === 0)
|
||||||
|
return <div className="px-6 py-3 text-[11px] text-slate-500">No banks in this office yet.</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-3">
|
||||||
|
<table className="ul-table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bank</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th className="text-right">Units</th>
|
||||||
|
<th className="text-right">Stressed</th>
|
||||||
|
<th className="text-right">Watch</th>
|
||||||
|
<th className="text-right">Stress rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{banks.map((b) => (
|
||||||
|
<tr key={b.institutionCode}>
|
||||||
|
<td>
|
||||||
|
<b>{b.institutionName}</b>
|
||||||
|
<span className="text-[10px] text-slate-500 block">{b.institutionCode}</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-[11px]">{b.type}</td>
|
||||||
|
<td className="text-right tabular-nums">{b.enterprises}</td>
|
||||||
|
<Suppressible value={b.red} suppressed={b.suppressed} />
|
||||||
|
<Suppressible value={b.watch} suppressed={b.suppressed} />
|
||||||
|
<Suppressible value={b.stressRatePct} suppressed={b.suppressed} suffix="%" />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Scissors,
|
Scissors,
|
||||||
Search,
|
Search,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
Sprout,
|
||||||
Store,
|
Store,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
Wheat,
|
Wheat,
|
||||||
@@ -38,6 +39,7 @@ const SECTOR_ICON: Record<Sector, LucideIcon> = {
|
|||||||
FOOD_PROC: Wheat,
|
FOOD_PROC: Wheat,
|
||||||
HANDICRAFTS: Scissors,
|
HANDICRAFTS: Scissors,
|
||||||
RURAL_RETAIL: Store,
|
RURAL_RETAIL: Store,
|
||||||
|
HORTICULTURE: Sprout,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What drives risk in each sector — the sentence under the name. */
|
/** What drives risk in each sector — the sentence under the name. */
|
||||||
@@ -47,6 +49,7 @@ const SECTOR_HINT: Record<Sector, string> = {
|
|||||||
FOOD_PROC: 'mandi linkage · wheat and gram input prices',
|
FOOD_PROC: 'mandi linkage · wheat and gram input prices',
|
||||||
HANDICRAFTS: 'buyer concentration · seasonal order flow',
|
HANDICRAFTS: 'buyer concentration · seasonal order flow',
|
||||||
RURAL_RETAIL: 'UPI inflow signals · agri-input demand',
|
RURAL_RETAIL: 'UPI inflow signals · agri-input demand',
|
||||||
|
HORTICULTURE: 'mandi price collapse · unseasonal rain in the picking window',
|
||||||
}
|
}
|
||||||
|
|
||||||
type Filter = 'all' | Band
|
type Filter = 'all' | Band
|
||||||
|
|||||||
+59
-3
@@ -40,7 +40,17 @@ export interface EnterpriseSession {
|
|||||||
* rather than an access-control hole.
|
* rather than an access-control hole.
|
||||||
*/
|
*/
|
||||||
export function isApexRole(role: string | undefined): boolean {
|
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 {
|
export function homeFor(session: OfficerSession | null): string {
|
||||||
@@ -132,7 +142,13 @@ export function errMsg(e: unknown): string {
|
|||||||
export type Lang = 'hi' | 'en'
|
export type Lang = 'hi' | 'en'
|
||||||
|
|
||||||
/** The five sector risk packs from the deck. */
|
/** 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. */
|
/** Composite Stress Score bands. Red escalates, Watch is monitored, Standard is healthy. */
|
||||||
export type Band = 'RED' | 'WATCH' | 'STANDARD'
|
export type Band = 'RED' | 'WATCH' | 'STANDARD'
|
||||||
@@ -642,6 +658,44 @@ export interface ApexDashboard {
|
|||||||
dataAgeDays: number
|
dataAgeDays: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A NABARD Regional Office and the portfolio it supervises.
|
||||||
|
*
|
||||||
|
* <p>{@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 {
|
export interface BankScorecardRow {
|
||||||
institutionCode: string
|
institutionCode: string
|
||||||
institutionName: string
|
institutionName: string
|
||||||
@@ -694,9 +748,11 @@ export interface InstitutionRow {
|
|||||||
export interface GeoCell {
|
export interface GeoCell {
|
||||||
key: string
|
key: string
|
||||||
name: string
|
name: string
|
||||||
level: 'ZONE' | 'STATE' | 'DISTRICT'
|
level: 'RO' | 'ZONE' | 'STATE' | 'DISTRICT'
|
||||||
zone?: string
|
zone?: string
|
||||||
zoneLabel?: string
|
zoneLabel?: string
|
||||||
|
/** Regional office code the row rolls up into — present at every level. */
|
||||||
|
office?: string
|
||||||
state?: string
|
state?: string
|
||||||
enterprises: number
|
enterprises: number
|
||||||
suppressed: boolean
|
suppressed: boolean
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const SECTORS: Record<Sector, { label: string; labelHi: string; short: st
|
|||||||
FOOD_PROC: { label: 'Food processing', labelHi: 'खाद्य प्रसंस्करण', short: 'Food proc.' },
|
FOOD_PROC: { label: 'Food processing', labelHi: 'खाद्य प्रसंस्करण', short: 'Food proc.' },
|
||||||
HANDICRAFTS: { label: 'Handicrafts', labelHi: 'हस्तशिल्प', short: 'Handicrafts' },
|
HANDICRAFTS: { label: 'Handicrafts', labelHi: 'हस्तशिल्प', short: 'Handicrafts' },
|
||||||
RURAL_RETAIL: { label: 'Rural retail', labelHi: 'ग्रामीण दुकान', short: 'Rural retail' },
|
RURAL_RETAIL: { label: 'Rural retail', labelHi: 'ग्रामीण दुकान', short: 'Rural retail' },
|
||||||
|
HORTICULTURE: { label: 'Horticulture', labelHi: 'बागवानी', short: 'Horticulture' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SECTOR_LIST = Object.keys(SECTORS) as Sector[]
|
export const SECTOR_LIST = Object.keys(SECTORS) as Sector[]
|
||||||
@@ -76,6 +77,7 @@ const SECTOR_SUFFIX: Record<Sector, { en: string[]; hi: string[] }> = {
|
|||||||
FOOD_PROC: { en: ['Food Products', 'Flour Mill', 'Masala Udyog', 'Agro Foods'], hi: ['फ़ूड प्रोडक्ट्स', 'आटा चक्की', 'मसाला उद्योग', 'एग्रो फ़ूड्स'] },
|
FOOD_PROC: { en: ['Food Products', 'Flour Mill', 'Masala Udyog', 'Agro Foods'], hi: ['फ़ूड प्रोडक्ट्स', 'आटा चक्की', 'मसाला उद्योग', 'एग्रो फ़ूड्स'] },
|
||||||
HANDICRAFTS: { en: ['Handicrafts', 'Handloom', 'Craft Works'], hi: ['हस्तशिल्प', 'हथकरघा', 'क्राफ़्ट वर्क्स'] },
|
HANDICRAFTS: { en: ['Handicrafts', 'Handloom', 'Craft Works'], hi: ['हस्तशिल्प', 'हथकरघा', 'क्राफ़्ट वर्क्स'] },
|
||||||
RURAL_RETAIL: { en: ['Kirana Store', 'General Store', 'Provision Store'], 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). */
|
/** Sector weights applied to the five sub-scores (deck feature 2). */
|
||||||
@@ -85,6 +87,11 @@ export const SECTOR_WEIGHTS: Record<Sector, SubScores> = {
|
|||||||
FOOD_PROC: { liquidity: 0.26, behavioural: 0.22, trendVolatility: 0.2, obligationLoad: 0.18, externalShock: 0.14 },
|
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 },
|
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 },
|
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. */
|
/** Band plan so the totals always match the deck: 9 red, 31 watch, 102 standard. */
|
||||||
@@ -279,6 +286,13 @@ const REASON_TEXT: Record<Sector, Record<keyof SubScores, { en: string; hi: stri
|
|||||||
obligationLoad: { en: 'Stock restocking clashes with the instalment date.', hi: 'माल भरने और किस्त की तारीख़ एक साथ पड़ रही है।' },
|
obligationLoad: { en: 'Stock restocking clashes with the instalment date.', hi: 'माल भरने और किस्त की तारीख़ एक साथ पड़ रही है।' },
|
||||||
externalShock: { en: 'Local market disruption flagged in the block.', hi: 'ब्लॉक में स्थानीय बाज़ार में गड़बड़ी दर्ज है।' },
|
externalShock: { en: 'Local market disruption flagged in the block.', hi: 'ब्लॉक में स्थानीय बाज़ार में गड़बड़ी दर्ज है।' },
|
||||||
},
|
},
|
||||||
|
HORTICULTURE: {
|
||||||
|
liquidity: { en: 'Harvest sold below cost as arrivals peaked together.', hi: 'एक साथ आवक बढ़ने से फ़सल लागत से कम भाव पर बिकी।' },
|
||||||
|
behavioural: { en: 'Selling through one commission agent on deferred payment.', hi: 'एक ही आढ़तिये को उधार पर माल दिया जा रहा है।' },
|
||||||
|
trendVolatility: { en: 'Mandi rate swung more than 40% inside a fortnight.', hi: 'पखवाड़े भर में मंडी भाव 40% से ज़्यादा घट-बढ़ा है।' },
|
||||||
|
obligationLoad: { en: 'Instalment falls between harvests, with no crop to sell.', hi: 'किस्त दो फ़सलों के बीच पड़ रही है, बेचने को कुछ नहीं।' },
|
||||||
|
externalShock: { en: 'Unseasonal rain in the block during the picking window.', hi: 'तुड़ाई के समय ब्लॉक में बेमौसम बारिश हुई है।' },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-3 SHAP drivers as plain-language sentences (deck feature 4). */
|
/** Top-3 SHAP drivers as plain-language sentences (deck feature 4). */
|
||||||
|
|||||||
+153
-25
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { errMsg, homeFor, post } from '../lib/api'
|
import { errMsg, get, homeFor, post } from '../lib/api'
|
||||||
import type { EnterpriseSession, OfficerSession } from '../lib/api'
|
import type { EnterpriseSession, OfficerSession } from '../lib/api'
|
||||||
import { UdyamMark, inputCls } from '../components/ui/bits'
|
import { UdyamMark, inputCls } from '../components/ui/bits'
|
||||||
import { ENTERPRISES, findEnterprise } from '../lib/demo/enterprises'
|
import { ENTERPRISES, findEnterprise } from '../lib/demo/enterprises'
|
||||||
@@ -45,6 +45,62 @@ const DEMO = {
|
|||||||
enterprise: { id: 'MSME-4417', pin: '1234' },
|
enterprise: { id: 'MSME-4417', pin: '1234' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One account per tier, so a reviewer can walk the hierarchy without a
|
||||||
|
* credentials sheet.
|
||||||
|
*
|
||||||
|
* <p>Grouped 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() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [mode, setMode] = useState<Mode>('OFFICER')
|
const [mode, setMode] = useState<Mode>('OFFICER')
|
||||||
@@ -53,39 +109,54 @@ export function LoginPage() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [slide, setSlide] = useState(0)
|
const [slide, setSlide] = useState(0)
|
||||||
|
const [offices, setOffices] = useState<OfficeOption[]>([])
|
||||||
|
const [office, setOffice] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setSlide((s) => (s + 1) % SLIDES.length), 6500)
|
const id = setInterval(() => setSlide((s) => (s + 1) % SLIDES.length), 6500)
|
||||||
return () => clearInterval(id)
|
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<OfficeOption[]>('/auth/regional-offices')
|
||||||
|
.then(setOffices)
|
||||||
|
.catch(() => setOffices([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign in against the backend; if it is unreachable (the UI-first default),
|
* 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.
|
* fall back to the seeded demo credentials so every screen stays walkable.
|
||||||
*/
|
*/
|
||||||
async function handleLogin(e: React.FormEvent) {
|
async function handleLogin(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
await signIn(identifier.trim(), secret, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(id: string, pass: string, m: Mode) {
|
||||||
setError('')
|
setError('')
|
||||||
const id = identifier.trim()
|
if (!id || !pass.trim()) {
|
||||||
if (!id || !secret.trim()) {
|
setError(m === 'OFFICER' ? 'Enter username and password' : 'उद्यम आईडी और पिन डालें')
|
||||||
setError(mode === 'OFFICER' ? 'Enter username and password' : 'उद्यम आईडी और पिन डालें')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
if (mode === 'OFFICER') {
|
if (m === 'OFFICER') {
|
||||||
const session = await post<OfficerSession>('/auth/officer-login', { username: id, password: secret })
|
const session = await post<OfficerSession>('/auth/officer-login', { username: id, password: pass })
|
||||||
sessionStorage.setItem('ul-officer-auth', JSON.stringify(session))
|
sessionStorage.setItem('ul-officer-auth', JSON.stringify(session))
|
||||||
// NABARD and bank staff share one sign-in form and land in different
|
// NABARD staff, regional-office staff and bank staff share one sign-in
|
||||||
// portals — the role on the session decides which.
|
// 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 })
|
navigate(homeFor(session), { replace: true })
|
||||||
} else {
|
} else {
|
||||||
const session = await post<EnterpriseSession>('/auth/app-login', { enterpriseId: id, pin: secret })
|
const session = await post<EnterpriseSession>('/auth/app-login', { enterpriseId: id, pin: pass })
|
||||||
localStorage.setItem('ul-app-auth', JSON.stringify(session))
|
localStorage.setItem('ul-app-auth', JSON.stringify(session))
|
||||||
navigate('/app', { replace: true })
|
navigate('/app', { replace: true })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const fallback = demoLogin(mode, id, secret)
|
const fallback = demoLogin(m, id, pass)
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
navigate(fallback, { replace: true })
|
navigate(fallback, { replace: true })
|
||||||
return
|
return
|
||||||
@@ -199,6 +270,41 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleLogin}>
|
<form onSubmit={handleLogin}>
|
||||||
|
{/*
|
||||||
|
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 && (
|
||||||
|
<label className="block mb-3.5">
|
||||||
|
<span className="block font-mono text-[10px] tracking-[0.14em] text-slate-500 mb-1.5">
|
||||||
|
REGIONAL OFFICE <span className="text-slate-400">· optional</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={office}
|
||||||
|
onChange={(e) => {
|
||||||
|
const code = e.target.value
|
||||||
|
setOffice(code)
|
||||||
|
const picked = offices.find((o) => o.code === code)
|
||||||
|
if (picked) {
|
||||||
|
setIdentifier(picked.username)
|
||||||
|
setSecret('Udyam@123')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">NABARD head office · all-India</option>
|
||||||
|
{offices.map((o) => (
|
||||||
|
<option key={o.code} value={o.code}>
|
||||||
|
{o.name} · {o.city}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="block mb-3.5">
|
<label className="block mb-3.5">
|
||||||
<span className="block font-mono text-[10px] tracking-[0.14em] text-slate-500 mb-1.5">
|
<span className="block font-mono text-[10px] tracking-[0.14em] text-slate-500 mb-1.5">
|
||||||
{hi ? 'उद्यम आईडी' : 'USERNAME'}
|
{hi ? 'उद्यम आईडी' : 'USERNAME'}
|
||||||
@@ -243,25 +349,47 @@ export function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-5 border border-dashed border-slate-300 rounded-lg px-3.5 py-3 bg-slate-50">
|
<div className="mt-5 border border-dashed border-slate-300 rounded-lg px-3.5 py-3 bg-slate-50">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="font-mono text-[9.5px] tracking-[0.14em] text-slate-500">DEMO CREDENTIALS</span>
|
<span className="font-mono text-[9.5px] tracking-[0.14em] text-slate-500">
|
||||||
|
DEMO SIGN-IN · ONE PER TIER
|
||||||
|
</span>
|
||||||
<button type="button" onClick={fillDemo} className="text-[10.5px] font-semibold text-forest hover:underline">
|
<button type="button" onClick={fillDemo} className="text-[10.5px] font-semibold text-forest hover:underline">
|
||||||
fill
|
fill form
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-[11px] text-slate-600 leading-relaxed">
|
|
||||||
{mode === 'OFFICER' ? (
|
<div className="flex flex-col gap-2.5">
|
||||||
<>
|
{PERSONAS.map((group) => (
|
||||||
{DEMO.officer.username} · {DEMO.officer.password}
|
<div key={group.tier}>
|
||||||
</>
|
<div className="text-[10px] font-semibold text-slate-600">{group.tier}</div>
|
||||||
) : (
|
<div className="text-[9.5px] text-slate-400 leading-snug mb-1">{group.note}</div>
|
||||||
<>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{DEMO.enterprise.id} · {DEMO.enterprise.pin}
|
{group.accounts.map((a) => (
|
||||||
</>
|
<button
|
||||||
)}
|
key={a.username}
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setMode(a.mode)
|
||||||
|
setIdentifier(a.username)
|
||||||
|
setSecret(a.secret)
|
||||||
|
void signIn(a.username, a.secret, a.mode)
|
||||||
|
}}
|
||||||
|
title={`${a.username} · ${a.secret}`}
|
||||||
|
className="text-[10.5px] px-2 py-1 rounded border border-slate-300 bg-white hover:border-forest hover:text-forest disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-400 mt-1.5 leading-snug">
|
|
||||||
Backend optional — with the API down, sign-in falls through to the seeded prototype data.
|
<p className="text-[10px] text-slate-400 mt-2.5 leading-snug">
|
||||||
|
Every console password is <span className="font-mono">Udyam@123</span>; the enterprise PIN
|
||||||
|
is <span className="font-mono">1234</span>. Backend optional — with the API down, sign-in
|
||||||
|
falls through to the seeded prototype data.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user