chore: initial commit — UdyamLinc UI, phases A-E
Vite/React 19 console and enterprise PWA. Officer console screens, offline ledger and sync, data feeds and trip-wire rules screens with freshness chip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+399
@@ -0,0 +1,399 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user