Files
udyamlinc/src/lib/api.ts
T
LincPayandClaude Opus 5 38067c41f8 feat(nabard): supervisory portal — national view, banks, early warning
/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>
2026-08-06 18:10:51 +05:30

580 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}