Files
udyamlinc/src/lib/api.ts
T
LincPayandClaude Opus 5 438b97d2d4 feat(console): data-entry screen, and mark hand-entered prices as such
/officer/data-entry — upload, preview, commit, and the batch history, for
the feeds no API serves and for the loan book.

The screen is built around one rule: nothing is written until someone has
seen what will be. Accepted, rejected and overwriting rows are shown as
three separate groups, because merging them into one list of rows hides
the only two questions the person clicking Commit has — what will not
land, and what will land on top of something already there. An overwriting
row shows the value it replaces beside the one replacing it.

The screen states in plain words that the effective date is the date being
reported about rather than today, and that when we learned it is recorded
separately — the distinction is invisible in a spreadsheet and expensive
to get wrong. Selecting the loan book adds the no-borrower-identity rule.

On the feeds page, mandi rows now carry a source: Agmarknet, or a 'Hand'
chip for a price somebody typed. By the time a price reaches a feature the
two are indistinguishable, so the distinction has to be visible there or
it is visible nowhere.

The demo fallback deliberately contains rejections. A demo where every
upload is clean teaches the wrong thing — a reviewer who only sees green
rows will not know the rejection panel exists.

tsc, oxlint and npm run build all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:17:44 +05:30

459 lines
12 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
}
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 }
}