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. * *

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(path: string, params?: Record): Promise { const res = await api.get(path, { params }) return res.data.data as T } export async function post(path: string, body?: unknown): Promise { const res = await api.post(path, body) return res.data.data as T } export async function put(path: string, body?: unknown): Promise { const res = await api.put(path, body) return res.data.data as T } export async function del(path: string): Promise { 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 }