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