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:
LincPay
2026-08-06 13:30:24 +05:30
co-authored by Claude Opus 5
commit f430f85202
55 changed files with 8691 additions and 0 deletions
+70
View File
@@ -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
View File
@@ -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
}
+17
View File
@@ -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)
}
+409
View File
@@ -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
}
+292
View File
@@ -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 }
})
}
+232
View File
@@ -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),
}))
}
+123
View File
@@ -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
View File
@@ -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 }
}
+159
View File
@@ -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 }
}
+22
View File
@@ -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
}
+85
View File
@@ -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 }
}