/nabard, reachable only by a NABARD role. Four screens: national view with the India→zone→state→district drill-down, partner-bank scorecard that expands into branches, early-warning digest, and the institution register. Deliberately a different chrome from the bank console. A NABARD user and a branch officer are not two ranks of one job; a portal that looked identical would invite the assumption that the apex user is a bank user with more rights. The sidebar states on every screen that this tier holds no borrower identity, so a supervisor can tell a partner bank what it can see without checking. Response rate is the column the scorecard is built around. Stress rate is mostly a fact about a bank's districts and sectors; response rate is a fact about the bank, it is the one no bank self-reports, and low values are coloured to draw the eye. Suppressed cells keep their row and lose their numbers — dropping the row reads as 'no enterprises here', a different and wrong claim, and the one a reader would most easily act on. Login routes by tier: both staff tiers share one sign-in and land in different portals. RequireApex bounces a bank session to /officer rather than to /login, because signed in as the wrong tier is not signed out. The routing is a convenience only — the backend re-derives the tier from the signed JWT and refuses the other surface with a 403. The demo fallback includes a partner bank below the suppression threshold, so the withholding behaviour is visible without a backend. tsc, oxlint and npm run build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
580 lines
15 KiB
TypeScript
580 lines
15 KiB
TypeScript
import axios from 'axios'
|
||
|
||
/** udyamlinc-backend — Spring Boot skeleton, context path /api/v1/udyam. */
|
||
export const API_BASE =
|
||
import.meta.env.VITE_UDYAM_API_BASE_URL ?? 'http://udyamlinc-backend.lincpay/api/v1/udyam'
|
||
|
||
/** Short timeout: field devices are on patchy 2G and every screen has a local fallback. */
|
||
export const api = axios.create({ baseURL: API_BASE, timeout: 6_000 })
|
||
|
||
// ── Auth sessions ────────────────────────────────────────────────
|
||
|
||
/** Field officer console session — sessionStorage 'ul-officer-auth'. */
|
||
export interface OfficerSession {
|
||
token: string
|
||
username: string
|
||
fullName: string
|
||
role: string // FIELD_OFFICER | SUPERVISOR
|
||
blocks: string[]
|
||
branch: string
|
||
}
|
||
|
||
/** Enterprise PWA session — localStorage 'ul-app-auth' (must survive an offline relaunch). */
|
||
export interface EnterpriseSession {
|
||
token: string
|
||
enterpriseId: string
|
||
name: string
|
||
nameHi: string
|
||
sector: Sector
|
||
block: string
|
||
lang: Lang
|
||
}
|
||
|
||
/**
|
||
* Which portal a console session belongs to.
|
||
*
|
||
* <p>Derived from the role rather than stored as its own field, so a tampered
|
||
* session cannot claim a tier its token does not carry. This only picks the
|
||
* landing page — the backend re-derives the tier from the signed JWT and refuses
|
||
* the other surface outright, so getting this wrong is a routing annoyance
|
||
* rather than an access-control hole.
|
||
*/
|
||
export function isApexRole(role: string | undefined): boolean {
|
||
return !!role && role.startsWith('NABARD_')
|
||
}
|
||
|
||
export function homeFor(session: OfficerSession | null): string {
|
||
if (!session?.token) return '/login'
|
||
return isApexRole(session.role) ? '/nabard' : '/officer'
|
||
}
|
||
|
||
export function officerSession(): OfficerSession | null {
|
||
const raw = sessionStorage.getItem('ul-officer-auth')
|
||
if (!raw) return null
|
||
try {
|
||
const s = JSON.parse(raw)
|
||
return s?.token ? s : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
export function enterpriseSession(): EnterpriseSession | null {
|
||
const raw = localStorage.getItem('ul-app-auth')
|
||
if (!raw) return null
|
||
try {
|
||
const s = JSON.parse(raw)
|
||
return s?.token ? s : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
export function clearSessions() {
|
||
sessionStorage.removeItem('ul-officer-auth')
|
||
localStorage.removeItem('ul-app-auth')
|
||
}
|
||
|
||
// Attach the JWT of whichever surface the user is on (officer console vs enterprise app)
|
||
api.interceptors.request.use((config) => {
|
||
const path = window.location.pathname
|
||
const token =
|
||
(path.startsWith('/app') ? enterpriseSession()?.token : officerSession()?.token) ??
|
||
officerSession()?.token ??
|
||
enterpriseSession()?.token
|
||
if (token && !config.url?.includes('/auth/')) {
|
||
config.headers.Authorization = `Bearer ${token}`
|
||
}
|
||
return config
|
||
})
|
||
|
||
// Expired/invalid token → drop the session and return to sign-in
|
||
api.interceptors.response.use(
|
||
(res) => res,
|
||
(error) => {
|
||
if (error.response?.status === 401 && !window.location.pathname.startsWith('/login')) {
|
||
clearSessions()
|
||
window.location.assign('/login')
|
||
}
|
||
return Promise.reject(error)
|
||
},
|
||
)
|
||
|
||
/** Standard backend envelope: { success, message, data } */
|
||
export async function get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
|
||
const res = await api.get(path, { params })
|
||
return res.data.data as T
|
||
}
|
||
|
||
export async function post<T>(path: string, body?: unknown): Promise<T> {
|
||
const res = await api.post(path, body)
|
||
return res.data.data as T
|
||
}
|
||
|
||
export async function put<T>(path: string, body?: unknown): Promise<T> {
|
||
const res = await api.put(path, body)
|
||
return res.data.data as T
|
||
}
|
||
|
||
export async function del<T>(path: string): Promise<T> {
|
||
const res = await api.delete(path)
|
||
return res.data.data as T
|
||
}
|
||
|
||
/** Extract the backend's error message from an axios error. */
|
||
export function errMsg(e: unknown): string {
|
||
const anyE = e as { response?: { data?: { message?: string } }; message?: string }
|
||
return anyE.response?.data?.message ?? anyE.message ?? 'Request failed'
|
||
}
|
||
|
||
// ── Domain types (mirror UdyamLinc-Backend/API-SPEC.md) ──────────
|
||
|
||
export type Lang = 'hi' | 'en'
|
||
|
||
/** The five sector risk packs from the deck. */
|
||
export type Sector = 'DAIRY' | 'POULTRY' | 'FOOD_PROC' | 'HANDICRAFTS' | 'RURAL_RETAIL'
|
||
|
||
/** Composite Stress Score bands. Red escalates, Watch is monitored, Standard is healthy. */
|
||
export type Band = 'RED' | 'WATCH' | 'STANDARD'
|
||
|
||
export interface Enterprise {
|
||
id: number
|
||
enterpriseId: string // MSME-4417
|
||
name: string
|
||
nameHi: string
|
||
sector: Sector
|
||
block: string
|
||
district: string
|
||
ownerCount: number
|
||
vintageMonths: number
|
||
loanOutstanding: number
|
||
monthlyInstalment: number
|
||
score: number // 0–100 Composite Stress Score
|
||
band: Band
|
||
trend: number[] // 6-month score history, oldest first
|
||
lastUpdated: string
|
||
}
|
||
|
||
/** One month of the quantile forecast. Amounts are net cash position in ₹. */
|
||
export interface ForecastPoint {
|
||
month: string // '2026-09'
|
||
label: string // 'Sep'
|
||
labelHi: string // 'सित'
|
||
p10: number
|
||
p50: number
|
||
p90: number
|
||
obligation: number // scheduled instalment + fixed costs due that month
|
||
}
|
||
|
||
/** Five independent risk families, each 0–100, combined with sector weights. */
|
||
export interface SubScores {
|
||
liquidity: number
|
||
behavioural: number
|
||
trendVolatility: number
|
||
obligationLoad: number
|
||
externalShock: number
|
||
}
|
||
|
||
export interface StressScore {
|
||
enterpriseId: string
|
||
score: number
|
||
band: Band
|
||
subScores: SubScores
|
||
computedAt: string
|
||
modelVersion: string
|
||
}
|
||
|
||
/** A SHAP driver rendered as a sentence a field officer can read aloud. */
|
||
export interface ReasonCode {
|
||
driver: string
|
||
driverHi: string
|
||
weight: number // contribution, 0–100
|
||
sentenceEn: string
|
||
sentenceHi: string
|
||
}
|
||
|
||
/** Deterministic rule that fires independently of the model, for auditability. */
|
||
export interface TripWire {
|
||
code: string
|
||
label: string
|
||
labelHi: string
|
||
fired: boolean
|
||
detail: string
|
||
threshold: string
|
||
}
|
||
|
||
export type AlertOrigin = 'MODEL' | 'TRIPWIRE'
|
||
export type AlertState = 'OPEN' | 'ACKNOWLEDGED' | 'ESCALATED' | 'SNOOZED' | 'CLOSED'
|
||
|
||
export interface RiskAlert {
|
||
id: number
|
||
alertId: string
|
||
enterpriseId: string
|
||
enterpriseName: string
|
||
sector: Sector
|
||
block: string
|
||
band: Band
|
||
score: number
|
||
origin: AlertOrigin
|
||
title: string
|
||
titleHi: string
|
||
reason: ReasonCode
|
||
shortfall: number // projected ₹ gap at P10
|
||
breachMonth: string
|
||
state: AlertState
|
||
raisedAt: string
|
||
}
|
||
|
||
export type VisitState = 'PLANNED' | 'DONE' | 'OVERDUE'
|
||
|
||
export interface FieldVisit {
|
||
id: number
|
||
visitId: string
|
||
enterpriseId: string
|
||
enterpriseName: string
|
||
block: string
|
||
action: string
|
||
actionHi: string
|
||
priority: number // 1 = act first
|
||
dueDate: string
|
||
state: VisitState
|
||
outcome: string | null
|
||
officer: string
|
||
loggedAt: string | null
|
||
}
|
||
|
||
/** One item of the ranked playbook the officer works down. */
|
||
export interface PlaybookAction {
|
||
code: string
|
||
label: string
|
||
labelHi: string
|
||
appliesTo: Band
|
||
detail: string
|
||
}
|
||
|
||
export type LedgerKind = 'IN' | 'OUT' | 'STOCK'
|
||
|
||
export interface LedgerEntry {
|
||
id: string // client-generated so offline entries have identity before sync
|
||
enterpriseId: string
|
||
kind: LedgerKind
|
||
amount: number
|
||
category: string
|
||
note: string | null
|
||
entryDate: string // 'YYYY-MM-DD'
|
||
syncState: 'PENDING' | 'SYNCED'
|
||
createdAt: string
|
||
}
|
||
|
||
export interface PortfolioSummary {
|
||
block: string
|
||
month: string
|
||
total: number
|
||
totalDelta: number
|
||
red: number
|
||
redDelta: number
|
||
watch: number
|
||
watchDelta: number
|
||
standard: number
|
||
standardDelta: number
|
||
updatedMinutesAgo: number
|
||
rows: Enterprise[]
|
||
}
|
||
|
||
// ── Ingestion, features and rules (the /admin surface) ───────────
|
||
|
||
/**
|
||
* A week of mandi prices for one district and commodity. Weekly rather than
|
||
* daily because a mandi does not report every day, and every feature built on it
|
||
* is defined against a trailing weekly mean anyway.
|
||
*/
|
||
export interface MarketSignal {
|
||
id: number
|
||
districtName: string
|
||
commodityName: string
|
||
weekStart: string // Monday of the ISO week
|
||
modalPrice: number | null
|
||
minPrice: number | null
|
||
maxPrice: number | null
|
||
arrivalCount: number | null
|
||
unit: string | null
|
||
source: string
|
||
validForDate: string // the date the fact describes
|
||
observedAt: string // when we learned it
|
||
}
|
||
|
||
export interface ClimateSignal {
|
||
id: number
|
||
districtName: string
|
||
weekStart: string
|
||
isoWeek: number
|
||
rainfallMm: number | null
|
||
tmaxC: number | null
|
||
rainfallNormalMm: number | null
|
||
rainfallAnomalyPct: number | null
|
||
heatAnomalyC: number | null
|
||
validForDate: string
|
||
observedAt: string
|
||
}
|
||
|
||
/** Per-district data age — what a trip-wire consults before it is allowed to fire. */
|
||
export interface Freshness {
|
||
districtCode: string
|
||
districtName: string
|
||
marketValidFor: string | null
|
||
marketStalenessDays: number | null
|
||
climateValidFor: string | null
|
||
climateStalenessDays: number | null
|
||
}
|
||
|
||
export type IngestionStatus = 'SUCCESS' | 'PARTIAL' | 'FAILED' | 'SKIPPED'
|
||
|
||
export interface IngestionRun {
|
||
id: number
|
||
source: string // AGMARKNET | OPEN_METEO
|
||
runDate: string
|
||
trigger: string // SCHEDULED | MANUAL
|
||
status: IngestionStatus
|
||
rowsFetched: number | null
|
||
rowsWritten: number | null
|
||
callsFailed: number | null
|
||
durationMs: number | null
|
||
startedAt: string
|
||
message: string | null
|
||
}
|
||
|
||
/** A catalogue entry. `stalenessToleranceDays` is what gates the rules. */
|
||
export interface FeatureDefinition {
|
||
code: string
|
||
label: string
|
||
family: string
|
||
unit: string | null
|
||
sourceKind: string | null
|
||
stalenessToleranceDays: number | null
|
||
direction: string | null
|
||
derivation: string | null
|
||
}
|
||
|
||
/**
|
||
* One tall-store row. A null `value` always carries a `missingReason` — that is
|
||
* the difference between a hole we can explain and one we cannot.
|
||
*/
|
||
export interface FeatureValueRow {
|
||
featureCode: string
|
||
value: number | null
|
||
validForDate: string
|
||
stalenessDays: number | null
|
||
missingReason: string | null
|
||
}
|
||
|
||
export interface TripWireRule {
|
||
id: number
|
||
code: string
|
||
label: string
|
||
labelHi: string
|
||
expression: string
|
||
thresholdText: string
|
||
severity: 'RED' | 'WATCH'
|
||
sectorCode: string | null
|
||
requiredFeatures: string
|
||
maxStalenessDays: number
|
||
active: boolean
|
||
version: number
|
||
}
|
||
|
||
export type RuleStatus =
|
||
| 'FIRED'
|
||
| 'NOT_FIRED'
|
||
| 'SKIPPED_STALE'
|
||
| 'SKIPPED_MISSING'
|
||
| 'SKIPPED_SECTOR'
|
||
| 'ERROR'
|
||
|
||
/**
|
||
* What one rule did against one snapshot. A rule that came back false and one
|
||
* that was never allowed to run are different events, and the console has to
|
||
* show which — "no alert" because the enterprise is fine reads very differently
|
||
* from "no alert" because the feed has been down a fortnight.
|
||
*/
|
||
export interface RuleOutcome {
|
||
ruleCode: string
|
||
ruleId: number
|
||
ruleVersion: number
|
||
severity: 'RED' | 'WATCH'
|
||
status: RuleStatus
|
||
detail: string | null
|
||
}
|
||
|
||
/** Enterprise app home payload — everything the Home tab needs in one call. */
|
||
export interface AppHome {
|
||
enterprise: Enterprise
|
||
alert: RiskAlert | null
|
||
forecast: ForecastPoint[]
|
||
todayIn: number
|
||
todayOut: number
|
||
advice: { textEn: string; textHi: string }
|
||
lastSyncedAt: string | null
|
||
}
|
||
|
||
// ── Manual & bulk data entry ─────────────────────────────────────
|
||
// The feeds no API serves: NECC egg rates, dairy-federation milk, fodder — and
|
||
// the loan book, until a lender builds a nightly extract.
|
||
|
||
export interface ManualEntryBatch {
|
||
id: number
|
||
entryType: 'MARKET_SIGNAL' | 'LOAN_RECORD'
|
||
filename: string | null
|
||
uploadedBy: string
|
||
uploadedAt: string
|
||
status: 'PREVIEW' | 'COMMITTED' | 'DISCARDED'
|
||
committedAt: string | null
|
||
committedBy: string | null
|
||
rowsParsed: number | null
|
||
rowsAccepted: number | null
|
||
rowsRejected: number | null
|
||
rowsOverwriting: number | null
|
||
note: string | null
|
||
}
|
||
|
||
export interface ManualEntryLine {
|
||
id: number
|
||
rowNumber: number
|
||
rawDistrict: string | null
|
||
rawCommodity: string | null
|
||
rawEffectiveDate: string | null
|
||
rawModalPrice: string | null
|
||
effectiveDate: string | null
|
||
weekStart: string | null
|
||
modalPrice: number | null
|
||
minPrice: number | null
|
||
maxPrice: number | null
|
||
unit: string | null
|
||
accepted: boolean
|
||
rejectReason: string | null
|
||
rejectDetail: string | null
|
||
warning: string | null
|
||
overwriting: boolean
|
||
/** What an accepted row would replace — a correction is only auditable if this survives. */
|
||
priorModalPrice: number | null
|
||
}
|
||
|
||
/** Accepted, rejected and overwriting stay apart all the way from the backend. */
|
||
export interface ManualEntryPreview {
|
||
batch: ManualEntryBatch
|
||
accepted: ManualEntryLine[]
|
||
rejected: ManualEntryLine[]
|
||
overwriting: ManualEntryLine[]
|
||
warnings: ManualEntryLine[]
|
||
}
|
||
|
||
/** Multipart, so it bypasses the JSON helpers. */
|
||
export async function uploadManual(file: File, type: 'MARKET_SIGNAL' | 'LOAN_RECORD') {
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
const res = await api.post(`/admin/manual/upload?type=${type}`, form)
|
||
return res.data as { success: boolean; message: string; data: ManualEntryPreview }
|
||
}
|
||
|
||
// ── NABARD (apex) surface ────────────────────────────────────────
|
||
// Aggregates only. Nothing here carries an enterprise id, by design — see
|
||
// ApexService on the backend. A cell with fewer borrowers than the platform's
|
||
// minimum is returned with suppressed=true rather than omitted, because a
|
||
// missing district reads as "no activity" and that is a different claim.
|
||
|
||
export interface ApexDashboard {
|
||
partnerBanks: number
|
||
branches: number
|
||
enterprises: number
|
||
red: number
|
||
watch: number
|
||
standard: number
|
||
stressRatePct: number
|
||
portfolioValue: number
|
||
openAlerts: number
|
||
minCellSize: number
|
||
dataAgeDays: number
|
||
}
|
||
|
||
export interface BankScorecardRow {
|
||
institutionCode: string
|
||
institutionName: string
|
||
shortName: string
|
||
type: string
|
||
branches: number
|
||
enterprises: number
|
||
suppressed: boolean
|
||
red?: number
|
||
watch?: number
|
||
standard?: number
|
||
stressRatePct?: number
|
||
avgScore?: number
|
||
alertsRaised?: number
|
||
alertsActioned?: number
|
||
/** Share of raised alerts a bank has acted on — the number a bank never self-reports. */
|
||
responseRatePct?: number
|
||
outstanding?: number
|
||
}
|
||
|
||
export interface ApexCell {
|
||
districtName?: string
|
||
sectorName?: string
|
||
enterprises: number
|
||
suppressed: boolean
|
||
red?: number
|
||
watch?: number
|
||
standard?: number
|
||
stressRatePct?: number
|
||
avgScore?: number
|
||
}
|
||
|
||
export interface ApexDigest {
|
||
asOf: string
|
||
districtsUnderStress: ApexCell[]
|
||
sectorsUnderStress: ApexCell[]
|
||
slowestResponders: BankScorecardRow[]
|
||
}
|
||
|
||
export interface InstitutionRow {
|
||
code: string
|
||
name: string
|
||
shortName: string
|
||
type: string
|
||
state: string
|
||
branches: { code: string; name: string; region: string; blocks: string }[]
|
||
}
|
||
|
||
/** One row of the India → zone → state → district drill-down. */
|
||
export interface GeoCell {
|
||
key: string
|
||
name: string
|
||
level: 'ZONE' | 'STATE' | 'DISTRICT'
|
||
zone?: string
|
||
zoneLabel?: string
|
||
state?: string
|
||
enterprises: number
|
||
suppressed: boolean
|
||
red?: number
|
||
watch?: number
|
||
standard?: number
|
||
stressRatePct?: number
|
||
avgScore?: number
|
||
}
|
||
|
||
/** A partner bank's branch, as the apex tier sees it — aggregates, never borrowers. */
|
||
export interface BranchCell {
|
||
name: string
|
||
branchCode: string
|
||
region: string
|
||
blocks: string
|
||
district: string
|
||
institutionCode: string
|
||
institutionName: string
|
||
enterprises: number
|
||
suppressed: boolean
|
||
red?: number
|
||
watch?: number
|
||
standard?: number
|
||
stressRatePct?: number
|
||
avgScore?: number
|
||
}
|