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:
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { AlertState, RiskAlert } from './api'
|
||||
import { alertsFor } from './demo/ops'
|
||||
|
||||
/*
|
||||
* Officer actions on the alert queue.
|
||||
*
|
||||
* The queue itself is derived from the portfolio; only the state changes an
|
||||
* officer makes (acknowledge / escalate / snooze / close) and their audit trail
|
||||
* are persisted, which is exactly the split the deck describes — the model
|
||||
* proposes, the officer disposes, and every disposal is auditable.
|
||||
*/
|
||||
|
||||
const KEY = 'ul-alert-states'
|
||||
const AUDIT_KEY = 'ul-alert-audit'
|
||||
|
||||
export interface AuditEntry {
|
||||
alertId: string
|
||||
from: AlertState
|
||||
to: AlertState
|
||||
actor: string
|
||||
note: string | null
|
||||
at: string
|
||||
}
|
||||
|
||||
function readStates(): Record<string, AlertState> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) ?? '{}') as Record<string, AlertState>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function readAudit(): AuditEntry[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(AUDIT_KEY) ?? '[]') as AuditEntry[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function setAlertState(alert: RiskAlert, to: AlertState, actor: string, note?: string) {
|
||||
const states = readStates()
|
||||
const from = states[alert.alertId] ?? alert.state
|
||||
states[alert.alertId] = to
|
||||
localStorage.setItem(KEY, JSON.stringify(states))
|
||||
|
||||
const audit: AuditEntry = { alertId: alert.alertId, from, to, actor, note: note ?? null, at: new Date().toISOString() }
|
||||
localStorage.setItem(AUDIT_KEY, JSON.stringify([audit, ...readAudit()].slice(0, 200)))
|
||||
window.dispatchEvent(new Event('ul-alerts-changed'))
|
||||
}
|
||||
|
||||
/** The alert queue for a block, with any officer actions applied on top. */
|
||||
export function useAlerts(block: string) {
|
||||
const [version, setVersion] = useState(0)
|
||||
|
||||
const bump = useCallback(() => setVersion((v) => v + 1), [])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('ul-alerts-changed', bump)
|
||||
return () => window.removeEventListener('ul-alerts-changed', bump)
|
||||
}, [bump])
|
||||
|
||||
const states = readStates()
|
||||
// `version` is read so the memo-free derivation re-runs after every action.
|
||||
void version
|
||||
const alerts = alertsFor(block).map((a) => ({ ...a, state: states[a.alertId] ?? a.state }))
|
||||
|
||||
return { alerts, audit: readAudit() }
|
||||
}
|
||||
+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
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Quote a CSV cell only when it needs it. */
|
||||
function cell(v: unknown): string {
|
||||
const s = v == null ? '' : String(v)
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
||||
}
|
||||
|
||||
/** Build a CSV and hand it to the browser as a download. */
|
||||
export function exportCsv(fileName: string, headers: string[], rows: unknown[][]) {
|
||||
const csv = [headers, ...rows].map((r) => r.map(cell).join(',')).join('\n')
|
||||
const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import type {
|
||||
Band,
|
||||
Enterprise,
|
||||
ForecastPoint,
|
||||
ReasonCode,
|
||||
Sector,
|
||||
StressScore,
|
||||
SubScores,
|
||||
TripWire,
|
||||
} from '../api'
|
||||
import { monthWindow, pickInt, pickOne, rng, seedOf, shuffled } from './seed'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The demo portfolio: 142 enterprises across 5 sector packs and 4 */
|
||||
/* blocks. The six named on the deck's wireframe slide are pinned to */
|
||||
/* their exact submitted scores; the rest are generated deterministically*/
|
||||
/* so band counts always total 9 / 31 / 102. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const BANDS: Record<Band, { label: string; labelHi: string; cls: string; dot: string }> = {
|
||||
RED: { label: 'High risk', labelHi: 'ज़्यादा जोखिम', cls: 'band-red', dot: 'bg-risk-red' },
|
||||
WATCH: { label: 'Watch', labelHi: 'निगरानी', cls: 'band-watch', dot: 'bg-amber' },
|
||||
STANDARD: { label: 'Standard', labelHi: 'ठीक', cls: 'band-standard', dot: 'bg-green' },
|
||||
}
|
||||
|
||||
/** Band thresholds map to the institution's own watchlist categories (deck slide 3). */
|
||||
export const BAND_THRESHOLD = { red: 65, watch: 35 }
|
||||
|
||||
export function bandOf(score: number): Band {
|
||||
if (score >= BAND_THRESHOLD.red) return 'RED'
|
||||
if (score >= BAND_THRESHOLD.watch) return 'WATCH'
|
||||
return 'STANDARD'
|
||||
}
|
||||
|
||||
export const SECTORS: Record<Sector, { label: string; labelHi: string; short: string }> = {
|
||||
DAIRY: { label: 'Dairy', labelHi: 'डेयरी', short: 'Dairy' },
|
||||
POULTRY: { label: 'Poultry', labelHi: 'मुर्गी पालन', short: 'Poultry' },
|
||||
FOOD_PROC: { label: 'Food processing', labelHi: 'खाद्य प्रसंस्करण', short: 'Food proc.' },
|
||||
HANDICRAFTS: { label: 'Handicrafts', labelHi: 'हस्तशिल्प', short: 'Handicrafts' },
|
||||
RURAL_RETAIL: { label: 'Rural retail', labelHi: 'ग्रामीण दुकान', short: 'Rural retail' },
|
||||
}
|
||||
|
||||
export const SECTOR_LIST = Object.keys(SECTORS) as Sector[]
|
||||
|
||||
export const BLOCKS = ['Sehore', 'Vidisha', 'Raisen', 'Bhopal'] as const
|
||||
export const BLOCK_DISTRICT: Record<string, string> = {
|
||||
Sehore: 'Sehore',
|
||||
Vidisha: 'Vidisha',
|
||||
Raisen: 'Raisen',
|
||||
Bhopal: 'Bhopal',
|
||||
}
|
||||
|
||||
/** The six enterprises printed on the wireframe slide — reproduced exactly. */
|
||||
const PINNED: { enterpriseId: string; name: string; nameHi: string; sector: Sector; block: string; score: number }[] = [
|
||||
{ enterpriseId: 'MSME-4417', name: 'Sehore Poultry Farm', nameHi: 'सीहोर पोल्ट्री फ़ार्म', sector: 'POULTRY', block: 'Sehore', score: 79 },
|
||||
{ enterpriseId: 'MSME-3902', name: 'Sharda Dairy Collective', nameHi: 'शारदा डेयरी समूह', sector: 'DAIRY', block: 'Vidisha', score: 71 },
|
||||
{ enterpriseId: 'MSME-5120', name: 'Anand Food Products', nameHi: 'आनंद फ़ूड प्रोडक्ट्स', sector: 'FOOD_PROC', block: 'Raisen', score: 58 },
|
||||
{ enterpriseId: 'MSME-2861', name: 'Gramin Kirana Store', nameHi: 'ग्रामीण किराना स्टोर', sector: 'RURAL_RETAIL', block: 'Sehore', score: 41 },
|
||||
{ enterpriseId: 'MSME-6033', name: 'Meera Handicrafts', nameHi: 'मीरा हस्तशिल्प', sector: 'HANDICRAFTS', block: 'Bhopal', score: 22 },
|
||||
{ enterpriseId: 'MSME-4488', name: 'Krishna Dairy Unit', nameHi: 'कृष्णा डेयरी यूनिट', sector: 'DAIRY', block: 'Vidisha', score: 18 },
|
||||
]
|
||||
|
||||
const NAME_PREFIX = [
|
||||
'Shri', 'Maa Narmada', 'Jai Kisan', 'Sarvodaya', 'Annapurna', 'Vindhya', 'Betwa', 'Narmada',
|
||||
'Adarsh', 'Nav Jeevan', 'Gram Shakti', 'Kisan Mitra', 'Sanskar', 'Prerna', 'Sunrise',
|
||||
'Bhagirath', 'Malwa', 'Sahyog', 'Ekta', 'Pragati', 'Suvidha', 'Anmol', 'Nirmal', 'Vaibhav',
|
||||
]
|
||||
const NAME_PREFIX_HI = [
|
||||
'श्री', 'माँ नर्मदा', 'जय किसान', 'सर्वोदय', 'अन्नपूर्णा', 'विंध्य', 'बेतवा', 'नर्मदा',
|
||||
'आदर्श', 'नव जीवन', 'ग्राम शक्ति', 'किसान मित्र', 'संस्कार', 'प्रेरणा', 'सनराइज़',
|
||||
'भगीरथ', 'मालवा', 'सहयोग', 'एकता', 'प्रगति', 'सुविधा', 'अनमोल', 'निर्मल', 'वैभव',
|
||||
]
|
||||
const SECTOR_SUFFIX: Record<Sector, { en: string[]; hi: string[] }> = {
|
||||
DAIRY: { en: ['Dairy', 'Milk Centre', 'Dairy Unit', 'Doodh Sangh'], hi: ['डेयरी', 'दूध केंद्र', 'डेयरी यूनिट', 'दूध संघ'] },
|
||||
POULTRY: { en: ['Poultry Farm', 'Layer Farm', 'Poultry Unit'], hi: ['पोल्ट्री फ़ार्म', 'लेयर फ़ार्म', 'पोल्ट्री यूनिट'] },
|
||||
FOOD_PROC: { en: ['Food Products', 'Flour Mill', 'Masala Udyog', 'Agro Foods'], hi: ['फ़ूड प्रोडक्ट्स', 'आटा चक्की', 'मसाला उद्योग', 'एग्रो फ़ूड्स'] },
|
||||
HANDICRAFTS: { en: ['Handicrafts', 'Handloom', 'Craft Works'], hi: ['हस्तशिल्प', 'हथकरघा', 'क्राफ़्ट वर्क्स'] },
|
||||
RURAL_RETAIL: { en: ['Kirana Store', 'General Store', 'Provision Store'], hi: ['किराना स्टोर', 'जनरल स्टोर', 'प्रोविज़न स्टोर'] },
|
||||
}
|
||||
|
||||
/** Sector weights applied to the five sub-scores (deck feature 2). */
|
||||
export const SECTOR_WEIGHTS: Record<Sector, SubScores> = {
|
||||
DAIRY: { liquidity: 0.28, behavioural: 0.2, trendVolatility: 0.18, obligationLoad: 0.19, externalShock: 0.15 },
|
||||
POULTRY: { liquidity: 0.3, behavioural: 0.22, trendVolatility: 0.17, obligationLoad: 0.14, externalShock: 0.17 },
|
||||
FOOD_PROC: { liquidity: 0.26, behavioural: 0.22, trendVolatility: 0.2, obligationLoad: 0.18, externalShock: 0.14 },
|
||||
HANDICRAFTS: { liquidity: 0.24, behavioural: 0.18, trendVolatility: 0.2, obligationLoad: 0.16, externalShock: 0.22 },
|
||||
RURAL_RETAIL: { liquidity: 0.3, behavioural: 0.24, trendVolatility: 0.18, obligationLoad: 0.18, externalShock: 0.1 },
|
||||
}
|
||||
|
||||
/** Band plan so the totals always match the deck: 9 red, 31 watch, 102 standard. */
|
||||
const TOTAL = 142
|
||||
const TARGET = { RED: 9, WATCH: 31, STANDARD: 102 }
|
||||
|
||||
function buildBandPlan(): Band[] {
|
||||
const remaining: Band[] = []
|
||||
const pinnedCounts = PINNED.reduce<Record<Band, number>>(
|
||||
(acc, p) => {
|
||||
acc[bandOf(p.score)]++
|
||||
return acc
|
||||
},
|
||||
{ RED: 0, WATCH: 0, STANDARD: 0 },
|
||||
)
|
||||
;(Object.keys(TARGET) as Band[]).forEach((b) => {
|
||||
for (let i = 0; i < TARGET[b] - pinnedCounts[b]; i++) remaining.push(b)
|
||||
})
|
||||
return shuffled(rng(seedOf('udyamlinc-band-plan')), remaining)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated scores stay below the headline enterprise's 79, so Sehore Poultry
|
||||
* Farm leads the dashboard exactly as it does on the deck's wireframe.
|
||||
*/
|
||||
function scoreForBand(r: () => number, band: Band): number {
|
||||
if (band === 'RED') return pickInt(r, 66, 78)
|
||||
if (band === 'WATCH') return pickInt(r, 36, 64)
|
||||
return pickInt(r, 8, 34)
|
||||
}
|
||||
|
||||
/** A 6-month score history that lands on the current score, drifting the way the band implies. */
|
||||
function trendFor(r: () => number, score: number, band: Band): number[] {
|
||||
const drift = band === 'RED' ? 1 : band === 'WATCH' ? 0.4 : -0.6
|
||||
const out: number[] = []
|
||||
let v = score - drift * 9
|
||||
for (let i = 0; i < 6; i++) {
|
||||
out.push(Math.max(4, Math.min(97, Math.round(v))))
|
||||
v += drift * 3 + (r() - 0.5) * 7
|
||||
}
|
||||
out[5] = score
|
||||
return out
|
||||
}
|
||||
|
||||
function build(): Enterprise[] {
|
||||
const plan = buildBandPlan()
|
||||
const list: Enterprise[] = []
|
||||
|
||||
PINNED.forEach((p, i) => {
|
||||
const r = rng(seedOf(p.enterpriseId))
|
||||
const band = bandOf(p.score)
|
||||
list.push({
|
||||
id: i + 1,
|
||||
enterpriseId: p.enterpriseId,
|
||||
name: p.name,
|
||||
nameHi: p.nameHi,
|
||||
sector: p.sector,
|
||||
block: p.block,
|
||||
district: BLOCK_DISTRICT[p.block],
|
||||
ownerCount: pickInt(r, 1, 4),
|
||||
vintageMonths: pickInt(r, 14, 96),
|
||||
loanOutstanding: pickInt(r, 6, 34) * 10_000,
|
||||
monthlyInstalment: pickInt(r, 24, 96) * 250,
|
||||
score: p.score,
|
||||
band,
|
||||
trend: trendFor(r, p.score, band),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
for (let i = 0; i < TOTAL - PINNED.length; i++) {
|
||||
const enterpriseId = `MSME-${1000 + ((i * 37 + 113) % 8000)}`
|
||||
const r = rng(seedOf(enterpriseId))
|
||||
const sector = pickOne(r, SECTOR_LIST)
|
||||
const block = pickOne(r, BLOCKS)
|
||||
const band = plan[i]
|
||||
const score = scoreForBand(r, band)
|
||||
const nameIdx = pickInt(r, 0, NAME_PREFIX.length - 1)
|
||||
const sufIdx = pickInt(r, 0, SECTOR_SUFFIX[sector].en.length - 1)
|
||||
list.push({
|
||||
id: PINNED.length + i + 1,
|
||||
enterpriseId,
|
||||
name: `${NAME_PREFIX[nameIdx]} ${SECTOR_SUFFIX[sector].en[sufIdx]}`,
|
||||
nameHi: `${NAME_PREFIX_HI[nameIdx]} ${SECTOR_SUFFIX[sector].hi[sufIdx]}`,
|
||||
sector,
|
||||
block,
|
||||
district: BLOCK_DISTRICT[block],
|
||||
ownerCount: pickInt(r, 1, 4),
|
||||
vintageMonths: pickInt(r, 8, 120),
|
||||
loanOutstanding: pickInt(r, 4, 40) * 10_000,
|
||||
monthlyInstalment: pickInt(r, 16, 110) * 250,
|
||||
score,
|
||||
band,
|
||||
trend: trendFor(r, score, band),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Highest risk first — the order the console works the book in.
|
||||
return list.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
/** The whole demo portfolio, built once per page load. */
|
||||
export const ENTERPRISES: Enterprise[] = build()
|
||||
|
||||
export function findEnterprise(enterpriseId: string): Enterprise | undefined {
|
||||
return ENTERPRISES.find((e) => e.enterpriseId === enterpriseId)
|
||||
}
|
||||
|
||||
// ── Sub-scores, drivers and trip-wires ──────────────────────────
|
||||
|
||||
/** Slide-6 driver weights for the pinned enterprise, so the demo matches the submission. */
|
||||
const PINNED_SUBSCORES: Record<string, SubScores> = {
|
||||
'MSME-4417': { liquidity: 26, behavioural: 19, trendVolatility: 14, obligationLoad: 11, externalShock: 9 },
|
||||
}
|
||||
|
||||
export function subScoresFor(e: Enterprise): SubScores {
|
||||
const pinned = PINNED_SUBSCORES[e.enterpriseId]
|
||||
if (pinned) return pinned
|
||||
const r = rng(seedOf(`${e.enterpriseId}:sub`))
|
||||
const w = SECTOR_WEIGHTS[e.sector]
|
||||
// Distribute the composite across the five families, weighted by sector, with jitter.
|
||||
const raw = {
|
||||
liquidity: e.score * w.liquidity * (0.8 + r() * 0.5),
|
||||
behavioural: e.score * w.behavioural * (0.8 + r() * 0.5),
|
||||
trendVolatility: e.score * w.trendVolatility * (0.8 + r() * 0.5),
|
||||
obligationLoad: e.score * w.obligationLoad * (0.8 + r() * 0.5),
|
||||
externalShock: e.score * w.externalShock * (0.8 + r() * 0.5),
|
||||
}
|
||||
return {
|
||||
liquidity: Math.round(raw.liquidity),
|
||||
behavioural: Math.round(raw.behavioural),
|
||||
trendVolatility: Math.round(raw.trendVolatility),
|
||||
obligationLoad: Math.round(raw.obligationLoad),
|
||||
externalShock: Math.round(raw.externalShock),
|
||||
}
|
||||
}
|
||||
|
||||
export function stressScoreFor(e: Enterprise): StressScore {
|
||||
return {
|
||||
enterpriseId: e.enterpriseId,
|
||||
score: e.score,
|
||||
band: e.band,
|
||||
subScores: subScoresFor(e),
|
||||
computedAt: new Date().toISOString(),
|
||||
modelVersion: 'lgbm-q-v0.9.3',
|
||||
}
|
||||
}
|
||||
|
||||
export const DRIVER_META: { key: keyof SubScores; label: string; labelHi: string }[] = [
|
||||
{ key: 'liquidity', label: 'Liquidity', labelHi: 'नकदी' },
|
||||
{ key: 'behavioural', label: 'Behavioural (UPI)', labelHi: 'लेन-देन व्यवहार' },
|
||||
{ key: 'trendVolatility', label: 'Trend & volatility', labelHi: 'उतार-चढ़ाव' },
|
||||
{ key: 'obligationLoad', label: 'Obligation load', labelHi: 'किस्त का बोझ' },
|
||||
{ key: 'externalShock', label: 'External shock', labelHi: 'बाहरी झटका' },
|
||||
]
|
||||
|
||||
/** Sector-specific sentence templates — the deck's "models the trade, not one generic model". */
|
||||
const REASON_TEXT: Record<Sector, Record<keyof SubScores, { en: string; hi: string }>> = {
|
||||
POULTRY: {
|
||||
liquidity: { en: 'Feed costs up 18% against a 9% fall in egg realisation.', hi: 'दाना 18% महँगा और अंडे का भाव 9% गिरा है।' },
|
||||
behavioural: { en: 'UPI collections down 23% over eight weeks.', hi: 'आठ हफ़्तों में UPI से आया पैसा 23% घटा है।' },
|
||||
trendVolatility: { en: 'Weekly sales swinging twice as wide as last season.', hi: 'हफ़्तेवार बिक्री पिछले सीज़न से दोगुनी ऊपर-नीचे हो रही है।' },
|
||||
obligationLoad: { en: 'Instalment now takes 41% of net monthly cash.', hi: 'किस्त अब महीने की कमाई का 41% ले रही है।' },
|
||||
externalShock: { en: 'Heat anomaly flagged in the block for the next fortnight.', hi: 'अगले पंद्रह दिन ब्लॉक में गर्मी की चेतावनी है।' },
|
||||
},
|
||||
DAIRY: {
|
||||
liquidity: { en: 'Fodder cost up 14% while procurement rate stayed flat.', hi: 'चारा 14% महँगा हुआ, दूध का रेट वही है।' },
|
||||
behavioural: { en: 'Society payment cycle stretched from 10 to 18 days.', hi: 'समिति से भुगतान 10 दिन की जगह 18 दिन में आ रहा है।' },
|
||||
trendVolatility: { en: 'Daily yield varying sharply across the last six weeks.', hi: 'छह हफ़्तों में रोज़ का दूध बहुत घट-बढ़ रहा है।' },
|
||||
obligationLoad: { en: 'Two instalments now fall in the same fortnight.', hi: 'दो किस्तें एक ही पखवाड़े में आ रही हैं।' },
|
||||
externalShock: { en: 'Rainfall deficit in the block is raising fodder prices.', hi: 'ब्लॉक में कम बारिश से चारा महँगा हो रहा है।' },
|
||||
},
|
||||
FOOD_PROC: {
|
||||
liquidity: { en: 'Raw material prepayment is locking up working capital.', hi: 'कच्चे माल का अग्रिम भुगतान पूँजी रोक रहा है।' },
|
||||
behavioural: { en: 'Buyer settlements slipping past the agreed 15 days.', hi: 'खरीदार 15 दिन की जगह देर से पैसा दे रहे हैं।' },
|
||||
trendVolatility: { en: 'Order volume swinging with festival demand.', hi: 'त्योहारों के साथ ऑर्डर बहुत घट-बढ़ रहे हैं।' },
|
||||
obligationLoad: { en: 'Instalment plus rent exceeds 50% of monthly inflow.', hi: 'किस्त और किराया मिलकर आधी से ज़्यादा कमाई ले रहे हैं।' },
|
||||
externalShock: { en: 'Mandi price shock in the primary input crop.', hi: 'मुख्य फ़सल के मंडी भाव में तेज़ बदलाव है।' },
|
||||
},
|
||||
HANDICRAFTS: {
|
||||
liquidity: { en: 'Finished stock is unsold going into the lean quarter.', hi: 'तैयार माल बिका नहीं और सुस्त तिमाही आ रही है।' },
|
||||
behavioural: { en: 'Top buyer accounts for 68% of all receipts.', hi: 'कुल आमदनी का 68% सिर्फ़ एक खरीदार से आता है।' },
|
||||
trendVolatility: { en: 'Receipts concentrated in two months of the year.', hi: 'साल की कमाई सिर्फ़ दो महीनों में सिमटी है।' },
|
||||
obligationLoad: { en: 'Repayment schedule ignores the seasonal lull.', hi: 'किस्त का समय सुस्त सीज़न का ध्यान नहीं रखता।' },
|
||||
externalShock: { en: 'Exhibition calendar thinner than last year.', hi: 'पिछले साल से कम प्रदर्शनियाँ लगी हैं।' },
|
||||
},
|
||||
RURAL_RETAIL: {
|
||||
liquidity: { en: 'Credit sales to customers up 31% this quarter.', hi: 'इस तिमाही उधार बिक्री 31% बढ़ी है।' },
|
||||
behavioural: { en: 'Daily UPI receipt count down for six straight weeks.', hi: 'छह हफ़्तों से रोज़ के UPI लेन-देन घट रहे हैं।' },
|
||||
trendVolatility: { en: 'Footfall dropping since the new road closure.', hi: 'रास्ता बंद होने से ग्राहक कम आ रहे हैं।' },
|
||||
obligationLoad: { en: 'Stock restocking clashes with the instalment date.', hi: 'माल भरने और किस्त की तारीख़ एक साथ पड़ रही है।' },
|
||||
externalShock: { en: 'Local market disruption flagged in the block.', hi: 'ब्लॉक में स्थानीय बाज़ार में गड़बड़ी दर्ज है।' },
|
||||
},
|
||||
}
|
||||
|
||||
/** Top-3 SHAP drivers as plain-language sentences (deck feature 4). */
|
||||
export function reasonCodesFor(e: Enterprise): ReasonCode[] {
|
||||
const sub = subScoresFor(e)
|
||||
return DRIVER_META.map((d) => ({
|
||||
driver: d.label,
|
||||
driverHi: d.labelHi,
|
||||
weight: sub[d.key],
|
||||
sentenceEn: REASON_TEXT[e.sector][d.key].en,
|
||||
sentenceHi: REASON_TEXT[e.sector][d.key].hi,
|
||||
}))
|
||||
.sort((a, b) => b.weight - a.weight)
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
/** Rule-based alerts that fire independently of the model, for auditability (feature 3). */
|
||||
export function tripWiresFor(e: Enterprise): TripWire[] {
|
||||
const r = rng(seedOf(`${e.enterpriseId}:tw`))
|
||||
const sub = subScoresFor(e)
|
||||
const dscr = (1.9 - e.score / 100).toFixed(2)
|
||||
return [
|
||||
{
|
||||
code: 'TW-01',
|
||||
label: 'Projected DSCR below 1.0',
|
||||
labelHi: 'किस्त चुकाने की क्षमता 1.0 से नीचे',
|
||||
fired: e.band === 'RED',
|
||||
detail: `P10 debt-service coverage projected at ${dscr}`,
|
||||
threshold: 'DSCR(P10) < 1.00',
|
||||
},
|
||||
{
|
||||
code: 'TW-02',
|
||||
label: 'UPI inflow drop over 8 weeks',
|
||||
labelHi: 'आठ हफ़्तों में UPI आमदनी में गिरावट',
|
||||
fired: sub.behavioural >= 15,
|
||||
detail: `Rolling 8-week inflow down ${pickInt(r, 12, 34)}%`,
|
||||
threshold: 'Δ inflow < −20%',
|
||||
},
|
||||
{
|
||||
code: 'TW-03',
|
||||
label: 'Ledger silent for 14 days',
|
||||
labelHi: '14 दिन से हिसाब दर्ज नहीं',
|
||||
fired: e.band !== 'STANDARD' && r() > 0.55,
|
||||
detail: `Last entry ${pickInt(r, 2, 26)} days ago`,
|
||||
threshold: 'no entry ≥ 14 days',
|
||||
},
|
||||
{
|
||||
code: 'TW-04',
|
||||
label: 'Instalment share of inflow above 40%',
|
||||
labelHi: 'किस्त आमदनी का 40% से ज़्यादा',
|
||||
fired: sub.obligationLoad >= 11,
|
||||
detail: `Instalment is ${pickInt(r, 22, 58)}% of median monthly inflow`,
|
||||
threshold: 'EMI / inflow > 40%',
|
||||
},
|
||||
{
|
||||
code: 'TW-05',
|
||||
label: 'Climate or mandi shock in block',
|
||||
labelHi: 'ब्लॉक में मौसम या मंडी का झटका',
|
||||
fired: sub.externalShock >= 9,
|
||||
detail: 'IMD heat anomaly + Agmarknet input price spike',
|
||||
threshold: 'anomaly z-score > 1.5',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ── Forecast ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Six-month quantile path. P50 drifts down for stressed enterprises; P10/P90
|
||||
* fan out with horizon, because uncertainty compounds. The obligation line is
|
||||
* the instalment plus fixed costs due that month — where P10 dips under it,
|
||||
* the enterprise escalates.
|
||||
*/
|
||||
export function forecastFor(e: Enterprise, horizon = 6): ForecastPoint[] {
|
||||
const r = rng(seedOf(`${e.enterpriseId}:fc`))
|
||||
const months = monthWindow(horizon)
|
||||
const base = 18_000 + (100 - e.score) * 420 + pickInt(r, -2000, 2500)
|
||||
const drift = e.band === 'RED' ? -0.14 : e.band === 'WATCH' ? -0.05 : 0.035
|
||||
const fixed = e.monthlyInstalment + pickInt(r, 8, 22) * 400
|
||||
|
||||
const points = months.map((m, h) => {
|
||||
const centre = base * (1 + drift * (h + 1)) + (r() - 0.5) * 1800
|
||||
const spread = centre * (0.16 + h * 0.045) + 900
|
||||
return {
|
||||
month: m.key,
|
||||
label: m.label,
|
||||
labelHi: m.labelHi,
|
||||
p10: Math.round(centre - spread),
|
||||
p50: Math.round(centre),
|
||||
p90: Math.round(centre + spread * 0.85),
|
||||
obligation: Math.round(fixed + h * 120),
|
||||
}
|
||||
})
|
||||
|
||||
// The wireframe slide quotes a ₹12,400 shortfall three months out for the
|
||||
// headline enterprise — pin it so the prototype matches the submitted deck.
|
||||
const pin = PINNED_SHORTFALL[e.enterpriseId]
|
||||
if (pin && points.length > pin.horizon) {
|
||||
const p = points[pin.horizon]
|
||||
p.p10 = p.obligation - pin.amount
|
||||
p.p50 = Math.max(p.p50, p.p10 + 4_000)
|
||||
p.p90 = Math.max(p.p90, p.p50 + 5_000)
|
||||
// Everything before the pinned month must still clear its obligation,
|
||||
// or the "first breach" would land earlier than the deck says.
|
||||
for (let i = 0; i < pin.horizon; i++) {
|
||||
points[i].p10 = Math.max(points[i].p10, points[i].obligation + 1_500)
|
||||
points[i].p50 = Math.max(points[i].p50, points[i].p10 + 3_000)
|
||||
points[i].p90 = Math.max(points[i].p90, points[i].p50 + 4_000)
|
||||
}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/** Enterprise → { months ahead of the first breach, ₹ gap }, as printed on the deck. */
|
||||
const PINNED_SHORTFALL: Record<string, { horizon: number; amount: number }> = {
|
||||
'MSME-4417': { horizon: 2, amount: 12_400 },
|
||||
}
|
||||
|
||||
/** First month where the downside case fails to cover obligations, or null. */
|
||||
export function breachPoint(points: ForecastPoint[]): ForecastPoint | null {
|
||||
return points.find((p) => p.p10 < p.obligation) ?? null
|
||||
}
|
||||
|
||||
/** Size of the projected gap at the breach month. */
|
||||
export function shortfallOf(points: ForecastPoint[]): number {
|
||||
const b = breachPoint(points)
|
||||
return b ? Math.max(0, b.obligation - b.p10) : 0
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import type {
|
||||
ClimateSignal,
|
||||
FeatureDefinition,
|
||||
FeatureValueRow,
|
||||
Freshness,
|
||||
IngestionRun,
|
||||
MarketSignal,
|
||||
RuleOutcome,
|
||||
TripWireRule,
|
||||
} from '../api'
|
||||
import { BLOCKS, BLOCK_DISTRICT, findEnterprise, tripWiresFor } from './enterprises'
|
||||
import { dayOffsetIso, pickInt, rng, seedOf } from './seed'
|
||||
|
||||
/*
|
||||
* Demo fallback for the ingestion, feature-store and rule-engine screens.
|
||||
*
|
||||
* These screens exist to show *provenance* — where a number came from and how
|
||||
* old it is — so the fallback has to be internally consistent rather than merely
|
||||
* plausible: a rule that skips for stale data here must skip against the same
|
||||
* staleness the freshness panel is showing.
|
||||
*/
|
||||
|
||||
const DISTRICTS = BLOCKS.map((b) => ({ code: b.toUpperCase(), name: BLOCK_DISTRICT[b] ?? b }))
|
||||
|
||||
/** Monday of the ISO week `weeksAgo` weeks back. */
|
||||
function weekStart(weeksAgo: number): string {
|
||||
const d = new Date()
|
||||
const day = (d.getDay() + 6) % 7 // 0 = Monday
|
||||
d.setDate(d.getDate() - day - weeksAgo * 7)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
// ── freshness ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Agmarknet reports most days, so market data is a day or two old; the climate
|
||||
* reanalysis archive trails real time by about five days by design.
|
||||
*/
|
||||
export function demoFreshness(): Freshness[] {
|
||||
return DISTRICTS.map((d) => {
|
||||
const r = rng(seedOf(`fresh:${d.code}`))
|
||||
const market = pickInt(r, 1, 4)
|
||||
const climate = pickInt(r, 5, 8)
|
||||
return {
|
||||
districtCode: d.code,
|
||||
districtName: d.name,
|
||||
marketValidFor: dayOffsetIso(-market),
|
||||
marketStalenessDays: market,
|
||||
climateValidFor: dayOffsetIso(-climate),
|
||||
climateStalenessDays: climate,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** The worst staleness across districts — what the shell's chip reports. */
|
||||
export function worstStaleness(rows: Freshness[]): number {
|
||||
return rows.reduce((worst, r) => Math.max(worst, r.marketStalenessDays ?? 0), 0)
|
||||
}
|
||||
|
||||
// ── market signals ───────────────────────────────────────────────
|
||||
|
||||
const COMMODITIES = [
|
||||
{ name: 'Maize', unit: 'QUINTAL', base: 2140 },
|
||||
{ name: 'Soyabean', unit: 'QUINTAL', base: 4680 },
|
||||
{ name: 'Wheat', unit: 'QUINTAL', base: 2520 },
|
||||
{ name: 'Green fodder', unit: 'QUINTAL', base: 680 },
|
||||
]
|
||||
|
||||
export function demoMarketSignals(weeks = 8): MarketSignal[] {
|
||||
const rows: MarketSignal[] = []
|
||||
let id = 1
|
||||
for (const d of DISTRICTS) {
|
||||
for (const c of COMMODITIES) {
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
const r = rng(seedOf(`mkt:${d.code}:${c.name}:${w}`))
|
||||
// A slow upward drift on the feed side, which is what makes a squeeze.
|
||||
const drift = 1 + (weeks - w) * 0.011
|
||||
const modal = Math.round(c.base * drift * (0.97 + r() * 0.06))
|
||||
rows.push({
|
||||
id: id++,
|
||||
districtName: d.name,
|
||||
commodityName: c.name,
|
||||
weekStart: weekStart(w),
|
||||
modalPrice: modal,
|
||||
minPrice: Math.round(modal * 0.93),
|
||||
maxPrice: Math.round(modal * 1.08),
|
||||
arrivalCount: pickInt(r, 2, 6),
|
||||
unit: c.unit,
|
||||
source: 'AGMARKNET',
|
||||
validForDate: dayOffsetIso(-(w * 7 + 2)),
|
||||
observedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function demoClimateSignals(weeks = 8): ClimateSignal[] {
|
||||
const rows: ClimateSignal[] = []
|
||||
let id = 1
|
||||
for (const d of DISTRICTS) {
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
const r = rng(seedOf(`clm:${d.code}:${w}`))
|
||||
const normal = 42 + pickInt(r, -8, 12)
|
||||
const rain = Math.max(0, Math.round(normal * (0.45 + r() * 0.8)))
|
||||
const tmaxNormal = 33 + pickInt(r, -2, 3)
|
||||
const tmax = tmaxNormal + (r() * 4 - 1)
|
||||
rows.push({
|
||||
id: id++,
|
||||
districtName: d.name,
|
||||
weekStart: weekStart(w),
|
||||
isoWeek: 30 - w,
|
||||
rainfallMm: rain,
|
||||
tmaxC: Number(tmax.toFixed(1)),
|
||||
rainfallNormalMm: normal,
|
||||
rainfallAnomalyPct: Number((((rain - normal) / normal) * 100).toFixed(1)),
|
||||
heatAnomalyC: Number((tmax - tmaxNormal).toFixed(1)),
|
||||
validForDate: dayOffsetIso(-(w * 7 + 5)),
|
||||
observedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function demoIngestionRuns(): IngestionRun[] {
|
||||
const runs: IngestionRun[] = []
|
||||
let id = 1
|
||||
for (let day = 0; day < 5; day++) {
|
||||
for (const source of ['AGMARKNET', 'OPEN_METEO']) {
|
||||
const r = rng(seedOf(`run:${source}:${day}`))
|
||||
const failed = day === 2 && source === 'AGMARKNET' ? 3 : 0
|
||||
runs.push({
|
||||
id: id++,
|
||||
source,
|
||||
runDate: dayOffsetIso(-day),
|
||||
trigger: 'SCHEDULED',
|
||||
status: failed > 0 ? 'PARTIAL' : 'SUCCESS',
|
||||
rowsFetched: pickInt(r, 120, 340),
|
||||
rowsWritten: pickInt(r, 24, 64),
|
||||
callsFailed: failed,
|
||||
durationMs: pickInt(r, 1800, 9400),
|
||||
startedAt: `${dayOffsetIso(-day)}T02:00:11`,
|
||||
message:
|
||||
failed > 0
|
||||
? '3 calls failed — upstream timeout, partial week written'
|
||||
: 'all districts written',
|
||||
})
|
||||
}
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
// ── feature catalogue ────────────────────────────────────────────
|
||||
|
||||
/** Mirrors the backend catalogue, including the three the trip-wires demanded. */
|
||||
export const DEMO_FEATURES: FeatureDefinition[] = [
|
||||
f('feed_cost_change', 'Feed cost change', 'EXTERNAL_SHOCK', 'RATIO', 'AGMARKNET', 10),
|
||||
f('output_price_change', 'Output price change', 'EXTERNAL_SHOCK', 'RATIO', 'AGMARKNET', 10),
|
||||
f('margin_squeeze', 'Margin squeeze', 'EXTERNAL_SHOCK', 'RATIO', 'AGMARKNET', 10),
|
||||
f('price_z_score', 'Seasonal price z-score', 'TREND_VOLATILITY', 'INDEX', 'AGMARKNET', 10),
|
||||
f('rainfall_anomaly', 'Rainfall anomaly', 'EXTERNAL_SHOCK', 'PCT', 'CLIMATE', 14),
|
||||
f('heat_anomaly', 'Heat anomaly', 'EXTERNAL_SHOCK', 'INDEX', 'CLIMATE', 14),
|
||||
f('cash_flow_change', 'Cash flow change', 'LIQUIDITY', 'RATIO', 'LEDGER', 7),
|
||||
f('emi_delay', 'EMI delay', 'BEHAVIOURAL', 'DAYS', 'CBS', 35),
|
||||
f('inventory_days', 'Inventory days', 'LIQUIDITY', 'DAYS', 'LEDGER', 14),
|
||||
f('upi_inflow_change', 'UPI inflow change', 'LIQUIDITY', 'RATIO', 'UPI', 21),
|
||||
f('feed_staleness_days', 'Feed staleness', 'QUALITY', 'DAYS', 'AGMARKNET', null),
|
||||
f('dscr_p10', 'DSCR at P10', 'OBLIGATION_LOAD', 'RATIO', 'FORECAST', 7),
|
||||
f('days_since_last_entry', 'Days since last ledger entry', 'BEHAVIOURAL', 'DAYS', 'LEDGER', 3),
|
||||
f('emi_to_inflow', 'Instalment share of inflow', 'OBLIGATION_LOAD', 'RATIO', 'CBS', 30),
|
||||
]
|
||||
|
||||
function f(
|
||||
code: string,
|
||||
label: string,
|
||||
family: string,
|
||||
unit: string,
|
||||
sourceKind: string,
|
||||
staleness: number | null,
|
||||
): FeatureDefinition {
|
||||
return { code, label, family, unit, sourceKind, stalenessToleranceDays: staleness, direction: null, derivation: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* The tall rows for one enterprise. `upi_inflow_change` is null on purpose —
|
||||
* there is no Account Aggregator consent yet, and showing that honestly is the
|
||||
* point of the missing-reason column.
|
||||
*/
|
||||
export function demoFeatureValues(enterpriseId: string): FeatureValueRow[] {
|
||||
const e = findEnterprise(enterpriseId)
|
||||
const r = rng(seedOf(`feat:${enterpriseId}`))
|
||||
const stress = (e?.score ?? 50) / 100
|
||||
const today = dayOffsetIso(0)
|
||||
|
||||
const value = (code: string, v: number | null, staleness: number | null, missing?: string): FeatureValueRow => ({
|
||||
featureCode: code,
|
||||
value: v,
|
||||
validForDate: today,
|
||||
stalenessDays: staleness,
|
||||
missingReason: missing ?? null,
|
||||
})
|
||||
|
||||
return [
|
||||
value('feed_cost_change', Number((0.04 + stress * 0.22).toFixed(3)), 2),
|
||||
value('output_price_change', Number((0.05 - stress * 0.07).toFixed(3)), 2),
|
||||
value('margin_squeeze', Number((stress * 0.28 - 0.01).toFixed(3)), 2),
|
||||
value('price_z_score', Number((stress * 2.6 - 0.4).toFixed(2)), 2),
|
||||
value('rainfall_anomaly', Number((-stress * 46 + 8).toFixed(1)), 6),
|
||||
value('heat_anomaly', Number((stress * 2.4).toFixed(2)), 6),
|
||||
value('cash_flow_change', Number((0.06 - stress * 0.6).toFixed(3)), 1),
|
||||
value('emi_delay', pickInt(r, 0, Math.round(stress * 70)), 4),
|
||||
value('inventory_days', pickInt(r, 8, Math.round(12 + stress * 50)), 1),
|
||||
value('upi_inflow_change', null, null, 'NO_CONSENT'),
|
||||
value('feed_staleness_days', 2, 2),
|
||||
value('dscr_p10', Number((1.65 - stress * 1.05).toFixed(2)), 0),
|
||||
value('days_since_last_entry', pickInt(r, 0, Math.round(stress * 22)), 0),
|
||||
value('emi_to_inflow', Number((0.12 + stress * 0.4).toFixed(3)), 4),
|
||||
]
|
||||
}
|
||||
|
||||
// ── rules ────────────────────────────────────────────────────────
|
||||
|
||||
export const DEMO_RULES: TripWireRule[] = [
|
||||
rule(1, 'TW-01', 'Projected DSCR below 1.0', 'किस्त चुकाने की क्षमता 1.0 से नीचे', 'dscr_p10 < 1.0', 'DSCR(P10) < 1.00', 'RED', 'dscr_p10', 7),
|
||||
rule(2, 'TW-02', 'UPI inflow drop over 8 weeks', 'आठ हफ़्तों में UPI आमदनी में गिरावट', 'upi_inflow_change < -0.20', 'Δ inflow < −20%', 'RED', 'upi_inflow_change', 10),
|
||||
rule(3, 'TW-03', 'Ledger silent for 14 days', '14 दिन से हिसाब दर्ज नहीं', 'days_since_last_entry >= 14', 'no entry ≥ 14 days', 'WATCH', 'days_since_last_entry', 3),
|
||||
rule(4, 'TW-04', 'Instalment share of inflow above 40%', 'किस्त आमदनी का 40% से ज़्यादा', 'emi_to_inflow > 0.40', 'EMI / inflow > 40%', 'WATCH', 'emi_to_inflow', 30),
|
||||
rule(5, 'TW-05', 'Climate or mandi shock in block', 'ब्लॉक में मौसम या मंडी का झटका', 'price_z_score > 1.5 or heat_anomaly > 1.5', 'anomaly z-score > 1.5', 'WATCH', 'price_z_score,heat_anomaly', 14),
|
||||
]
|
||||
|
||||
function rule(
|
||||
id: number,
|
||||
code: string,
|
||||
label: string,
|
||||
labelHi: string,
|
||||
expression: string,
|
||||
thresholdText: string,
|
||||
severity: 'RED' | 'WATCH',
|
||||
requiredFeatures: string,
|
||||
maxStalenessDays: number,
|
||||
): TripWireRule {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
label,
|
||||
labelHi,
|
||||
expression,
|
||||
thresholdText,
|
||||
severity,
|
||||
sectorCode: null,
|
||||
requiredFeatures,
|
||||
maxStalenessDays,
|
||||
active: true,
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the demo rules against the demo feature rows — the same two guards
|
||||
* the backend applies, in the same order: missing first, then stale, then the
|
||||
* expression. TW-02 therefore always skips for want of consent, which is exactly
|
||||
* what the real engine reports today.
|
||||
*/
|
||||
export function demoRuleOutcomes(enterpriseId: string): RuleOutcome[] {
|
||||
const values = new Map(demoFeatureValues(enterpriseId).map((v) => [v.featureCode, v]))
|
||||
const catalogue = new Map(DEMO_FEATURES.map((d) => [d.code, d]))
|
||||
const fired = new Map(tripWiresFor(findEnterprise(enterpriseId)!).map((t) => [t.code, t.fired]))
|
||||
|
||||
return DEMO_RULES.map((r): RuleOutcome => {
|
||||
const base = { ruleCode: r.code, ruleId: r.id, ruleVersion: r.version, severity: r.severity }
|
||||
|
||||
for (const code of r.requiredFeatures.split(',')) {
|
||||
const v = values.get(code.trim())
|
||||
if (!v || v.value === null) {
|
||||
return { ...base, status: 'SKIPPED_MISSING', detail: `${code} (${v?.missingReason ?? 'not computed'})` }
|
||||
}
|
||||
const ruleLimit = r.maxStalenessDays
|
||||
const featureLimit = catalogue.get(code.trim())?.stalenessToleranceDays ?? null
|
||||
const tolerance = featureLimit === null ? ruleLimit : Math.min(ruleLimit, featureLimit)
|
||||
if (v.stalenessDays !== null && v.stalenessDays > tolerance) {
|
||||
return {
|
||||
...base,
|
||||
status: 'SKIPPED_STALE',
|
||||
detail: `${code} is ${v.stalenessDays}d old, tolerance ${tolerance}d`,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...base, status: fired.get(r.code) ? 'FIRED' : 'NOT_FIRED', detail: r.thresholdText }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import type {
|
||||
AlertOrigin,
|
||||
AlertState,
|
||||
Enterprise,
|
||||
FieldVisit,
|
||||
PlaybookAction,
|
||||
PortfolioSummary,
|
||||
Sector,
|
||||
VisitState,
|
||||
} from '../api'
|
||||
import type { RiskAlert } from '../api'
|
||||
import {
|
||||
ENTERPRISES,
|
||||
SECTORS,
|
||||
breachPoint,
|
||||
forecastFor,
|
||||
reasonCodesFor,
|
||||
shortfallOf,
|
||||
tripWiresFor,
|
||||
} from './enterprises'
|
||||
import { currentMonthKey, dayOffsetIso, monthRef, pickInt, pickOne, rng, seedOf } from './seed'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Portfolio, alert queue, visit playbook and benchmark figures. */
|
||||
/* All derived from ENTERPRISES so every screen agrees. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const ALL_BLOCKS = 'ALL'
|
||||
|
||||
export function scopedEnterprises(block: string, sector?: Sector | 'ALL'): Enterprise[] {
|
||||
return ENTERPRISES.filter(
|
||||
(e) => (block === ALL_BLOCKS || e.block === block) && (!sector || sector === 'ALL' || e.sector === sector),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard KPI tiles. At the default scope (all blocks, current month) the
|
||||
* counts are 142 / 9 / 31 / 102 with the deck's deltas; any narrower scope
|
||||
* recomputes honestly from the filtered set.
|
||||
*/
|
||||
export function portfolioSummary(block: string, month: string): PortfolioSummary {
|
||||
const rows = scopedEnterprises(block)
|
||||
const red = rows.filter((e) => e.band === 'RED').length
|
||||
const watch = rows.filter((e) => e.band === 'WATCH').length
|
||||
const standard = rows.filter((e) => e.band === 'STANDARD').length
|
||||
const r = rng(seedOf(`${block}:${month}:summary`))
|
||||
const wide = block === ALL_BLOCKS && month === currentMonthKey()
|
||||
return {
|
||||
block,
|
||||
month,
|
||||
total: rows.length,
|
||||
totalDelta: wide ? 4 : pickInt(r, 0, 3),
|
||||
red,
|
||||
redDelta: wide ? 2 : pickInt(r, -1, 2),
|
||||
watch,
|
||||
watchDelta: wide ? -3 : pickInt(r, -3, 2),
|
||||
standard,
|
||||
standardDelta: wide ? 0 : pickInt(r, -2, 4),
|
||||
updatedMinutesAgo: wide ? 12 : pickInt(r, 3, 55),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alerts ──────────────────────────────────────────────────────
|
||||
|
||||
const ALERT_STATES: AlertState[] = ['OPEN', 'OPEN', 'OPEN', 'ACKNOWLEDGED', 'ESCALATED', 'SNOOZED']
|
||||
|
||||
/** Every Red and Watch enterprise carries a live alert; Standard ones do not. */
|
||||
export function alertsFor(block: string): RiskAlert[] {
|
||||
return scopedEnterprises(block)
|
||||
.filter((e) => e.band !== 'STANDARD')
|
||||
.map((e, i) => {
|
||||
const r = rng(seedOf(`${e.enterpriseId}:alert`))
|
||||
const points = forecastFor(e)
|
||||
const breach = breachPoint(points)
|
||||
const reason = reasonCodesFor(e)[0]
|
||||
const wires = tripWiresFor(e).filter((w) => w.fired)
|
||||
const origin: AlertOrigin = wires.length >= 3 ? 'TRIPWIRE' : 'MODEL'
|
||||
const breachMonthRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
|
||||
return {
|
||||
id: i + 1,
|
||||
alertId: `ALT-${String(4200 + i)}`,
|
||||
enterpriseId: e.enterpriseId,
|
||||
enterpriseName: e.name,
|
||||
sector: e.sector,
|
||||
block: e.block,
|
||||
band: e.band,
|
||||
score: e.score,
|
||||
origin,
|
||||
title: breachMonthRef
|
||||
? `Cash shortfall projected by ${breachMonthRef.fullEn}`
|
||||
: 'Stress score above watch threshold',
|
||||
titleHi: breachMonthRef
|
||||
? `${breachMonthRef.fullHi} तक पैसा कम पड़ सकता है`
|
||||
: 'जोखिम स्कोर निगरानी सीमा से ऊपर',
|
||||
reason,
|
||||
shortfall: shortfallOf(points),
|
||||
breachMonth: breach?.month ?? '',
|
||||
state: e.band === 'RED' ? 'OPEN' : pickOne(r, ALERT_STATES),
|
||||
raisedAt: dayOffsetIso(-pickInt(r, 0, 21)),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
/** The single alert the enterprise app shows on its Home tab. */
|
||||
export function alertForEnterprise(enterpriseId: string): RiskAlert | null {
|
||||
return alertsFor(ALL_BLOCKS).find((a) => a.enterpriseId === enterpriseId) ?? null
|
||||
}
|
||||
|
||||
// ── Field visits / playbook ─────────────────────────────────────
|
||||
|
||||
/** Ranked interventions the officer works down (deck feature 8). */
|
||||
export const PLAYBOOK: PlaybookAction[] = [
|
||||
{ code: 'PB-01', label: 'Restructure instalment to the earning season', labelHi: 'किस्त को कमाई के मौसम से मिलाएँ', appliesTo: 'RED', detail: 'Move the due date behind the sector cash peak; no principal change.' },
|
||||
{ code: 'PB-02', label: 'Sanction a short working-capital top-up', labelHi: 'थोड़ी कार्यशील पूँजी और दें', appliesTo: 'RED', detail: 'Bridge the projected P10 gap for two months against stock.' },
|
||||
{ code: 'PB-03', label: 'Field visit — verify stock and receivables', labelHi: 'मौके पर जाकर स्टॉक और उधारी जाँचें', appliesTo: 'RED', detail: 'Confirm the ledger against physical stock before any action.' },
|
||||
{ code: 'PB-04', label: 'Input-cost counselling with the sector pack', labelHi: 'लागत घटाने की सलाह दें', appliesTo: 'WATCH', detail: 'Bulk feed/raw purchase timing against the mandi price curve.' },
|
||||
{ code: 'PB-05', label: 'Enrol for daily ledger reminders', labelHi: 'रोज़ हिसाब दर्ज करने की याद दिलाएँ', appliesTo: 'WATCH', detail: 'Ledger silence is the strongest early behavioural signal.' },
|
||||
{ code: 'PB-06', label: 'Diversify buyer concentration', labelHi: 'एक ही खरीदार पर निर्भरता घटाएँ', appliesTo: 'WATCH', detail: 'Introduce to the block procurement group.' },
|
||||
{ code: 'PB-07', label: 'Routine monitoring — no action', labelHi: 'सिर्फ़ निगरानी, कोई कार्रवाई नहीं', appliesTo: 'STANDARD', detail: 'Re-score next cycle.' },
|
||||
]
|
||||
|
||||
const VISIT_STATES: VisitState[] = ['PLANNED', 'DONE', 'OVERDUE']
|
||||
const OUTCOMES = [
|
||||
'Stock verified, ledger matches. Advised bulk feed purchase.',
|
||||
'Owner unavailable — revisit scheduled.',
|
||||
'Instalment date moved to the 20th, sanctioned by branch.',
|
||||
'Working-capital top-up of ₹25,000 recommended.',
|
||||
'Buyer payment confirmed as delayed, not defaulted.',
|
||||
]
|
||||
|
||||
export function visitsFor(block: string, officer = 'Aditya Vyas'): FieldVisit[] {
|
||||
return scopedEnterprises(block)
|
||||
.filter((e) => e.band !== 'STANDARD')
|
||||
.slice(0, 24)
|
||||
.map((e, i) => {
|
||||
const r = rng(seedOf(`${e.enterpriseId}:visit`))
|
||||
const action = PLAYBOOK.filter((p) => p.appliesTo === e.band)[i % 3] ?? PLAYBOOK[0]
|
||||
const state: VisitState = e.band === 'RED' && i < 6 ? (i < 4 ? 'DONE' : 'PLANNED') : pickOne(r, VISIT_STATES)
|
||||
return {
|
||||
id: i + 1,
|
||||
visitId: `FV-${String(1800 + i)}`,
|
||||
enterpriseId: e.enterpriseId,
|
||||
enterpriseName: e.name,
|
||||
block: e.block,
|
||||
action: action.label,
|
||||
actionHi: action.labelHi,
|
||||
priority: e.band === 'RED' ? 1 : 2,
|
||||
dueDate: dayOffsetIso(pickInt(r, -6, 14)),
|
||||
state,
|
||||
outcome: state === 'DONE' ? pickOne(r, OUTCOMES) : null,
|
||||
officer,
|
||||
loggedAt: state === 'DONE' ? dayOffsetIso(-pickInt(r, 1, 9)) : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** "This week 6 / 9 high-risk visits done" — the sidebar progress card on slide 6. */
|
||||
export function weeklyVisitProgress(block: string): { done: number; target: number } {
|
||||
const visits = visitsFor(block).filter((v) => v.priority === 1)
|
||||
const target = block === ALL_BLOCKS ? 9 : Math.max(3, visits.length)
|
||||
const done = block === ALL_BLOCKS ? 6 : visits.filter((v) => v.state === 'DONE').length
|
||||
return { done, target }
|
||||
}
|
||||
|
||||
// ── Benchmarks (deck slide 10) ──────────────────────────────────
|
||||
|
||||
export const BENCHMARKS = {
|
||||
aucRoc: 0.87,
|
||||
aucRepaymentOnly: 0.68,
|
||||
aucBureau: 0.61,
|
||||
alertPrecisionRed: 0.79,
|
||||
medianLeadTimeMonths: '3–6',
|
||||
onDeviceLatencyMs: 250,
|
||||
datasetSize: 2400,
|
||||
sectorsCovered: 5,
|
||||
districtsCovered: 4,
|
||||
}
|
||||
|
||||
/** Band migration month over month — how the book moved. */
|
||||
export function bandMigration(block: string): { month: string; red: number; watch: number; standard: number }[] {
|
||||
const rows = scopedEnterprises(block)
|
||||
const r = rng(seedOf(`${block}:migration`))
|
||||
const base = new Date()
|
||||
return Array.from({ length: 6 }, (_, i) => {
|
||||
const d = new Date(base.getFullYear(), base.getMonth() - (5 - i), 1)
|
||||
const m = monthRef(d)
|
||||
const decay = 5 - i
|
||||
return {
|
||||
month: m.label,
|
||||
red: Math.max(0, rows.filter((e) => e.band === 'RED').length - decay + pickInt(r, -1, 1)),
|
||||
watch: Math.max(0, rows.filter((e) => e.band === 'WATCH').length - decay * 2 + pickInt(r, -2, 3)),
|
||||
standard: rows.filter((e) => e.band === 'STANDARD').length + decay * 2,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Per-sector-pack rollup for the Forecasts screen. */
|
||||
export function sectorRollup(block: string) {
|
||||
return (Object.keys(SECTORS) as Sector[]).map((s) => {
|
||||
const rows = scopedEnterprises(block, s)
|
||||
const red = rows.filter((e) => e.band === 'RED').length
|
||||
const watch = rows.filter((e) => e.band === 'WATCH').length
|
||||
const avg = rows.length ? rows.reduce((a, e) => a + e.score, 0) / rows.length : 0
|
||||
return {
|
||||
sector: s,
|
||||
label: SECTORS[s].label,
|
||||
count: rows.length,
|
||||
red,
|
||||
watch,
|
||||
standard: rows.length - red - watch,
|
||||
avgScore: Math.round(avg),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Portfolio-level P10/P50/P90 aggregate over the 6-month horizon. */
|
||||
export function portfolioForecast(block: string) {
|
||||
const rows = scopedEnterprises(block)
|
||||
const sample = rows.slice(0, 60) // enough for a stable aggregate without 142 × 6 work
|
||||
const paths = sample.map((e) => forecastFor(e))
|
||||
return paths[0].map((_, h) => ({
|
||||
month: paths[0][h].month,
|
||||
label: paths[0][h].label,
|
||||
labelHi: paths[0][h].labelHi,
|
||||
p10: Math.round(paths.reduce((a, p) => a + p[h].p10, 0) / paths.length),
|
||||
p50: Math.round(paths.reduce((a, p) => a + p[h].p50, 0) / paths.length),
|
||||
p90: Math.round(paths.reduce((a, p) => a + p[h].p90, 0) / paths.length),
|
||||
obligation: Math.round(paths.reduce((a, p) => a + p[h].obligation, 0) / paths.length),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Deterministic seeding for the UdyamLinc prototype.
|
||||
*
|
||||
* Same pattern as BharatLinc's rconDemo.tsx: every figure is a pure function of
|
||||
* (enterprise id, month), so the dashboard, the detail page, the alert queue and
|
||||
* the PWA all quote the *same* numbers for the same scope — and a reload never
|
||||
* shuffles the demo out from under a reviewer.
|
||||
*/
|
||||
|
||||
/** FNV-style string hash. Stable across reloads and machines. */
|
||||
export function seedOf(key: string): number {
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
h ^= key.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
/** Mulberry32 — small, fast, fully deterministic PRNG. Returns a 0..1 generator. */
|
||||
export function rng(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
/** Integer in [min, max]. */
|
||||
export function pickInt(r: () => number, min: number, max: number): number {
|
||||
return min + Math.floor(r() * (max - min + 1))
|
||||
}
|
||||
|
||||
export function pickOne<T>(r: () => number, xs: readonly T[]): T {
|
||||
return xs[Math.floor(r() * xs.length) % xs.length]
|
||||
}
|
||||
|
||||
/** Fisher–Yates driven by the seeded rng, so the shuffle is reproducible. */
|
||||
export function shuffled<T>(r: () => number, xs: readonly T[]): T[] {
|
||||
const out = xs.slice()
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(r() * (i + 1))
|
||||
;[out[i], out[j]] = [out[j], out[i]]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Date helpers ────────────────────────────────────────────────
|
||||
|
||||
const MONTHS_EN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
const MONTHS_HI = ['जन', 'फ़र', 'मार्च', 'अप्रैल', 'मई', 'जून', 'जुल', 'अग', 'सित', 'अक्तू', 'नव', 'दिस']
|
||||
/** Full Hindi month names — used in reason sentences, where the short form reads badly. */
|
||||
const MONTHS_HI_FULL = [
|
||||
'जनवरी', 'फ़रवरी', 'मार्च', 'अप्रैल', 'मई', 'जून',
|
||||
'जुलाई', 'अगस्त', 'सितंबर', 'अक्टूबर', 'नवंबर', 'दिसंबर',
|
||||
]
|
||||
|
||||
export interface MonthRef {
|
||||
key: string // '2026-10'
|
||||
label: string // 'Oct'
|
||||
labelHi: string // 'अक्तू'
|
||||
fullEn: string // 'October'
|
||||
fullHi: string // 'अक्टूबर'
|
||||
}
|
||||
|
||||
export function monthRef(d: Date): MonthRef {
|
||||
const m = d.getMonth()
|
||||
return {
|
||||
key: `${d.getFullYear()}-${String(m + 1).padStart(2, '0')}`,
|
||||
label: MONTHS_EN[m],
|
||||
labelHi: MONTHS_HI[m],
|
||||
fullEn: [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
][m],
|
||||
fullHi: MONTHS_HI_FULL[m],
|
||||
}
|
||||
}
|
||||
|
||||
/** The `count` months starting at `offset` months from today. */
|
||||
export function monthWindow(count: number, offset = 0): MonthRef[] {
|
||||
const base = new Date()
|
||||
return Array.from({ length: count }, (_, i) => monthRef(new Date(base.getFullYear(), base.getMonth() + offset + i, 1)))
|
||||
}
|
||||
|
||||
/** Current month key, e.g. '2026-08'. The console's default scope. */
|
||||
export function currentMonthKey(): string {
|
||||
return monthRef(new Date()).key
|
||||
}
|
||||
|
||||
export function isoDate(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function todayIso(): string {
|
||||
return isoDate(new Date())
|
||||
}
|
||||
|
||||
/** `n` days from today, negative for the past. */
|
||||
export function dayOffsetIso(n: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + n)
|
||||
return isoDate(d)
|
||||
}
|
||||
|
||||
// ── Formatting ──────────────────────────────────────────────────
|
||||
|
||||
/** ₹ with Indian digit grouping and no decimals — how every amount is shown. */
|
||||
export function inr(n: number): string {
|
||||
return `₹${Math.round(n).toLocaleString('en-IN')}`
|
||||
}
|
||||
|
||||
/** Compact ₹ for KPI tiles: ₹12.4L / ₹8,200. */
|
||||
export function inrCompact(n: number): string {
|
||||
const abs = Math.abs(n)
|
||||
if (abs >= 1e7) return `₹${(n / 1e7).toFixed(2)}Cr`
|
||||
if (abs >= 1e5) return `₹${(n / 1e5).toFixed(1)}L`
|
||||
if (abs >= 1e3) return `₹${(n / 1e3).toFixed(1)}k`
|
||||
return `₹${Math.round(n)}`
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { Lang } from './api'
|
||||
|
||||
/*
|
||||
* Minimal two-language dictionary. No i18n library on purpose: the enterprise
|
||||
* PWA must work fully offline from a precached shell, and the string count is
|
||||
* small enough that a plain map is cheaper than a runtime loader.
|
||||
*
|
||||
* The officer console is English; the enterprise app is Hindi-first.
|
||||
*/
|
||||
|
||||
type Dict = Record<string, { hi: string; en: string }>
|
||||
|
||||
export const STRINGS: Dict = {
|
||||
// ── app shell / tabs
|
||||
'tab.home': { hi: 'होम', en: 'Home' },
|
||||
'tab.ledger': { hi: 'हिसाब', en: 'Ledger' },
|
||||
'tab.alerts': { hi: 'अलर्ट', en: 'Alerts' },
|
||||
'tab.profile': { hi: 'प्रोफ़ाइल', en: 'Profile' },
|
||||
|
||||
// ── home
|
||||
'home.attention': { hi: 'ध्यान दें', en: 'Attention' },
|
||||
'home.estimate': { hi: 'अनुमान', en: 'Estimate' },
|
||||
'home.shortfall': { hi: 'कमी', en: 'shortfall' },
|
||||
'home.comingMonths': { hi: 'आने वाले महीने', en: 'Coming months' },
|
||||
'home.sixMonths': { hi: '6 महीने', en: '6 months' },
|
||||
'home.instalment': { hi: 'किस्त', en: 'Instalment' },
|
||||
'home.legend': { hi: 'नारंगी और लाल = पैसा कम पड़ सकता है', en: 'Orange and red = cash may fall short' },
|
||||
'home.todayAccount': { hi: 'आज का हिसाब', en: "Today's ledger" },
|
||||
'home.moneyIn': { hi: 'पैसा आया', en: 'Money in' },
|
||||
'home.moneyOut': { hi: 'पैसा गया', en: 'Money out' },
|
||||
'home.stock': { hi: 'स्टॉक', en: 'Stock' },
|
||||
'home.todayIn': { hi: 'आज आया', en: 'In today' },
|
||||
'home.todayOut': { hi: 'आज गया', en: 'Out today' },
|
||||
'home.todaySuggestion': { hi: 'आज का सुझाव', en: "Today's suggestion" },
|
||||
'home.allGood': { hi: 'सब ठीक चल रहा है', en: 'Everything looks steady' },
|
||||
'home.allGoodDetail': { hi: 'अगले छह महीने किस्त निकलती दिख रही है।', en: 'The instalment is covered across the next six months.' },
|
||||
|
||||
// ── ledger
|
||||
'ledger.title': { hi: 'हिसाब', en: 'Ledger' },
|
||||
'ledger.addEntry': { hi: 'नया हिसाब जोड़ें', en: 'Add an entry' },
|
||||
'ledger.amount': { hi: 'रकम', en: 'Amount' },
|
||||
'ledger.chooseType': { hi: 'क्या दर्ज करना है?', en: 'What are you recording?' },
|
||||
'ledger.chooseCategory': { hi: 'किस लिए?', en: 'What for?' },
|
||||
'ledger.save': { hi: 'सेव करें', en: 'Save' },
|
||||
'ledger.saved': { hi: 'सेव हो गया', en: 'Saved' },
|
||||
'ledger.balance': { hi: 'इस महीने का बाक़ी', en: 'Balance this month' },
|
||||
'ledger.empty': { hi: 'अभी कोई हिसाब नहीं', en: 'No entries yet' },
|
||||
'ledger.pending': { hi: 'भेजना बाक़ी', en: 'Pending sync' },
|
||||
'ledger.synced': { hi: 'भेज दिया', en: 'Synced' },
|
||||
'ledger.today': { hi: 'आज', en: 'Today' },
|
||||
'ledger.back': { hi: 'वापस', en: 'Back' },
|
||||
|
||||
// ── alerts
|
||||
'alerts.title': { hi: 'अलर्ट', en: 'Alerts' },
|
||||
'alerts.none': { hi: 'कोई चेतावनी नहीं', en: 'No alerts' },
|
||||
'alerts.whatToDo': { hi: 'क्या करें', en: 'What to do' },
|
||||
'alerts.raisedOn': { hi: 'तारीख़', en: 'Raised' },
|
||||
|
||||
// ── profile
|
||||
'profile.title': { hi: 'प्रोफ़ाइल', en: 'Profile' },
|
||||
'profile.enterprise': { hi: 'उद्यम', en: 'Enterprise' },
|
||||
'profile.sector': { hi: 'काम', en: 'Trade' },
|
||||
'profile.block': { hi: 'ब्लॉक', en: 'Block' },
|
||||
'profile.consent': { hi: 'सहमति', en: 'Consent' },
|
||||
'profile.consentActive': { hi: 'चालू है', en: 'Active' },
|
||||
'profile.revoke': { hi: 'सहमति वापस लें', en: 'Revoke consent' },
|
||||
'profile.language': { hi: 'भाषा', en: 'Language' },
|
||||
'profile.sync': { hi: 'सिंक', en: 'Sync' },
|
||||
'profile.syncNow': { hi: 'अभी भेजें', en: 'Sync now' },
|
||||
'profile.lastSynced': { hi: 'पिछली बार भेजा', en: 'Last synced' },
|
||||
'profile.never': { hi: 'कभी नहीं', en: 'Never' },
|
||||
'profile.noPii': { hi: 'आपकी निजी जानकारी कहीं नहीं रखी जाती।', en: 'No personal details are stored, ever.' },
|
||||
'profile.logout': { hi: 'बाहर निकलें', en: 'Sign out' },
|
||||
|
||||
// ── shared
|
||||
'common.offline': { hi: 'इंटरनेट नहीं — सेव किया हुआ दिख रहा है', en: 'Offline — showing saved data' },
|
||||
'common.enterpriseId': { hi: 'उद्यम आईडी', en: 'Enterprise ID' },
|
||||
'common.pin': { hi: 'पिन', en: 'PIN' },
|
||||
'common.signIn': { hi: 'अंदर आएँ', en: 'Sign in' },
|
||||
}
|
||||
|
||||
/** Translate a key. Falls back to the key itself so a missing string is obvious. */
|
||||
export function tr(key: string, lang: Lang): string {
|
||||
return STRINGS[key]?.[lang] ?? key
|
||||
}
|
||||
|
||||
const LANG_KEY = 'ul-lang'
|
||||
|
||||
/** Language preference, persisted so an offline relaunch keeps it. */
|
||||
export function useLang(fallback: Lang = 'hi') {
|
||||
const [lang, setLangState] = useState<Lang>(() => (localStorage.getItem(LANG_KEY) as Lang) || fallback)
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setLangState((localStorage.getItem(LANG_KEY) as Lang) || fallback)
|
||||
window.addEventListener('ul-lang-changed', sync)
|
||||
return () => window.removeEventListener('ul-lang-changed', sync)
|
||||
}, [fallback])
|
||||
|
||||
const setLang = useCallback((l: Lang) => {
|
||||
localStorage.setItem(LANG_KEY, l)
|
||||
window.dispatchEvent(new Event('ul-lang-changed'))
|
||||
}, [])
|
||||
|
||||
const t = useCallback((key: string) => tr(key, lang), [lang])
|
||||
|
||||
return { lang, setLang, t }
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { LedgerEntry, LedgerKind } from './api'
|
||||
import { dayOffsetIso, pickInt, pickOne, rng, seedOf, todayIso } from './demo/seed'
|
||||
|
||||
/*
|
||||
* Offline-first ledger.
|
||||
*
|
||||
* Every entry is written to localStorage first and marked PENDING; a sync pass
|
||||
* flips it to SYNCED. This is what makes the deck's "works with zero
|
||||
* connectivity" claim true in the prototype — the owner never sees a spinner
|
||||
* and never loses a tap.
|
||||
*/
|
||||
|
||||
const KEY = 'ul-ledger'
|
||||
const SYNC_KEY = 'ul-last-sync'
|
||||
|
||||
export const CATEGORIES: Record<LedgerKind, { code: string; en: string; hi: string }[]> = {
|
||||
IN: [
|
||||
{ code: 'SALE', en: 'Sale', hi: 'बिक्री' },
|
||||
{ code: 'UPI', en: 'UPI received', hi: 'UPI आया' },
|
||||
{ code: 'RECOVERY', en: 'Udhaar recovered', hi: 'उधारी वसूली' },
|
||||
{ code: 'OTHER_IN', en: 'Other', hi: 'अन्य' },
|
||||
],
|
||||
OUT: [
|
||||
{ code: 'INPUT', en: 'Raw material / feed', hi: 'कच्चा माल / दाना' },
|
||||
{ code: 'WAGES', en: 'Wages', hi: 'मज़दूरी' },
|
||||
{ code: 'TRANSPORT', en: 'Transport', hi: 'भाड़ा' },
|
||||
{ code: 'EMI', en: 'Instalment', hi: 'किस्त' },
|
||||
{ code: 'OTHER_OUT', en: 'Other', hi: 'अन्य' },
|
||||
],
|
||||
STOCK: [
|
||||
{ code: 'STOCK_IN', en: 'Stock added', hi: 'स्टॉक आया' },
|
||||
{ code: 'STOCK_OUT', en: 'Stock sold', hi: 'स्टॉक गया' },
|
||||
],
|
||||
}
|
||||
|
||||
function read(): LedgerEntry[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
return raw ? (JSON.parse(raw) as LedgerEntry[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function write(entries: LedgerEntry[]) {
|
||||
localStorage.setItem(KEY, JSON.stringify(entries))
|
||||
window.dispatchEvent(new Event('ul-ledger-changed'))
|
||||
}
|
||||
|
||||
/** 30 days of plausible history, generated once per enterprise so the app is never empty. */
|
||||
function seedHistory(enterpriseId: string): LedgerEntry[] {
|
||||
const r = rng(seedOf(`${enterpriseId}:ledger`))
|
||||
const out: LedgerEntry[] = []
|
||||
for (let d = 30; d >= 0; d--) {
|
||||
const entries = pickInt(r, 1, 3)
|
||||
for (let i = 0; i < entries; i++) {
|
||||
const kind: LedgerKind = r() > 0.45 ? 'IN' : 'OUT'
|
||||
const cat = pickOne(r, CATEGORIES[kind])
|
||||
out.push({
|
||||
id: `seed-${enterpriseId}-${d}-${i}`,
|
||||
enterpriseId,
|
||||
kind,
|
||||
amount: kind === 'IN' ? pickInt(r, 6, 52) * 100 : pickInt(r, 4, 38) * 100,
|
||||
category: cat.code,
|
||||
note: null,
|
||||
entryDate: dayOffsetIso(-d),
|
||||
syncState: 'SYNCED',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function loadLedger(enterpriseId: string): LedgerEntry[] {
|
||||
const all = read()
|
||||
const mine = all.filter((e) => e.enterpriseId === enterpriseId)
|
||||
if (mine.length) return mine.sort((a, b) => b.entryDate.localeCompare(a.entryDate))
|
||||
const seeded = seedHistory(enterpriseId)
|
||||
write([...all, ...seeded])
|
||||
return seeded.sort((a, b) => b.entryDate.localeCompare(a.entryDate))
|
||||
}
|
||||
|
||||
/** Three-tap write: amount → category → save. Always succeeds, online or not. */
|
||||
export function addEntry(input: {
|
||||
enterpriseId: string
|
||||
kind: LedgerKind
|
||||
amount: number
|
||||
category: string
|
||||
note?: string
|
||||
}): LedgerEntry {
|
||||
const entry: LedgerEntry = {
|
||||
id: `local-${Date.now()}-${Math.round(Math.random() * 1e6)}`,
|
||||
enterpriseId: input.enterpriseId,
|
||||
kind: input.kind,
|
||||
amount: input.amount,
|
||||
category: input.category,
|
||||
note: input.note ?? null,
|
||||
entryDate: todayIso(),
|
||||
syncState: 'PENDING',
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
write([...read(), entry])
|
||||
return entry
|
||||
}
|
||||
|
||||
export function pendingCount(enterpriseId: string): number {
|
||||
return read().filter((e) => e.enterpriseId === enterpriseId && e.syncState === 'PENDING').length
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the queue. The backend endpoint lands in a later pass — until then this
|
||||
* flips PENDING → SYNCED locally so the UI's sync states are exercisable.
|
||||
*/
|
||||
export function syncPending(enterpriseId: string): number {
|
||||
const all = read()
|
||||
let n = 0
|
||||
const next = all.map((e) => {
|
||||
if (e.enterpriseId === enterpriseId && e.syncState === 'PENDING') {
|
||||
n++
|
||||
return { ...e, syncState: 'SYNCED' as const }
|
||||
}
|
||||
return e
|
||||
})
|
||||
if (n) {
|
||||
write(next)
|
||||
localStorage.setItem(SYNC_KEY, new Date().toISOString())
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
export function lastSyncedAt(): string | null {
|
||||
return localStorage.getItem(SYNC_KEY)
|
||||
}
|
||||
|
||||
export function dayTotals(entries: LedgerEntry[], date = todayIso()) {
|
||||
const today = entries.filter((e) => e.entryDate === date)
|
||||
return {
|
||||
in: today.filter((e) => e.kind === 'IN').reduce((a, e) => a + e.amount, 0),
|
||||
out: today.filter((e) => e.kind === 'OUT').reduce((a, e) => a + e.amount, 0),
|
||||
count: today.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** Live ledger for a given enterprise, re-reading whenever an entry is written. */
|
||||
export function useLedger(enterpriseId: string) {
|
||||
const [entries, setEntries] = useState<LedgerEntry[]>(() => loadLedger(enterpriseId))
|
||||
|
||||
const refresh = useCallback(() => setEntries(loadLedger(enterpriseId)), [enterpriseId])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
window.addEventListener('ul-ledger-changed', refresh)
|
||||
return () => window.removeEventListener('ul-ledger-changed', refresh)
|
||||
}, [refresh])
|
||||
|
||||
return { entries, refresh }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Connectivity state for the offline-first PWA. Drives the offline banner, the
|
||||
* "showing saved data" note and whether the sync button is live.
|
||||
*/
|
||||
export function useOffline(): boolean {
|
||||
const [offline, setOffline] = useState(() => !navigator.onLine)
|
||||
|
||||
useEffect(() => {
|
||||
const on = () => setOffline(false)
|
||||
const off = () => setOffline(true)
|
||||
window.addEventListener('online', on)
|
||||
window.addEventListener('offline', off)
|
||||
return () => {
|
||||
window.removeEventListener('online', on)
|
||||
window.removeEventListener('offline', off)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return offline
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { FieldVisit } from './api'
|
||||
import { visitsFor } from './demo/ops'
|
||||
|
||||
/*
|
||||
* Visits scheduled from the console during a demo session.
|
||||
*
|
||||
* The seeded playbook comes from demo/ops; anything the officer schedules here
|
||||
* is appended locally so "Schedule visit" on the dashboard produces a row that
|
||||
* actually shows up under Field visits — the loop the deck describes as
|
||||
* "action and feedback".
|
||||
*/
|
||||
|
||||
const KEY = 'ul-visits'
|
||||
|
||||
function read(): FieldVisit[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
return raw ? (JSON.parse(raw) as FieldVisit[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function write(v: FieldVisit[]) {
|
||||
localStorage.setItem(KEY, JSON.stringify(v))
|
||||
window.dispatchEvent(new Event('ul-visits-changed'))
|
||||
}
|
||||
|
||||
export function scheduleVisit(input: {
|
||||
enterpriseId: string
|
||||
enterpriseName: string
|
||||
block: string
|
||||
action: string
|
||||
actionHi: string
|
||||
priority: number
|
||||
dueDate: string
|
||||
officer: string
|
||||
}): FieldVisit {
|
||||
const existing = read()
|
||||
const visit: FieldVisit = {
|
||||
id: 9000 + existing.length,
|
||||
visitId: `FV-${9000 + existing.length}`,
|
||||
state: 'PLANNED',
|
||||
outcome: null,
|
||||
loggedAt: null,
|
||||
...input,
|
||||
}
|
||||
write([visit, ...existing])
|
||||
return visit
|
||||
}
|
||||
|
||||
/** Record the outcome of a visit — this is what feeds the next retraining cycle. */
|
||||
export function logOutcome(visitId: string, outcome: string) {
|
||||
const local = read()
|
||||
const hit = local.find((v) => v.visitId === visitId)
|
||||
if (hit) {
|
||||
write(local.map((v) => (v.visitId === visitId ? { ...v, state: 'DONE', outcome, loggedAt: new Date().toISOString() } : v)))
|
||||
return
|
||||
}
|
||||
// A seeded visit: shadow it locally rather than mutating the generator.
|
||||
const seeded = visitsFor('ALL').find((v) => v.visitId === visitId)
|
||||
if (seeded) {
|
||||
write([{ ...seeded, state: 'DONE', outcome, loggedAt: new Date().toISOString() }, ...local])
|
||||
}
|
||||
}
|
||||
|
||||
/** Seeded playbook plus locally scheduled/updated visits, local taking precedence. */
|
||||
export function useVisits(block: string, officer: string) {
|
||||
const [local, setLocal] = useState<FieldVisit[]>(read)
|
||||
|
||||
const refresh = useCallback(() => setLocal(read()), [])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('ul-visits-changed', refresh)
|
||||
return () => window.removeEventListener('ul-visits-changed', refresh)
|
||||
}, [refresh])
|
||||
|
||||
const seeded = visitsFor(block, officer)
|
||||
const overridden = new Set(local.map((v) => v.visitId))
|
||||
const scoped = local.filter((v) => block === 'ALL' || v.block === block)
|
||||
const visits = [...scoped, ...seeded.filter((v) => !overridden.has(v.visitId))]
|
||||
|
||||
return { visits, refresh }
|
||||
}
|
||||
Reference in New Issue
Block a user