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
+6
View File
@@ -0,0 +1,6 @@
node_modules
.git
dist
# never let a local API override leak into the deployed build
*.local
.env.local
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+15
View File
@@ -0,0 +1,15 @@
# ── build stage: compile the Vite bundle inside Docker ──
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# VITE_UDYAM_API_BASE_URL is unset here, so the compiled-in default
# (http://udyamlinc-backend.lincpay/api/v1/udyam) is baked into the bundle.
RUN npm run build
# ── serve stage: nginx serving the static SPA on 5173 ──
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 5173
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/udyamlinc-tile.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="UdyamLinc — predictive cash-flow intelligence and early warning for rural microenterprises. LincFusion · LincPay Solutions" />
<meta name="theme-color" content="#0b5d3b" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>UdyamLinc · Early Warning Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
server {
listen 5173;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
gzip_min_length 1024;
# Immutable fingerprinted assets
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# The service worker must never be cached, or the PWA can never update
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# SPA fallback — React Router owns the paths
location / {
try_files $uri $uri/ /index.html;
}
}
+2367
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "udyamlinc-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"axios": "^1.18.1",
"chart.js": "^4.5.1",
"react": "^19.2.7",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "UdyamLinc — उद्यम हिसाब",
"short_name": "UdyamLinc",
"description": "Cash-flow forecast and early warning for microenterprises. Works offline.",
"start_url": "/app",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#06301f",
"theme_color": "#0b5d3b",
"lang": "hi",
"icons": [
{ "src": "/udyamlinc-tile.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}
+54
View File
@@ -0,0 +1,54 @@
/*
* UdyamLinc service worker — app-shell precache + stale-while-revalidate.
*
* Scope of the offline promise (deck feature 7): the enterprise PWA must open,
* render and accept ledger entries with zero connectivity. Ledger writes are
* queued in localStorage by the app itself (see lib/ledgerStore.ts); this worker
* only guarantees the shell and its assets are reachable.
*/
const CACHE = 'udyamlinc-v1'
const SHELL = ['/', '/app', '/index.html', '/manifest.webmanifest', '/udyamlinc-tile.svg']
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()))
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
)
})
self.addEventListener('fetch', (event) => {
const req = event.request
if (req.method !== 'GET') return
const url = new URL(req.url)
// API calls are never served stale — the app falls back to its local store instead.
if (url.pathname.includes('/api/')) return
// Navigations: network first, fall back to the cached shell when offline.
if (req.mode === 'navigate') {
event.respondWith(fetch(req).catch(() => caches.match('/index.html')))
return
}
// Assets: cache first, refresh in the background.
event.respondWith(
caches.match(req).then((hit) => {
const network = fetch(req)
.then((res) => {
if (res.ok) {
const copy = res.clone()
caches.open(CACHE).then((c) => c.put(req, copy))
}
return res
})
.catch(() => hit)
return hit || network
}),
)
})
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="ul" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1e7a46"/>
<stop offset="100%" stop-color="#0b5d3b"/>
</linearGradient>
</defs>
<path d="M32 3 58 17.5v29L32 61 6 46.5v-29z" fill="url(#ul)"/>
<path d="M34.5 15 20 36h10l-2.5 13L44 27H33.5z" fill="#f2b544"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

+104
View File
@@ -0,0 +1,104 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { LoginPage } from './pages/LoginPage'
import { enterpriseSession, officerSession } from './lib/api'
// Field officer console
import { OfficerShell } from './features/officer/OfficerShell'
import { OfficerDashboard } from './features/officer/OfficerDashboard'
import { EnterprisesPage } from './features/officer/EnterprisesPage'
import { EnterpriseDetailPage } from './features/officer/EnterpriseDetailPage'
import { AlertsPage } from './features/officer/AlertsPage'
import { ForecastsPage } from './features/officer/ForecastsPage'
import { VisitsPage } from './features/officer/VisitsPage'
import { RulesPage } from './features/officer/RulesPage'
import { DataFeedsPage } from './features/officer/DataFeedsPage'
import { ReportsPage } from './features/officer/ReportsPage'
import { SettingsPage } from './features/officer/SettingsPage'
// Enterprise PWA
import { EnterpriseShell } from './features/enterprise/EnterpriseShell'
import { AppHomePage } from './features/enterprise/AppHomePage'
import { AppLedgerPage } from './features/enterprise/AppLedgerPage'
import { AppAlertsPage } from './features/enterprise/AppAlertsPage'
import { AppProfilePage } from './features/enterprise/AppProfilePage'
/**
* Queries fall back to demo data per page, so a failed request should not spin
* or spam retries — one attempt, generous staleness, no refetch on focus.
*/
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 0, refetchOnWindowFocus: false, staleTime: 60_000 },
},
})
function RequireOfficer({ children }: { children: React.ReactNode }) {
const location = useLocation()
if (!officerSession()?.token) return <Navigate to="/login" replace state={{ from: location }} />
return children
}
function RequireEnterprise({ children }: { children: React.ReactNode }) {
const location = useLocation()
if (!enterpriseSession()?.token) return <Navigate to="/login" replace state={{ from: location }} />
return children
}
/** Land on whichever surface already has a session. */
function RootRedirect() {
if (officerSession()?.token) return <Navigate to="/officer" replace />
if (enterpriseSession()?.token) return <Navigate to="/app" replace />
return <Navigate to="/login" replace />
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
{/* Field officer console — portfolio, risk detail and recommended action */}
<Route
path="/officer"
element={
<RequireOfficer>
<OfficerShell />
</RequireOfficer>
}
>
<Route index element={<OfficerDashboard />} />
<Route path="enterprises" element={<EnterprisesPage />} />
<Route path="enterprises/:enterpriseId" element={<EnterpriseDetailPage />} />
<Route path="alerts" element={<AlertsPage />} />
<Route path="forecasts" element={<ForecastsPage />} />
<Route path="visits" element={<VisitsPage />} />
<Route path="rules" element={<RulesPage />} />
<Route path="feeds" element={<DataFeedsPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
{/* Enterprise PWA — offline, Hindi-first */}
<Route
path="/app"
element={
<RequireEnterprise>
<EnterpriseShell />
</RequireEnterprise>
}
>
<Route index element={<AppHomePage />} />
<Route path="ledger" element={<AppLedgerPage />} />
<Route path="alerts" element={<AppAlertsPage />} />
<Route path="profile" element={<AppProfilePage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
)
}
@@ -0,0 +1,41 @@
import { Bar } from 'react-chartjs-2'
import type { ChartOptions } from 'chart.js'
import { CHART_COLOURS } from './chartSetup'
import './chartSetup'
export interface MigrationRow {
month: string
red: number
watch: number
standard: number
}
/** Stacked band counts month over month — how the book migrated between watchlist categories. */
export function BandMigrationBar({ rows, height = 240 }: { rows: MigrationRow[]; height?: number }) {
const data = {
labels: rows.map((r) => r.month),
datasets: [
{ label: 'High risk', data: rows.map((r) => r.red), backgroundColor: CHART_COLOURS.red, borderRadius: 3 },
{ label: 'Watch', data: rows.map((r) => r.watch), backgroundColor: CHART_COLOURS.amber, borderRadius: 3 },
{ label: 'Standard', data: rows.map((r) => r.standard), backgroundColor: CHART_COLOURS.green, borderRadius: 3 },
],
}
const options: ChartOptions<'bar'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 10, boxHeight: 10, padding: 14, usePointStyle: true } },
},
scales: {
x: { stacked: true, grid: { display: false } },
y: { stacked: true, grid: { color: CHART_COLOURS.grid } },
},
}
return (
<div style={{ height }}>
<Bar data={data} options={options} />
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { Line } from 'react-chartjs-2'
import type { ChartOptions } from 'chart.js'
import type { ForecastPoint } from '../../lib/api'
import { CHART_COLOURS } from './chartSetup'
import './chartSetup'
import { inr } from '../../lib/demo/seed'
/**
* The P10 / P50 / P90 quantile fan against the scheduled-obligation line.
* Where the P10 band dips under the dashed obligation line, the enterprise
* escalates — that crossing is the whole product in one picture.
*/
export function ForecastFan({ points, height = 240 }: { points: ForecastPoint[]; height?: number }) {
const labels = points.map((p) => p.label)
const data = {
labels,
datasets: [
// P90 drawn first, then P10 filled back to it — that pair renders the band.
{
label: 'P90 (upside)',
data: points.map((p) => p.p90),
borderColor: 'rgba(63,184,113,0.45)',
backgroundColor: 'rgba(63,184,113,0.12)',
borderWidth: 1,
pointRadius: 0,
fill: false,
tension: 0.35,
},
{
label: 'P10 (downside)',
data: points.map((p) => p.p10),
borderColor: 'rgba(192,57,43,0.55)',
backgroundColor: 'rgba(63,184,113,0.14)',
borderWidth: 1,
pointRadius: 0,
fill: '-1',
tension: 0.35,
},
{
label: 'P50 (expected)',
data: points.map((p) => p.p50),
borderColor: CHART_COLOURS.forest,
backgroundColor: CHART_COLOURS.forest,
borderWidth: 2.4,
pointRadius: 3,
pointHoverRadius: 5,
fill: false,
tension: 0.35,
},
{
label: 'Obligation due',
data: points.map((p) => p.obligation),
borderColor: CHART_COLOURS.amber,
borderWidth: 1.8,
borderDash: [5, 4],
pointRadius: 0,
fill: false,
},
],
}
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 10, boxHeight: 10, padding: 14, usePointStyle: true } },
tooltip: {
callbacks: { label: (c) => `${c.dataset.label}: ${inr(c.parsed.y ?? 0)}` },
},
},
scales: {
x: { grid: { display: false } },
y: {
grid: { color: CHART_COLOURS.grid },
ticks: { callback: (v) => `₹${Math.round(Number(v) / 1000)}k` },
},
},
}
return (
<div style={{ height }}>
<Line data={data} options={options} />
</div>
)
}
@@ -0,0 +1,38 @@
import { Bar } from 'react-chartjs-2'
import type { ChartOptions } from 'chart.js'
import { BENCHMARKS } from '../../lib/demo/ops'
import { CHART_COLOURS } from './chartSetup'
import './chartSetup'
/** Slide-10 benchmark: 90-day stress detection AUC-ROC against two baselines. */
export function ModelComparisonBar({ height = 190 }: { height?: number }) {
const data = {
labels: ['UdyamLinc — full signal set', 'Repayment history only', 'Bureau-score baseline'],
datasets: [
{
label: 'AUC-ROC',
data: [BENCHMARKS.aucRoc, BENCHMARKS.aucRepaymentOnly, BENCHMARKS.aucBureau],
backgroundColor: [CHART_COLOURS.forest, CHART_COLOURS.greenHi, '#94a3b8'],
borderRadius: 4,
barThickness: 22,
},
],
}
const options: ChartOptions<'bar'> = {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { min: 0, max: 1, grid: { color: CHART_COLOURS.grid }, ticks: { stepSize: 0.1 } },
y: { grid: { display: false }, ticks: { font: { size: 10.5 } } },
},
}
return (
<div style={{ height }}>
<Bar data={data} options={options} />
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { Radar } from 'react-chartjs-2'
import type { ChartOptions } from 'chart.js'
import type { Band, SubScores } from '../../lib/api'
import { DRIVER_META } from '../../lib/demo/enterprises'
import './chartSetup'
/** The five risk families as a radar — shows the *shape* of the stress, not just its size. */
export function SubScoreRadar({ sub, band, height = 230 }: { sub: SubScores; band: Band; height?: number }) {
const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e8a020' : '#1e7a46'
const data = {
labels: DRIVER_META.map((d) => d.label),
datasets: [
{
label: 'Sub-score',
data: DRIVER_META.map((d) => sub[d.key]),
borderColor: colour,
backgroundColor: `${colour}22`,
borderWidth: 2,
pointBackgroundColor: colour,
pointRadius: 3,
},
],
}
const options: ChartOptions<'radar'> = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
r: {
beginAtZero: true,
suggestedMax: 30,
angleLines: { color: '#e8eeea' },
grid: { color: '#e8eeea' },
pointLabels: { font: { size: 10 } },
ticks: { display: false },
},
},
}
return (
<div style={{ height }}>
<Radar data={data} options={options} />
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import {
BarElement,
CategoryScale,
Chart as ChartJS,
Filler,
Legend,
LineElement,
LinearScale,
PointElement,
RadialLinearScale,
Tooltip,
} from 'chart.js'
/*
* chart.js elements are registered exactly once, here. Every chart component
* imports this module for its side effect, so no page re-registers.
*/
ChartJS.register(
CategoryScale,
LinearScale,
RadialLinearScale,
PointElement,
LineElement,
BarElement,
Filler,
Tooltip,
Legend,
)
ChartJS.defaults.font.family = '"Segoe UI", Roboto, -apple-system, Arial, sans-serif'
ChartJS.defaults.font.size = 11
ChartJS.defaults.color = '#64748b'
export const CHART_COLOURS = {
forest: '#0b5d3b',
green: '#1e7a46',
greenHi: '#3fb871',
amber: '#e8a020',
red: '#c0392b',
grid: '#e8eeea',
}
+310
View File
@@ -0,0 +1,310 @@
import { NavLink } from 'react-router-dom'
import type { Band } from '../../lib/api'
import { BANDS } from '../../lib/demo/enterprises'
/* ------------------------------------------------------------------ */
/* Shared widgets. Structure follows BharatLinc's components/ui/bits; */
/* the palette is UdyamLinc forest/amber, and the risk primitives at */
/* the bottom (RiskBadge / ScoreDial / Sparkline / DriverBar) are new. */
/* ------------------------------------------------------------------ */
export function StatusPill({ status }: { status: string }) {
const s = status.toUpperCase()
const cls =
s.includes('DONE') || s.includes('CLOSED') || s.includes('SYNCED') || s.includes('ACTIVE')
? 'pill-ok'
: s.includes('OVERDUE') || s.includes('ESCALATED') || s.includes('OPEN') || s.includes('BREACH')
? 'pill-bad'
: s.includes('PLANNED') || s.includes('PENDING') || s.includes('SNOOZED')
? 'pill-pend'
: s.includes('ACK')
? 'pill-info'
: 'pill-mut'
return <span className={`pill ${cls}`}>{status.replace(/_/g, ' ')}</span>
}
export function Card({
title,
badge,
actions,
children,
className = '',
}: {
title?: string
badge?: string
actions?: React.ReactNode
children: React.ReactNode
className?: string
}) {
return (
<div className={`bg-white border border-slate-200 rounded-[11px] shadow-[0_1px_3px_rgba(0,0,0,0.05)] overflow-hidden ${className}`}>
{title && (
<div className="px-3.5 py-2.5 border-b border-slate-200 flex items-center gap-2">
<h3 className="text-[12.5px] font-bold text-forest">{title}</h3>
{badge && <span className="font-mono text-[9.5px] bg-green-soft text-forest px-1.5 py-0.5 rounded-lg">{badge}</span>}
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
</div>
)}
{children}
</div>
)
}
export function Modal({
title,
onClose,
children,
wide,
xwide,
}: {
title: string
onClose: () => void
children: React.ReactNode
wide?: boolean
xwide?: boolean
}) {
return (
<div className="fixed inset-0 z-[100] grid place-items-center bg-[rgba(6,48,31,0.55)] backdrop-blur-[2px] p-4" onClick={onClose}>
<div
className={`bg-white rounded-[14px] shadow-2xl w-full ${xwide ? 'max-w-[1040px]' : wide ? 'max-w-[720px]' : 'max-w-[460px]'} max-h-[92vh] overflow-y-auto`}
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-3.5 bg-gradient-to-br from-forest to-forest-mid text-white flex items-center justify-between rounded-t-[14px]">
<h3 className="text-[13.5px] font-bold">{title}</h3>
<button onClick={onClose} aria-label="Close" className="w-6 h-6 rounded-full bg-white/15 hover:bg-white/25 grid place-items-center text-[11px]">
✕
</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
)
}
export function SubNav({ items }: { items: { label: string; to: string; end?: boolean }[] }) {
return (
<div className="flex gap-1 border-b border-slate-200 mb-4 overflow-x-auto">
{items.map((i) => (
<NavLink
key={i.to}
to={i.to}
end={i.end}
className={({ isActive }) =>
[
'px-3.5 py-2 text-[11.5px] font-semibold whitespace-nowrap border-b-2 -mb-px',
isActive ? 'text-forest border-amber' : 'text-slate-500 border-transparent hover:text-forest',
].join(' ')
}
>
{i.label}
</NavLink>
))}
</div>
)
}
export function Field({
label,
hint,
children,
className = '',
}: {
label: string
hint?: string
children: React.ReactNode
className?: string
}) {
return (
<label className={`block ${className}`}>
<span className="block font-mono text-[9.5px] tracking-[0.1em] uppercase text-slate-500 mb-1">{label}</span>
{children}
{hint && <span className="block text-[10.5px] text-slate-400 mt-1">{hint}</span>}
</label>
)
}
export const inputCls =
'w-full border border-slate-300 rounded-md px-2.5 py-1.5 text-[12.5px] outline-none focus:border-green focus:ring-2 focus:ring-green/15 bg-white'
export function PrimaryBtn({
children,
className = '',
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...rest}
className={`bg-forest hover:bg-forest-mid disabled:opacity-50 disabled:cursor-not-allowed text-white text-[12px] font-semibold px-3.5 py-1.5 rounded-md transition-colors ${className}`}
>
{children}
</button>
)
}
export function GhostBtn({
children,
className = '',
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...rest}
className={`border border-slate-300 hover:bg-slate-50 disabled:opacity-50 text-slate-700 text-[12px] font-semibold px-3.5 py-1.5 rounded-md transition-colors ${className}`}
>
{children}
</button>
)
}
export function ActionChip({
children,
active,
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
return (
<button
{...rest}
className={[
'text-[11px] font-semibold px-2.5 py-1 rounded-full border transition-colors',
active ? 'bg-forest text-white border-forest' : 'bg-white text-slate-600 border-slate-300 hover:border-green',
].join(' ')}
>
{children}
</button>
)
}
/** Empty/loading state used wherever a list can come back with nothing. */
export function Empty({ text }: { text: string }) {
return <div className="py-10 text-center text-[12px] text-slate-400">{text}</div>
}
// ── UdyamLinc risk primitives ───────────────────────────────────
export function RiskBadge({ band, hi = false }: { band: Band; hi?: boolean }) {
const b = BANDS[band]
return <span className={`pill ${b.cls}`}>{hi ? b.labelHi : b.label}</span>
}
/**
* The 0–100 Composite Stress Score as an arc — the dial on the deck's risk
* detail panel. Red sweeps clockwise in proportion to the score.
*/
export function ScoreDial({ score, band, size = 96 }: { score: number; band: Band; size?: number }) {
const stroke = size * 0.11
const r = (size - stroke) / 2
const circ = 2 * Math.PI * r
const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e8a020' : '#1e7a46'
return (
<div className="relative grid place-items-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90" role="img" aria-label={`Stress score ${score} of 100`}>
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#eef2f0" strokeWidth={stroke} />
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={colour}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${(circ * score) / 100} ${circ}`}
/>
</svg>
<div className="absolute text-center leading-none">
<div className="font-bold" style={{ color: colour, fontSize: size * 0.3 }}>
{score}
</div>
<div className="font-mono uppercase tracking-[0.1em] text-slate-400" style={{ fontSize: size * 0.085 }}>
{BANDS[band].label}
</div>
</div>
</div>
)
}
/** Six-month score trend as an inline SVG — one per table row, so no chart.js. */
export function Sparkline({
values,
band,
width = 84,
height = 26,
}: {
values: number[]
band: Band
width?: number
height?: number
}) {
if (!values.length) return null
const min = Math.min(...values)
const max = Math.max(...values)
const span = Math.max(1, max - min)
const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e8a020' : '#1e7a46'
const pts = values
.map((v, i) => {
const x = (i / (values.length - 1)) * (width - 4) + 2
const y = height - 3 - ((v - min) / span) * (height - 6)
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
const last = values[values.length - 1]
const lastX = width - 2
const lastY = height - 3 - ((last - min) / span) * (height - 6)
return (
<svg width={width} height={height} role="img" aria-label="6-month score trend">
<polyline points={pts} fill="none" stroke={colour} strokeWidth="1.6" strokeLinejoin="round" />
<circle cx={lastX} cy={lastY} r="2.2" fill={colour} />
</svg>
)
}
/** One SHAP driver as a labelled bar — the "Top drivers" list on the deck. */
export function DriverBar({ label, weight, max = 30 }: { label: string; weight: number; max?: number }) {
const pct = Math.min(100, (weight / max) * 100)
const colour = weight >= 20 ? 'bg-risk-red' : weight >= 12 ? 'bg-amber' : 'bg-green'
return (
<div className="flex items-center gap-2.5">
<span className="text-[11px] text-slate-600 w-[120px] shrink-0">{label}</span>
<span className="flex-1 h-[7px] rounded-full bg-slate-100 overflow-hidden">
<span className={`block h-full rounded-full ${colour}`} style={{ width: `${pct}%` }} />
</span>
<span className="font-mono text-[10.5px] text-slate-500 w-6 text-right">{weight}</span>
</div>
)
}
/** KPI tile used across the console — value, caption and a coloured corner dot. */
export function KpiTile({
label,
value,
delta,
tone = 'neutral',
}: {
label: string
value: string | number
delta?: string
tone?: 'neutral' | 'red' | 'watch' | 'good'
}) {
const dot =
tone === 'red' ? 'bg-risk-red' : tone === 'watch' ? 'bg-amber' : tone === 'good' ? 'bg-green' : 'bg-forest'
const deltaCls = tone === 'red' ? 'text-risk-red' : tone === 'watch' ? 'text-amber' : 'text-slate-400'
return (
<div className="ul-card px-3.5 py-3 flex-1 min-w-[150px]">
<div className="flex items-start justify-between">
<span className="text-[10.5px] text-slate-500">{label}</span>
<span className={`w-2.5 h-2.5 rounded-[3px] ${dot}`} />
</div>
<div className="text-[26px] font-bold text-forest leading-tight mt-1">{value}</div>
{delta && <div className={`text-[10.5px] ${deltaCls}`}>{delta}</div>}
</div>
)
}
/** UdyamLinc mark — hexagon + bolt, matching the deck's logo. */
export function UdyamMark({ size = 26, onDark = false }: { size?: number; onDark?: boolean }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" aria-label="UdyamLinc" role="img">
<path d="M32 3 58 17.5v29L32 61 6 46.5v-29z" fill={onDark ? '#3fb871' : '#0b5d3b'} />
<path d="M34.5 15 20 36h10l-2.5 13L44 27H33.5z" fill={onDark ? '#f2b544' : '#e8a020'} />
</svg>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { enterpriseSession } from '../../lib/api'
import { Empty } from '../../components/ui/bits'
import { findEnterprise, reasonCodesFor, tripWiresFor } from '../../lib/demo/enterprises'
import { alertForEnterprise } from '../../lib/demo/ops'
import { inr, monthRef } from '../../lib/demo/seed'
import { useLang } from '../../lib/i18n'
/**
* Alert history for the owner. Same reason codes the officer sees, but only
* ever as plain sentences — no scores, no model vocabulary, nothing that reads
* like a credit rating handed to the borrower.
*/
export function AppAlertsPage() {
const session = enterpriseSession()
const { lang, t } = useLang('hi')
const hi = lang === 'hi'
const e = session ? findEnterprise(session.enterpriseId) : undefined
if (!session || !e) return <Empty text="—" />
const alert = alertForEnterprise(e.enterpriseId)
const reasons = reasonCodesFor(e)
const fired = tripWiresFor(e).filter((w) => w.fired)
if (!alert) {
return (
<div className="bg-green-soft border border-green/20 rounded-xl px-4 py-6 text-center">
<div className="w-12 h-12 rounded-full bg-white text-green grid place-items-center text-[22px] mx-auto mb-2.5">
✓
</div>
<div className="text-[14px] font-bold text-forest">{t('alerts.none')}</div>
<p className="text-[12px] text-slate-600 mt-1">{t('home.allGoodDetail')}</p>
</div>
)
}
const breachRef = alert.breachMonth ? monthRef(new Date(`${alert.breachMonth}-01T00:00:00`)) : null
return (
<div className="space-y-3">
<section className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="bg-risk-red text-white px-4 py-3">
<div className="text-[13.5px] font-bold">{hi ? alert.titleHi : alert.title}</div>
<div className="text-[12px] text-white/85 mt-0.5">
{t('home.estimate')}: <b>{inr(alert.shortfall)}</b> {t('home.shortfall')}
{breachRef && ` · ${hi ? breachRef.fullHi : breachRef.fullEn}`}
</div>
</div>
<div className="px-4 py-3">
<div className="text-[11px] font-semibold text-slate-500 mb-2">
{hi ? 'ऐसा क्यों लग रहा है' : 'Why this is expected'}
</div>
<ul className="space-y-2.5">
{reasons.map((r) => (
<li key={r.driver} className="flex gap-2.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber mt-[7px] shrink-0" />
<span className="text-[12.5px] text-slate-700 leading-snug">{hi ? r.sentenceHi : r.sentenceEn}</span>
</li>
))}
</ul>
</div>
<div className="px-4 py-3 border-t border-slate-100 bg-amber-soft">
<div className="text-[11px] font-semibold text-slate-600 mb-1">{t('alerts.whatToDo')}</div>
<p className="text-[12.5px] text-slate-700 leading-snug">
{hi
? 'अपने फ़ील्ड अधिकारी से बात करें — किस्त की तारीख़ कमाई के मौसम से मिलाई जा सकती है।'
: 'Speak to your field officer — the instalment date can be aligned to your earning season.'}
</p>
</div>
<div className="px-4 py-2 border-t border-slate-100 text-[10.5px] text-slate-400">
{t('alerts.raisedOn')}: {alert.raisedAt} · {alert.alertId}
</div>
</section>
{fired.length > 0 && (
<section className="bg-white rounded-xl border border-slate-200 px-4 py-3">
<div className="text-[11px] font-semibold text-slate-500 mb-2">
{hi ? 'किन बातों पर नज़र है' : 'What is being watched'}
</div>
<ul className="space-y-2">
{fired.map((w) => (
<li key={w.code} className="flex gap-2.5 items-start">
<span className="w-4 h-4 rounded-full bg-risk-soft text-risk-red grid place-items-center text-[9px] font-bold mt-0.5 shrink-0">
!
</span>
<span className="text-[12px] text-slate-700 leading-snug">{hi ? w.labelHi : w.label}</span>
</li>
))}
</ul>
</section>
)}
</div>
)
}
+208
View File
@@ -0,0 +1,208 @@
import { useNavigate } from 'react-router-dom'
import { enterpriseSession } from '../../lib/api'
import type { ForecastPoint } from '../../lib/api'
import { Empty } from '../../components/ui/bits'
import { breachPoint, findEnterprise, forecastFor } from '../../lib/demo/enterprises'
import { alertForEnterprise } from '../../lib/demo/ops'
import { inr, monthRef } from '../../lib/demo/seed'
import { dayTotals, useLedger } from '../../lib/ledgerStore'
import { useLang } from '../../lib/i18n'
import { EntrySheet } from './EntrySheet'
import { useState } from 'react'
import type { LedgerKind } from '../../lib/api'
/**
* The enterprise home screen, reproducing the deck's phone wireframe:
* an alert card, the six-month forecast strip against the instalment line,
* the three-tap ledger tiles, today's totals and a suggestion.
*/
export function AppHomePage() {
const navigate = useNavigate()
const session = enterpriseSession()
const { lang, t } = useLang('hi')
const [sheet, setSheet] = useState<LedgerKind | null>(null)
const e = session ? findEnterprise(session.enterpriseId) : undefined
const { entries } = useLedger(session?.enterpriseId ?? '')
if (!session || !e) return <Empty text="—" />
const points = forecastFor(e)
const alert = alertForEnterprise(e.enterpriseId)
const breach = breachPoint(points)
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
const totals = dayTotals(entries)
const hi = lang === 'hi'
return (
<div className="space-y-3">
{/* ── alert card ─────────────────────────────────────── */}
{alert && breachRef ? (
<button
onClick={() => navigate('/app/alerts')}
className="w-full text-left bg-risk-red text-white rounded-xl px-4 py-3 flex items-start gap-3"
>
<span className="w-6 h-6 rounded-full bg-white/25 grid place-items-center text-[13px] font-bold shrink-0">
!
</span>
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-bold">{t('home.attention')}</span>
<span className="block text-[12px] text-white/90 leading-snug">
{hi ? alert.titleHi : alert.title}
</span>
<span className="block text-[12.5px] font-bold mt-1">
{t('home.estimate')}: {inr(alert.shortfall)} {t('home.shortfall')}
</span>
</span>
<span className="text-[16px] opacity-70">›</span>
</button>
) : (
<div className="bg-green-soft border border-green/20 rounded-xl px-4 py-3">
<div className="text-[13.5px] font-bold text-forest">{t('home.allGood')}</div>
<div className="text-[11.5px] text-slate-600 leading-snug mt-0.5">{t('home.allGoodDetail')}</div>
</div>
)}
{/* ── forecast strip ─────────────────────────────────── */}
<section className="bg-white rounded-xl border border-slate-200 px-4 py-3">
<div className="flex items-baseline justify-between mb-3">
<h2 className="text-[13px] font-bold text-slate-800">{t('home.comingMonths')}</h2>
<span className="text-[10.5px] text-slate-400">{t('home.sixMonths')}</span>
</div>
<ForecastStrip points={points} hi={hi} instalmentLabel={t('home.instalment')} />
<p className="text-[10.5px] text-slate-400 mt-2.5 leading-snug">{t('home.legend')}</p>
</section>
{/* ── three-tap ledger ───────────────────────────────── */}
<section className="bg-white rounded-xl border border-slate-200 px-4 py-3">
<h2 className="text-[13px] font-bold text-slate-800 mb-3">{t('home.todayAccount')}</h2>
<div className="grid grid-cols-3 gap-2.5">
{(
[
{ kind: 'IN' as LedgerKind, key: 'home.moneyIn', sign: '+', cls: 'bg-green-soft text-green border-green/25' },
{ kind: 'OUT' as LedgerKind, key: 'home.moneyOut', sign: '−', cls: 'bg-risk-soft text-risk-red border-risk-red/20' },
{ kind: 'STOCK' as LedgerKind, key: 'home.stock', sign: '▢', cls: 'bg-amber-soft text-amber border-amber/30' },
]
).map((tile) => (
<button
key={tile.kind}
onClick={() => setSheet(tile.kind)}
className={`rounded-xl border ${tile.cls} py-3 flex flex-col items-center gap-1.5 active:scale-95 transition-transform`}
>
<span className="w-9 h-9 rounded-full bg-white grid place-items-center text-[17px] font-bold">
{tile.sign}
</span>
<span className="text-[11.5px] font-semibold text-slate-700">{t(tile.key)}</span>
</button>
))}
</div>
<div className="grid grid-cols-2 gap-2.5 mt-3">
<div className="bg-slate-50 rounded-lg px-3 py-2">
<div className="text-[10.5px] text-slate-400">{t('home.todayIn')}</div>
<div className="text-[15px] font-bold text-green">{inr(totals.in)}</div>
</div>
<div className="bg-slate-50 rounded-lg px-3 py-2">
<div className="text-[10.5px] text-slate-400">{t('home.todayOut')}</div>
<div className="text-[15px] font-bold text-risk-red">{inr(totals.out)}</div>
</div>
</div>
</section>
{/* ── suggestion ─────────────────────────────────────── */}
<section className="bg-amber-soft border border-amber/30 rounded-xl px-4 py-3 flex gap-3">
<span className="w-6 h-6 rounded-full bg-amber text-white grid place-items-center text-[12px] font-bold shrink-0">
✓
</span>
<div className="min-w-0">
<div className="text-[12.5px] font-bold text-slate-800">{t('home.todaySuggestion')}</div>
<p className="text-[12px] text-slate-700 leading-snug mt-0.5">
{hi ? adviceHi(e.sector, breachRef?.fullHi) : adviceEn(e.sector, breachRef?.fullEn)}
</p>
</div>
</section>
{sheet && <EntrySheet kind={sheet} enterpriseId={e.enterpriseId} onClose={() => setSheet(null)} />}
</div>
)
}
/**
* Six months as bars against the instalment line. A bar is green when even the
* downside clears what is due, amber when only the expected case does, and red
* when the expected case falls short.
*/
function ForecastStrip({
points,
hi,
instalmentLabel,
}: {
points: ForecastPoint[]
hi: boolean
instalmentLabel: string
}) {
const ceiling = Math.max(...points.map((p) => Math.max(p.p50, p.obligation))) * 1.15
const obligation = points[0].obligation
const linePct = (obligation / ceiling) * 100
return (
<div className="relative">
<div className="flex items-end gap-2 h-[112px]">
{points.map((p) => {
const tone = p.p10 >= p.obligation ? 'bg-green' : p.p50 >= p.obligation ? 'bg-amber' : 'bg-risk-red'
return (
<div key={p.month} className="flex-1 flex flex-col items-center justify-end h-full">
<div
className={`w-full rounded-t-md ${tone}`}
style={{ height: `${Math.max(6, (p.p50 / ceiling) * 100)}%` }}
title={`${hi ? p.labelHi : p.label}: ${inr(p.p50)}`}
/>
</div>
)
})}
</div>
{/* instalment threshold */}
<div
className="absolute left-0 right-0 border-t-2 border-dashed border-risk-red/70 pointer-events-none"
style={{ bottom: `${Math.min(95, linePct)}%` }}
>
<span className="absolute -top-2.5 right-0 bg-risk-red text-white text-[9.5px] font-bold px-1.5 rounded-full">
{instalmentLabel}
</span>
</div>
<div className="flex gap-2 mt-1.5">
{points.map((p) => (
<span key={p.month} className="flex-1 text-center text-[10px] text-slate-500">
{hi ? p.labelHi : p.label}
</span>
))}
</div>
</div>
)
}
function adviceHi(sector: string, breachMonth?: string): string {
const base: Record<string, string> = {
POULTRY: 'अभी दाना ख़रीद लें — रेट 18% बढ़ा है और आगे और बढ़ सकता है।',
DAIRY: 'चारा अभी भर लें और समिति से भुगतान जल्दी माँगें।',
FOOD_PROC: 'कच्चा माल थोक में लें और खरीदार से 15 दिन में भुगतान तय करें।',
HANDICRAFTS: 'तैयार माल की बिक्री के लिए दूसरा खरीदार भी जोड़ें।',
RURAL_RETAIL: 'उधार बिक्री कम करें और रोज़ UPI से वसूली करें।',
}
const tail = breachMonth ? ` ${breachMonth} तक पैसा कम पड़ सकता है।` : ''
return (base[sector] ?? base.RURAL_RETAIL) + tail
}
function adviceEn(sector: string, breachMonth?: string): string {
const base: Record<string, string> = {
POULTRY: 'Buy feed now — the rate is up 18% and may rise further.',
DAIRY: 'Stock fodder now and press the society for faster payment.',
FOOD_PROC: 'Buy raw material in bulk and hold buyers to 15-day settlement.',
HANDICRAFTS: 'Add a second buyer so sales do not depend on one account.',
RURAL_RETAIL: 'Reduce credit sales and collect daily over UPI.',
}
const tail = breachMonth ? ` Cash may fall short by ${breachMonth}.` : ''
return (base[sector] ?? base.RURAL_RETAIL) + tail
}
+140
View File
@@ -0,0 +1,140 @@
import { useMemo, useState } from 'react'
import { enterpriseSession } from '../../lib/api'
import type { LedgerKind } from '../../lib/api'
import { Empty } from '../../components/ui/bits'
import { CATEGORIES, useLedger } from '../../lib/ledgerStore'
import { inr, todayIso } from '../../lib/demo/seed'
import { useLang } from '../../lib/i18n'
import { EntrySheet } from './EntrySheet'
type Range = 'DAY' | 'WEEK' | 'MONTH'
/** The owner's own book: running balance, day/week/month view, and the entry sheet. */
export function AppLedgerPage() {
const session = enterpriseSession()
const { lang, t } = useLang('hi')
const [range, setRange] = useState<Range>('WEEK')
const [sheet, setSheet] = useState<LedgerKind | null>(null)
const { entries } = useLedger(session?.enterpriseId ?? '')
const hi = lang === 'hi'
const cutoff = useMemo(() => {
const d = new Date()
if (range === 'DAY') return todayIso()
d.setDate(d.getDate() - (range === 'WEEK' ? 7 : 30))
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}, [range])
const rows = entries.filter((e) => (range === 'DAY' ? e.entryDate === cutoff : e.entryDate >= cutoff))
const inTotal = rows.filter((r) => r.kind === 'IN').reduce((a, r) => a + r.amount, 0)
const outTotal = rows.filter((r) => r.kind === 'OUT').reduce((a, r) => a + r.amount, 0)
const balance = inTotal - outTotal
const byDate = rows.reduce<Record<string, typeof rows>>((acc, r) => {
;(acc[r.entryDate] ||= []).push(r)
return acc
}, {})
const label = (kind: LedgerKind, code: string) => {
const c = CATEGORIES[kind].find((x) => x.code === code)
return c ? (hi ? c.hi : c.en) : code
}
if (!session) return <Empty text="—" />
return (
<div className="space-y-3">
{/* balance */}
<section className="bg-forest text-white rounded-xl px-4 py-3.5">
<div className="text-[11px] text-[#a8c9b6]">{t('ledger.balance')}</div>
<div className="text-[30px] font-bold leading-tight">{inr(balance)}</div>
<div className="flex gap-4 mt-1.5 text-[11.5px]">
<span className="text-green-hi">↑ {inr(inTotal)}</span>
<span className="text-amber-hi">↓ {inr(outTotal)}</span>
</div>
</section>
{/* range */}
<div className="flex gap-2">
{(
[
{ key: 'DAY' as Range, hi: 'आज', en: 'Today' },
{ key: 'WEEK' as Range, hi: 'हफ़्ता', en: 'Week' },
{ key: 'MONTH' as Range, hi: 'महीना', en: 'Month' },
]
).map((r) => (
<button
key={r.key}
onClick={() => setRange(r.key)}
className={[
'flex-1 py-2 rounded-lg text-[12.5px] font-semibold border transition-colors',
range === r.key ? 'bg-forest text-white border-forest' : 'bg-white text-slate-600 border-slate-200',
].join(' ')}
>
{hi ? r.hi : r.en}
</button>
))}
</div>
{/* add */}
<div className="grid grid-cols-3 gap-2">
{(
[
{ kind: 'IN' as LedgerKind, key: 'home.moneyIn', cls: 'bg-green-soft text-green border-green/25' },
{ kind: 'OUT' as LedgerKind, key: 'home.moneyOut', cls: 'bg-risk-soft text-risk-red border-risk-red/20' },
{ kind: 'STOCK' as LedgerKind, key: 'home.stock', cls: 'bg-amber-soft text-amber border-amber/30' },
]
).map((b) => (
<button
key={b.kind}
onClick={() => setSheet(b.kind)}
className={`rounded-lg border ${b.cls} py-2.5 text-[12px] font-semibold text-slate-700 active:scale-95 transition-transform`}
>
+ {t(b.key)}
</button>
))}
</div>
{/* entries */}
{Object.entries(byDate).map(([date, list]) => (
<section key={date} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-3.5 py-2 bg-slate-50 border-b border-slate-100 flex items-center">
<span className="text-[11px] font-semibold text-slate-600">
{date === todayIso() ? t('ledger.today') : date}
</span>
<span className="ml-auto text-[11px] text-slate-400">{list.length}</span>
</div>
{list.map((r) => (
<div key={r.id} className="px-3.5 py-2.5 flex items-center gap-3 border-b border-slate-50 last:border-0">
<span
className={`w-8 h-8 rounded-full grid place-items-center text-[14px] font-bold ${
r.kind === 'IN' ? 'band-standard' : r.kind === 'OUT' ? 'band-red' : 'band-watch'
}`}
>
{r.kind === 'IN' ? '+' : r.kind === 'OUT' ? '−' : '▢'}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] font-semibold text-slate-700">{label(r.kind, r.category)}</span>
<span className="block text-[10.5px] text-slate-400">
{r.syncState === 'PENDING' ? t('ledger.pending') : t('ledger.synced')}
</span>
</span>
<span
className={`text-[14px] font-bold ${
r.kind === 'IN' ? 'text-green' : r.kind === 'OUT' ? 'text-risk-red' : 'text-amber'
}`}
>
{r.kind === 'OUT' ? '−' : ''}
{inr(r.amount)}
</span>
</div>
))}
</section>
))}
{!rows.length && <Empty text={t('ledger.empty')} />}
{sheet && <EntrySheet kind={sheet} enterpriseId={session.enterpriseId} onClose={() => setSheet(null)} />}
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { clearSessions, enterpriseSession } from '../../lib/api'
import type { Lang } from '../../lib/api'
import { Empty } from '../../components/ui/bits'
import { SECTORS, findEnterprise } from '../../lib/demo/enterprises'
import { lastSyncedAt, pendingCount, syncPending } from '../../lib/ledgerStore'
import { useLang } from '../../lib/i18n'
import { useOffline } from '../../lib/useOffline'
/**
* Enterprise profile: what the system knows, the consent that permits it, the
* sync state, and the language switch. The "no personal data" promise is stated
* here because this is the only screen where the owner sees their own record.
*/
export function AppProfilePage() {
const navigate = useNavigate()
const session = enterpriseSession()
const { lang, setLang, t } = useLang('hi')
const offline = useOffline()
const [consent, setConsent] = useState(true)
const [justSynced, setJustSynced] = useState<number | null>(null)
const hi = lang === 'hi'
const e = session ? findEnterprise(session.enterpriseId) : undefined
if (!session || !e) return <Empty text="—" />
const pending = pendingCount(e.enterpriseId)
const synced = lastSyncedAt()
function doSync() {
const n = syncPending(e!.enterpriseId)
setJustSynced(n)
setTimeout(() => setJustSynced(null), 2200)
}
function signOut() {
clearSessions()
navigate('/login', { replace: true })
}
return (
<div className="space-y-3">
<section className="bg-white rounded-xl border border-slate-200 px-4 py-4 text-center">
<div className="w-14 h-14 rounded-full bg-green-soft text-forest grid place-items-center text-[20px] font-bold mx-auto mb-2">
{e.name[0]}
</div>
<div className="text-[15px] font-bold text-slate-800">{hi ? e.nameHi : e.name}</div>
<div className="font-mono text-[11px] text-slate-400">{e.enterpriseId}</div>
</section>
<section className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{[
[t('profile.sector'), hi ? SECTORS[e.sector].labelHi : SECTORS[e.sector].label],
[t('profile.block'), e.block],
[hi ? 'ज़िला' : 'District', e.district],
].map(([k, v]) => (
<div key={k} className="px-4 py-2.5 flex justify-between text-[12.5px]">
<span className="text-slate-500">{k}</span>
<span className="font-semibold text-slate-700">{v}</span>
</div>
))}
</section>
{/* consent */}
<section className="bg-white rounded-xl border border-slate-200 px-4 py-3">
<div className="flex items-center gap-2.5">
<span className="text-[12.5px] font-semibold text-slate-700">{t('profile.consent')}</span>
<span className={`pill ${consent ? 'pill-ok' : 'pill-mut'}`}>
{consent ? t('profile.consentActive') : hi ? 'वापस ली गई' : 'Revoked'}
</span>
</div>
<p className="text-[11.5px] text-slate-500 leading-snug mt-1.5">
{hi
? 'सिर्फ़ लेन-देन का जोड़ देखा जाता है — किसी का नाम, नंबर या खाता नहीं।'
: 'Only transaction aggregates are read — never a name, number or account.'}
</p>
<button
onClick={() => setConsent((c) => !c)}
className="mt-2.5 text-[12px] font-semibold text-risk-red border border-risk-red/30 rounded-lg px-3 py-1.5"
>
{consent ? t('profile.revoke') : hi ? 'सहमति दोबारा दें' : 'Give consent again'}
</button>
</section>
{/* sync */}
<section className="bg-white rounded-xl border border-slate-200 px-4 py-3">
<div className="flex items-center gap-2.5 mb-1.5">
<span className="text-[12.5px] font-semibold text-slate-700">{t('profile.sync')}</span>
<span className={`pill ${offline ? 'pill-bad' : 'pill-ok'}`}>
{offline ? (hi ? 'इंटरनेट नहीं' : 'Offline') : hi ? 'जुड़ा है' : 'Online'}
</span>
{pending > 0 && <span className="pill pill-pend">{pending} {t('ledger.pending')}</span>}
</div>
<div className="text-[11.5px] text-slate-500">
{t('profile.lastSynced')}: {synced ? new Date(synced).toLocaleString() : t('profile.never')}
</div>
<button
onClick={doSync}
disabled={offline || pending === 0}
className="mt-2.5 w-full bg-forest disabled:opacity-40 text-white text-[13px] font-semibold py-2.5 rounded-lg"
>
{t('profile.syncNow')}
</button>
{justSynced !== null && (
<p className="text-[11.5px] text-green mt-1.5 text-center">
{justSynced} {hi ? 'हिसाब भेज दिए' : 'entries synced'}
</p>
)}
</section>
{/* language */}
<section className="bg-white rounded-xl border border-slate-200 px-4 py-3">
<div className="text-[12.5px] font-semibold text-slate-700 mb-2">{t('profile.language')}</div>
<div className="flex gap-2">
{(
[
{ key: 'hi' as Lang, label: 'हिन्दी' },
{ key: 'en' as Lang, label: 'English' },
]
).map((l) => (
<button
key={l.key}
onClick={() => setLang(l.key)}
className={[
'flex-1 py-2 rounded-lg text-[12.5px] font-semibold border transition-colors',
lang === l.key ? 'bg-forest text-white border-forest' : 'bg-white text-slate-600 border-slate-200',
].join(' ')}
>
{l.label}
</button>
))}
</div>
</section>
<p className="text-[11px] text-slate-400 text-center leading-snug px-4">{t('profile.noPii')}</p>
<button
onClick={signOut}
className="w-full border border-slate-200 bg-white text-slate-600 text-[13px] font-semibold py-2.5 rounded-xl"
>
{t('profile.logout')}
</button>
</div>
)
}
@@ -0,0 +1,95 @@
import { NavLink, Outlet } from 'react-router-dom'
import { enterpriseSession } from '../../lib/api'
import { UdyamMark } from '../../components/ui/bits'
import { useLang } from '../../lib/i18n'
import { useOffline } from '../../lib/useOffline'
import { pendingCount } from '../../lib/ledgerStore'
/*
* Enterprise PWA shell — a phone-width frame with a forest header and a four-tab
* bottom bar. Hindi-first: the owner's language is the default and English is
* the option, not the other way round.
*/
const TABS: { to: string; key: string; icon: string; end?: boolean }[] = [
{ to: '/app', key: 'tab.home', icon: '⌂', end: true },
{ to: '/app/ledger', key: 'tab.ledger', icon: '₹' },
{ to: '/app/alerts', key: 'tab.alerts', icon: '!' },
{ to: '/app/profile', key: 'tab.profile', icon: '☺' },
]
export function EnterpriseShell() {
const session = enterpriseSession()
const { lang, t } = useLang('hi')
const offline = useOffline()
const pending = session ? pendingCount(session.enterpriseId) : 0
return (
<div className="min-h-screen bg-[#eef3f0] flex justify-center">
<div className="w-full max-w-[420px] bg-[#f4f7f5] min-h-screen flex flex-col shadow-[0_0_40px_rgba(6,48,31,0.08)]">
{/* ── header ────────────────────────────────────────── */}
<header className="bg-forest text-white px-4 py-3 flex items-center gap-2.5 sticky top-0 z-30">
<UdyamMark size={26} onDark />
<div className="min-w-0 leading-tight">
<div className="text-[13.5px] font-bold">
Udyam<span className="text-amber-hi">Linc</span>
</div>
<div className="text-[10.5px] text-[#a8c9b6] truncate">
{lang === 'hi' ? (session?.nameHi ?? session?.name) : session?.name}
</div>
</div>
<div className="ml-auto flex items-center gap-2">
{pending > 0 && (
<span className="text-[9.5px] bg-amber text-forest-deep font-bold px-1.5 py-0.5 rounded-full">
{pending}
</span>
)}
<NavLink to="/app/alerts" className="w-7 h-7 grid place-items-center rounded-full bg-white/10" aria-label={t('tab.alerts')}>
🔔
</NavLink>
</div>
</header>
{offline && (
<div className="bg-amber-soft border-b border-amber/30 px-4 py-1.5 text-[11px] text-slate-700 text-center">
{t('common.offline')}
</div>
)}
<main className="flex-1 px-3.5 py-3.5 pb-24">
<Outlet />
</main>
{/* ── bottom tab bar ────────────────────────────────── */}
<nav className="fixed bottom-0 w-full max-w-[420px] bg-white border-t border-slate-200 flex ul-safe-bottom z-30">
{TABS.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
end={tab.end}
className={({ isActive }) =>
[
'flex-1 flex flex-col items-center gap-0.5 py-2.5 text-[10.5px] font-semibold transition-colors',
isActive ? 'text-forest' : 'text-slate-400',
].join(' ')
}
>
{({ isActive }) => (
<>
<span
className={`w-7 h-7 grid place-items-center rounded-full text-[14px] ${
isActive ? 'bg-green-soft text-forest' : 'text-slate-400'
}`}
>
{tab.icon}
</span>
{t(tab.key)}
</>
)}
</NavLink>
))}
</nav>
</div>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { useState } from 'react'
import type { LedgerKind } from '../../lib/api'
import { CATEGORIES, addEntry } from '../../lib/ledgerStore'
import { useLang } from '../../lib/i18n'
import { inr } from '../../lib/demo/seed'
/**
* The three-tap ledger entry: type the amount, tap a category, tap save.
*
* Designed for ~15 seconds of daily effort — big touch targets, a numeric pad
* of quick amounts, no free text required, and it never blocks on the network.
*/
const QUICK = [100, 200, 500, 1000, 2000, 5000]
const KIND_META: Record<LedgerKind, { key: string; cls: string; ring: string }> = {
IN: { key: 'home.moneyIn', cls: 'bg-green text-white', ring: 'focus:ring-green/30 focus:border-green' },
OUT: { key: 'home.moneyOut', cls: 'bg-risk-red text-white', ring: 'focus:ring-risk-red/25 focus:border-risk-red' },
STOCK: { key: 'home.stock', cls: 'bg-amber text-white', ring: 'focus:ring-amber/30 focus:border-amber' },
}
export function EntrySheet({
kind,
enterpriseId,
onClose,
}: {
kind: LedgerKind
enterpriseId: string
onClose: () => void
}) {
const { lang, t } = useLang('hi')
const [amount, setAmount] = useState('')
const [category, setCategory] = useState(CATEGORIES[kind][0].code)
const [done, setDone] = useState(false)
const hi = lang === 'hi'
const meta = KIND_META[kind]
const value = Number(amount) || 0
function save() {
if (value <= 0) return
addEntry({ enterpriseId, kind, amount: value, category })
setDone(true)
setTimeout(onClose, 900)
}
return (
<div className="fixed inset-0 z-[90] flex items-end justify-center bg-black/40" onClick={onClose}>
<div
className="w-full max-w-[420px] bg-white rounded-t-2xl p-4 ul-safe-bottom"
onClick={(ev) => ev.stopPropagation()}
>
{done ? (
<div className="py-10 text-center">
<div className="w-14 h-14 rounded-full bg-green-soft text-green grid place-items-center text-[26px] mx-auto mb-3">
✓
</div>
<div className="text-[16px] font-bold text-forest">{t('ledger.saved')}</div>
<div className="text-[12.5px] text-slate-500 mt-1">{inr(value)}</div>
</div>
) : (
<>
<div className="flex items-center gap-2.5 mb-4">
<span className={`w-8 h-8 rounded-full grid place-items-center text-[16px] font-bold ${meta.cls}`}>
{kind === 'IN' ? '+' : kind === 'OUT' ? '−' : '▢'}
</span>
<h2 className="text-[15px] font-bold text-slate-800">{t(meta.key)}</h2>
<button onClick={onClose} className="ml-auto w-8 h-8 rounded-full bg-slate-100 text-slate-500 text-[13px]">
✕
</button>
</div>
{/* 1 — amount */}
<label className="block mb-3">
<span className="block text-[11.5px] text-slate-500 mb-1.5">{t('ledger.amount')}</span>
<div className="flex items-center gap-2 border border-slate-300 rounded-xl px-3 py-2 focus-within:ring-2 focus-within:ring-green/20">
<span className="text-[20px] text-slate-400">₹</span>
<input
autoFocus
inputMode="numeric"
pattern="[0-9]*"
className={`flex-1 text-[26px] font-bold text-slate-800 outline-none ${meta.ring}`}
placeholder="0"
value={amount}
onChange={(ev) => setAmount(ev.target.value.replace(/\D/g, ''))}
/>
</div>
</label>
<div className="grid grid-cols-3 gap-2 mb-4">
{QUICK.map((q) => (
<button
key={q}
onClick={() => setAmount(String(q))}
className="py-2 rounded-lg bg-slate-100 text-[13px] font-semibold text-slate-700 active:scale-95 transition-transform"
>
₹{q.toLocaleString('en-IN')}
</button>
))}
</div>
{/* 2 — category */}
<div className="mb-4">
<span className="block text-[11.5px] text-slate-500 mb-1.5">{t('ledger.chooseCategory')}</span>
<div className="flex flex-wrap gap-2">
{CATEGORIES[kind].map((c) => (
<button
key={c.code}
onClick={() => setCategory(c.code)}
className={[
'px-3 py-2 rounded-full text-[12.5px] font-semibold border transition-colors',
category === c.code
? 'bg-forest text-white border-forest'
: 'bg-white text-slate-600 border-slate-300',
].join(' ')}
>
{hi ? c.hi : c.en}
</button>
))}
</div>
</div>
{/* 3 — save */}
<button
onClick={save}
disabled={value <= 0}
className="w-full bg-forest disabled:opacity-40 text-white text-[15px] font-bold py-3.5 rounded-xl active:scale-[0.99] transition-transform"
>
{t('ledger.save')}
{value > 0 && ` · ${inr(value)}`}
</button>
</>
)}
</div>
</div>
)
}
+138
View File
@@ -0,0 +1,138 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { officerSession } from '../../lib/api'
import type { AlertState, RiskAlert } from '../../lib/api'
import { ActionChip, Card, Empty, GhostBtn, RiskBadge, StatusPill } from '../../components/ui/bits'
import { SECTORS } from '../../lib/demo/enterprises'
import { inr, monthRef } from '../../lib/demo/seed'
import { setAlertState, useAlerts } from '../../lib/alertStore'
import { useScope } from './officerScope'
const FILTERS: { key: AlertState | 'ALL'; label: string }[] = [
{ key: 'ALL', label: 'All' },
{ key: 'OPEN', label: 'Open' },
{ key: 'ACKNOWLEDGED', label: 'Acknowledged' },
{ key: 'ESCALATED', label: 'Escalated' },
{ key: 'SNOOZED', label: 'Snoozed' },
]
/**
* The alert queue. Every row carries its origin (model vs trip-wire), the
* plain-language reason, the projected gap, and the three dispositions an
* officer can take — each of which is written to the audit strip below.
*/
export function AlertsPage() {
const navigate = useNavigate()
const { block } = useScope()
const [filter, setFilter] = useState<AlertState | 'ALL'>('OPEN')
const { alerts, audit } = useAlerts(block)
const actor = officerSession()?.fullName ?? 'Field Officer'
const rows = alerts.filter((a) => filter === 'ALL' || a.state === filter)
function act(alert: RiskAlert, to: AlertState) {
setAlertState(alert, to, actor)
}
return (
<div className="grid gap-4 xl:grid-cols-[1fr_320px] items-start">
<div>
<div className="flex flex-wrap items-center gap-1.5 mb-3">
{FILTERS.map((f) => (
<ActionChip key={f.key} active={filter === f.key} onClick={() => setFilter(f.key)}>
{f.label}
{f.key !== 'ALL' && (
<span className="ml-1 opacity-60">{alerts.filter((a) => a.state === f.key).length}</span>
)}
</ActionChip>
))}
<span className="text-[11px] text-slate-500 ml-1">{rows.length} shown</span>
</div>
<div className="space-y-2.5">
{rows.map((a) => {
const breachRef = a.breachMonth ? monthRef(new Date(`${a.breachMonth}-01T00:00:00`)) : null
return (
<div key={a.alertId} className="ul-card px-4 py-3">
<div className="flex items-start gap-3">
<span
className={`w-7 h-7 rounded-full grid place-items-center text-[12px] font-bold shrink-0 ${
a.band === 'RED' ? 'band-red' : 'band-watch'
}`}
>
!
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<button
className="text-[13px] font-bold text-forest hover:underline"
onClick={() => navigate(`/officer/enterprises/${a.enterpriseId}`)}
>
{a.enterpriseName}
</button>
<RiskBadge band={a.band} />
<span className={`pill ${a.origin === 'TRIPWIRE' ? 'pill-info' : 'pill-mut'}`}>
{a.origin === 'TRIPWIRE' ? 'Trip-wire' : 'Model'}
</span>
<StatusPill status={a.state} />
<span className="font-mono text-[10px] text-slate-400 ml-auto">{a.alertId}</span>
</div>
<div className="text-[11px] text-slate-500 mt-0.5">
{a.enterpriseId} · {SECTORS[a.sector].short} · {a.block} · score{' '}
<b className="text-slate-700">{a.score}</b> · raised {a.raisedAt}
</div>
<p className="text-[12.5px] font-semibold text-slate-700 mt-2">{a.title}</p>
<p className="text-[11.5px] text-slate-600 leading-snug">{a.reason.sentenceEn}</p>
<p className="text-[11px] text-slate-400 leading-snug">{a.reason.sentenceHi}</p>
{a.shortfall > 0 && (
<p className="text-[11.5px] text-risk-red font-semibold mt-1.5">
Projected gap {inr(a.shortfall)} at P10{breachRef && ` in ${breachRef.fullEn}`}
</p>
)}
<div className="flex flex-wrap gap-2 mt-3">
<GhostBtn disabled={a.state === 'ACKNOWLEDGED'} onClick={() => act(a, 'ACKNOWLEDGED')}>
Acknowledge
</GhostBtn>
<GhostBtn disabled={a.state === 'ESCALATED'} onClick={() => act(a, 'ESCALATED')}>
Escalate
</GhostBtn>
<GhostBtn disabled={a.state === 'SNOOZED'} onClick={() => act(a, 'SNOOZED')}>
Snooze
</GhostBtn>
<GhostBtn onClick={() => navigate(`/officer/enterprises/${a.enterpriseId}`)}>
Open enterprise
</GhostBtn>
</div>
</div>
</div>
</div>
)
})}
</div>
{!rows.length && <Empty text="Nothing in this state right now." />}
</div>
{/* ── audit strip ─────────────────────────────────────── */}
<Card title="Audit trail" badge="every action logged">
<div className="p-3 space-y-2.5 max-h-[560px] overflow-y-auto">
{audit.map((a, i) => (
<div key={`${a.alertId}-${i}`} className="border-l-2 border-slate-200 pl-2.5">
<div className="font-mono text-[10px] text-slate-400">{new Date(a.at).toLocaleString()}</div>
<div className="text-[11.5px] text-slate-700">
<b>{a.alertId}</b> {a.from} → {a.to}
</div>
<div className="text-[10.5px] text-slate-400">{a.actor}</div>
</div>
))}
{!audit.length && <p className="text-[11.5px] text-slate-400 py-4 text-center">No actions taken yet.</p>}
</div>
</Card>
</div>
)
}
+315
View File
@@ -0,0 +1,315 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { get } from '../../lib/api'
import type { ClimateSignal, Freshness, IngestionRun, MarketSignal } from '../../lib/api'
import { Card, Empty, StatusPill } from '../../components/ui/bits'
import {
demoClimateSignals,
demoFreshness,
demoIngestionRuns,
demoMarketSignals,
} from '../../lib/demo/feeds'
/**
* Where the numbers came from and how old they are.
*
* <p>This screen exists because the failure mode that matters is silent: a score
* computed from a three-week-old mandi price looks exactly like a score computed
* this morning. Staleness is shown first and everywhere, before any price.
*/
export function DataFeedsPage() {
const [district, setDistrict] = useState<string>('ALL')
const freshnessQ = useQuery({ queryKey: ['freshness'], queryFn: () => get<Freshness[]>('/admin/ingestion/freshness') })
const marketQ = useQuery({
queryKey: ['market-signals'],
queryFn: () => get<MarketSignal[]>('/admin/ingestion/signals/market', { weeks: 8 }),
})
const climateQ = useQuery({
queryKey: ['climate-signals'],
queryFn: () => get<ClimateSignal[]>('/admin/ingestion/signals/climate', { weeks: 8 }),
})
const runsQ = useQuery({
queryKey: ['ingestion-runs'],
queryFn: () => get<IngestionRun[]>('/admin/ingestion/runs', { limit: 10 }),
})
const demo = freshnessQ.isError || marketQ.isError || climateQ.isError || runsQ.isError
const freshness = freshnessQ.data ?? demoFreshness()
const market = marketQ.data ?? demoMarketSignals()
const climate = climateQ.data ?? demoClimateSignals()
const runs = runsQ.data ?? demoIngestionRuns()
const districts = useMemo(
() => Array.from(new Set(freshness.map((f) => f.districtName))).sort(),
[freshness],
)
// Latest week per (district, commodity) — the panel is a current view, not a log.
const latestMarket = useMemo(() => {
const byKey = new Map<string, MarketSignal>()
for (const row of market) {
if (district !== 'ALL' && row.districtName !== district) continue
const key = `${row.districtName}|${row.commodityName}`
const held = byKey.get(key)
if (!held || row.weekStart > held.weekStart) byKey.set(key, row)
}
return Array.from(byKey.values()).sort(
(a, b) =>
a.districtName.localeCompare(b.districtName) || a.commodityName.localeCompare(b.commodityName),
)
}, [market, district])
const latestClimate = useMemo(() => {
const byDistrict = new Map<string, ClimateSignal>()
for (const row of climate) {
if (district !== 'ALL' && row.districtName !== district) continue
const held = byDistrict.get(row.districtName)
if (!held || row.weekStart > held.weekStart) byDistrict.set(row.districtName, row)
}
return Array.from(byDistrict.values()).sort((a, b) => a.districtName.localeCompare(b.districtName))
}, [climate, district])
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div>
<h1 className="text-[15px] font-bold text-forest leading-tight">Data feeds</h1>
<p className="text-[11px] text-slate-500">
Mandi prices from Agmarknet via data.gov.in; rainfall and temperature from the reanalysis archive.
External feeds are pulled nightly and never called while you wait.
</p>
</div>
<div className="ml-auto flex items-center gap-2">
{demo && <span className="pill pill-pend">Demo data</span>}
<select
value={district}
onChange={(e) => setDistrict(e.target.value)}
className="border border-slate-300 rounded-md px-2 py-1 text-[11.5px] bg-white outline-none focus:border-green"
aria-label="District"
>
<option value="ALL">All districts</option>
{districts.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
</div>
{/* ── freshness ─────────────────────────────────────── */}
<Card title="Feed freshness" badge="what a trip-wire checks first">
<div className="p-4">
<p className="text-[11.5px] text-slate-500 leading-snug mb-3">
A rule refuses to fire on data older than it tolerates — a trip-wire on a three-week-old price is worse
than no trip-wire. These are the ages every rule is measured against.
</p>
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-4">
{freshness.map((f) => (
<div key={f.districtCode} className="rounded-lg border border-slate-200 px-3 py-2.5">
<div className="text-[12px] font-semibold text-slate-700">{f.districtName}</div>
<FreshnessRow label="Mandi" days={f.marketStalenessDays} validFor={f.marketValidFor} warnAbove={10} />
<FreshnessRow label="Climate" days={f.climateStalenessDays} validFor={f.climateValidFor} warnAbove={14} />
</div>
))}
</div>
</div>
</Card>
<div className="grid gap-4 xl:grid-cols-2">
{/* ── market panel ───────────────────────────────── */}
<Card title="Mandi prices" badge="latest week held">
<div className="p-4 overflow-x-auto">
{latestMarket.length === 0 ? (
<Empty text="No market signals yet — run ingestion to populate." />
) : (
<table className="ul-table w-full">
<thead>
<tr>
<th>District</th>
<th>Commodity</th>
<th className="text-right">Modal</th>
<th className="text-right">Range</th>
<th className="text-right">Arrivals</th>
<th>Week</th>
<th className="text-right">Age</th>
</tr>
</thead>
<tbody>
{latestMarket.map((row) => (
<tr key={row.id}>
<td>{row.districtName}</td>
<td className="font-medium text-slate-700">{row.commodityName}</td>
<td className="text-right font-semibold">
{row.modalPrice === null ? '—' : `₹${row.modalPrice.toLocaleString('en-IN')}`}
</td>
<td className="text-right text-slate-500 text-[11px]">
{row.minPrice === null || row.maxPrice === null
? '—'
: `${row.minPrice.toLocaleString('en-IN')} – ${row.maxPrice.toLocaleString('en-IN')}`}
</td>
<td className="text-right text-slate-500">{row.arrivalCount ?? '—'}</td>
<td className="text-slate-500 text-[11px]">{row.weekStart}</td>
<td className="text-right">
<AgeChip validFor={row.validForDate} warnAbove={10} />
</td>
</tr>
))}
</tbody>
</table>
)}
<p className="text-[10.5px] text-slate-400 mt-2.5">
A price level is not a feature. These rows become <span className="font-mono">feed_cost_change</span>,{' '}
<span className="font-mono">margin_squeeze</span> and{' '}
<span className="font-mono">price_z_score</span> against their trailing means.
</p>
</div>
</Card>
{/* ── climate panel ──────────────────────────────── */}
<Card title="Rainfall & heat" badge="against the 30-year normal">
<div className="p-4 overflow-x-auto">
{latestClimate.length === 0 ? (
<Empty text="No climate signals yet — run ingestion to populate." />
) : (
<table className="ul-table w-full">
<thead>
<tr>
<th>District</th>
<th className="text-right">Rain</th>
<th className="text-right">Normal</th>
<th className="text-right">Anomaly</th>
<th className="text-right">Max temp</th>
<th className="text-right">vs normal</th>
<th>Week</th>
</tr>
</thead>
<tbody>
{latestClimate.map((row) => (
<tr key={row.id}>
<td>{row.districtName}</td>
<td className="text-right">{row.rainfallMm === null ? '—' : `${row.rainfallMm} mm`}</td>
<td className="text-right text-slate-500">
{row.rainfallNormalMm === null ? '—' : `${row.rainfallNormalMm} mm`}
</td>
<td className="text-right">
<Delta value={row.rainfallAnomalyPct} suffix="%" goodWhenPositive />
</td>
<td className="text-right">{row.tmaxC === null ? '—' : `${row.tmaxC} °C`}</td>
<td className="text-right">
<Delta value={row.heatAnomalyC} suffix=" °C" />
</td>
<td className="text-slate-500 text-[11px]">{row.weekStart}</td>
</tr>
))}
</tbody>
</table>
)}
<p className="text-[10.5px] text-slate-400 mt-2.5">
An absolute rainfall figure means nothing on its own — the normal beside it is what makes a deficit
readable, and it needs decades of history the live endpoints do not serve.
</p>
</div>
</Card>
</div>
{/* ── runs ──────────────────────────────────────────── */}
<Card title="Ingestion runs" badge="nightly at 02:00">
<div className="p-4 overflow-x-auto">
<table className="ul-table w-full">
<thead>
<tr>
<th>Source</th>
<th>Date</th>
<th>Trigger</th>
<th>Status</th>
<th className="text-right">Fetched</th>
<th className="text-right">Written</th>
<th className="text-right">Failed calls</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id}>
<td className="font-mono text-[11px]">{run.source}</td>
<td>{run.runDate}</td>
<td className="text-slate-500">{run.trigger}</td>
<td>
<StatusPill status={run.status} />
</td>
<td className="text-right">{run.rowsFetched ?? '—'}</td>
<td className="text-right font-semibold">{run.rowsWritten ?? '—'}</td>
<td className={`text-right ${run.callsFailed ? 'text-risk-red font-semibold' : 'text-slate-400'}`}>
{run.callsFailed ?? 0}
</td>
<td className="text-slate-500 text-[11px] max-w-[280px] truncate">{run.message}</td>
</tr>
))}
</tbody>
</table>
<p className="text-[10.5px] text-slate-400 mt-2.5">
A skipped run is recorded too. "Nothing ran last night" and "it ran and found nothing" must never look
the same here.
</p>
</div>
</Card>
</div>
)
}
// ── small parts ──────────────────────────────────────────────────
function FreshnessRow({
label,
days,
validFor,
warnAbove,
}: {
label: string
days: number | null
validFor: string | null
warnAbove: number
}) {
const stale = days !== null && days > warnAbove
return (
<div className="flex items-baseline gap-2 mt-1.5">
<span className="text-[10.5px] text-slate-400 w-[52px]">{label}</span>
{days === null ? (
<span className="pill pill-bad">no data</span>
) : (
<>
<span className={`text-[13px] font-bold ${stale ? 'text-risk-red' : 'text-green'}`}>{days}d</span>
<span className="text-[10px] text-slate-400">{validFor}</span>
</>
)}
</div>
)
}
function AgeChip({ validFor, warnAbove }: { validFor: string; warnAbove: number }) {
const days = Math.max(0, Math.round((Date.now() - new Date(validFor).getTime()) / 86_400_000))
return (
<span className={`pill ${days > warnAbove ? 'pill-bad' : 'pill-ok'}`}>{days}d</span>
)
}
function Delta({
value,
suffix,
goodWhenPositive,
}: {
value: number | null
suffix: string
goodWhenPositive?: boolean
}) {
if (value === null) return <span className="text-slate-400">—</span>
const bad = goodWhenPositive ? value < -20 : value > 1.5
return (
<span className={bad ? 'text-risk-red font-semibold' : 'text-slate-600'}>
{value > 0 ? '+' : ''}
{value}
{suffix}
</span>
)
}
@@ -0,0 +1,339 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { officerSession } from '../../lib/api'
import type { Enterprise } from '../../lib/api'
import {
ActionChip,
Card,
DriverBar,
Empty,
GhostBtn,
PrimaryBtn,
RiskBadge,
ScoreDial,
Sparkline,
StatusPill,
} from '../../components/ui/bits'
import { ForecastFan } from '../../components/charts/ForecastFan'
import { SubScoreRadar } from '../../components/charts/SubScoreRadar'
import {
DRIVER_META,
SECTORS,
breachPoint,
findEnterprise,
forecastFor,
reasonCodesFor,
stressScoreFor,
subScoresFor,
tripWiresFor,
} from '../../lib/demo/enterprises'
import { inr, inrCompact, monthRef } from '../../lib/demo/seed'
import { loadLedger } from '../../lib/ledgerStore'
import { CATEGORIES } from '../../lib/ledgerStore'
import { useVisits } from '../../lib/visitStore'
import { ScheduleVisitModal } from './ScheduleVisitModal'
type Tab = 'OVERVIEW' | 'FORECAST' | 'DRIVERS' | 'LEDGER' | 'VISITS'
const TABS: { key: Tab; label: string }[] = [
{ key: 'OVERVIEW', label: 'Overview' },
{ key: 'FORECAST', label: 'Forecast' },
{ key: 'DRIVERS', label: 'Drivers' },
{ key: 'LEDGER', label: 'Ledger' },
{ key: 'VISITS', label: 'Visits' },
]
/** Everything the officer needs about one enterprise, in five tabs. */
export function EnterpriseDetailPage() {
const { enterpriseId = '' } = useParams()
const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('OVERVIEW')
const [scheduling, setScheduling] = useState(false)
const e = findEnterprise(enterpriseId)
if (!e) return <Empty text={`No enterprise found for ${enterpriseId}.`} />
const points = forecastFor(e)
const breach = breachPoint(points)
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
return (
<div>
{/* ── header ─────────────────────────────────────────── */}
<div className="flex items-start gap-4 mb-4">
<button onClick={() => navigate(-1)} className="text-[11.5px] text-slate-400 hover:text-forest mt-1">
← Back
</button>
<div className="min-w-0">
<div className="flex items-center gap-2.5">
<h1 className="text-[19px] font-bold text-forest">{e.name}</h1>
<RiskBadge band={e.band} />
</div>
<div className="text-[11.5px] text-slate-500">
{e.enterpriseId} · {SECTORS[e.sector].label} · {e.block} block, {e.district} district ·{' '}
{e.vintageMonths} months on book
</div>
</div>
<div className="ml-auto flex gap-2">
<PrimaryBtn onClick={() => setScheduling(true)}>Schedule visit</PrimaryBtn>
<GhostBtn onClick={() => setTab('FORECAST')}>Full forecast</GhostBtn>
</div>
</div>
{breach && (
<div className="bg-risk-soft border border-risk-red/20 rounded-lg px-4 py-2.5 mb-4 flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-risk-red text-white grid place-items-center text-[12px] font-bold shrink-0">
!
</span>
<p className="text-[12px] text-slate-700">
Downside case fails to cover obligations from <b>{breachRef?.fullEn}</b> — projected gap{' '}
<b className="text-risk-red">{inr(breach.obligation - breach.p10)}</b> at P10.
</p>
</div>
)}
<div className="flex gap-1.5 mb-4">
{TABS.map((t) => (
<ActionChip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
{t.label}
</ActionChip>
))}
</div>
{tab === 'OVERVIEW' && <OverviewTab e={e} />}
{tab === 'FORECAST' && <ForecastTab e={e} />}
{tab === 'DRIVERS' && <DriversTab e={e} />}
{tab === 'LEDGER' && <LedgerTab e={e} />}
{tab === 'VISITS' && <VisitsTab e={e} />}
{scheduling && <ScheduleVisitModal enterprise={e} onClose={() => setScheduling(false)} />}
</div>
)
}
function OverviewTab({ e }: { e: Enterprise }) {
const score = stressScoreFor(e)
const reasons = reasonCodesFor(e)
return (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Composite Stress Score" badge={score.modelVersion}>
<div className="p-4 grid place-items-center">
<ScoreDial score={e.score} band={e.band} size={132} />
<div className="mt-3 w-full">
<Sparkline values={e.trend} band={e.band} width={240} height={44} />
<div className="text-[10px] text-slate-400 text-center mt-1">6-month score history</div>
</div>
</div>
</Card>
<Card title="Institutional record">
<dl className="p-4 space-y-2.5 text-[12px]">
{[
['Loan outstanding', inr(e.loanOutstanding)],
['Monthly instalment', inr(e.monthlyInstalment)],
['Sector pack', SECTORS[e.sector].label],
['Block / district', `${e.block} / ${e.district}`],
['Owners on record', String(e.ownerCount)],
['Vintage', `${e.vintageMonths} months`],
].map(([k, v]) => (
<div key={k} className="flex justify-between gap-3">
<dt className="text-slate-500">{k}</dt>
<dd className="font-semibold text-slate-700">{v}</dd>
</div>
))}
</dl>
</Card>
<Card title="Why this score" badge="SHAP">
<div className="p-4 space-y-3">
{reasons.map((r) => (
<div key={r.driver} className="border-l-[3px] border-amber pl-2.5">
<div className="text-[11px] font-semibold text-slate-700">
{r.driver} <span className="font-mono text-slate-400">· {r.weight}</span>
</div>
<p className="text-[11.5px] text-slate-600 leading-snug">{r.sentenceEn}</p>
<p className="text-[11px] text-slate-400 leading-snug">{r.sentenceHi}</p>
</div>
))}
</div>
</Card>
</div>
)
}
function ForecastTab({ e }: { e: Enterprise }) {
const points = forecastFor(e)
return (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Net cash position — 6-month quantile forecast" className="lg:col-span-2">
<div className="p-4">
<ForecastFan points={points} height={280} />
</div>
</Card>
<Card title="Month by month">
<div className="overflow-x-auto">
<table className="ul-table">
<thead>
<tr>
<th>Month</th>
<th className="text-right">P10</th>
<th className="text-right">P50</th>
<th className="text-right">Due</th>
</tr>
</thead>
<tbody>
{points.map((p) => {
const short = p.p10 < p.obligation
return (
<tr key={p.month} className={short ? '!bg-risk-soft' : ''}>
<td className="font-semibold text-slate-700">{p.label}</td>
<td className={`text-right ${short ? 'text-risk-red font-bold' : 'text-slate-600'}`}>
{inrCompact(p.p10)}
</td>
<td className="text-right text-slate-600">{inrCompact(p.p50)}</td>
<td className="text-right text-slate-500">{inrCompact(p.obligation)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<p className="px-3.5 py-2 text-[10.5px] text-slate-400 border-t border-slate-100">
Rows in red are months where the downside case does not cover what is due.
</p>
</Card>
</div>
)
}
function DriversTab({ e }: { e: Enterprise }) {
const sub = subScoresFor(e)
const wires = tripWiresFor(e)
return (
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Five risk families" badge={`${SECTORS[e.sector].label} weights`}>
<div className="p-4">
<SubScoreRadar sub={sub} band={e.band} />
<div className="space-y-2 mt-4">
{DRIVER_META.map((d) => (
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
))}
</div>
</div>
</Card>
<Card title="Deterministic trip-wires" badge="rule engine">
<div className="overflow-x-auto">
<table className="ul-table">
<thead>
<tr>
<th>Code</th>
<th>Rule</th>
<th>Threshold</th>
<th>State</th>
</tr>
</thead>
<tbody>
{wires.map((w) => (
<tr key={w.code}>
<td className="font-mono text-[10.5px] text-slate-500">{w.code}</td>
<td>
<span className="block font-semibold text-slate-700">{w.label}</span>
<span className="block text-[10.5px] text-slate-400">{w.detail}</span>
</td>
<td className="font-mono text-[10.5px] text-slate-500">{w.threshold}</td>
<td>
<span className={`pill ${w.fired ? 'pill-bad' : 'pill-mut'}`}>{w.fired ? 'Fired' : 'Clear'}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="px-3.5 py-2 text-[10.5px] text-slate-400 border-t border-slate-100">
Trip-wires fire independently of the model, so every alert stays auditable.
</p>
</Card>
</div>
)
}
function LedgerTab({ e }: { e: Enterprise }) {
const entries = loadLedger(e.enterpriseId).slice(0, 40)
const label = (kind: string, code: string) =>
CATEGORIES[kind as keyof typeof CATEGORIES]?.find((c) => c.code === code)?.en ?? code
return (
<Card title="Three-tap ledger — last 40 entries" badge="owner-reported">
<div className="overflow-x-auto max-h-[520px] overflow-y-auto">
<table className="ul-table">
<thead className="sticky top-0">
<tr>
<th>Date</th>
<th>Type</th>
<th>Category</th>
<th className="text-right">Amount</th>
<th>Sync</th>
</tr>
</thead>
<tbody>
{entries.map((l) => (
<tr key={l.id}>
<td className="text-slate-600">{l.entryDate}</td>
<td>
<span className={`pill ${l.kind === 'IN' ? 'pill-ok' : l.kind === 'OUT' ? 'pill-bad' : 'pill-info'}`}>
{l.kind === 'IN' ? 'Money in' : l.kind === 'OUT' ? 'Money out' : 'Stock'}
</span>
</td>
<td className="text-slate-600">{label(l.kind, l.category)}</td>
<td className="text-right font-semibold text-slate-700">{inr(l.amount)}</td>
<td>
<StatusPill status={l.syncState} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{!entries.length && <Empty text="This enterprise has not recorded any entries yet." />}
</Card>
)
}
function VisitsTab({ e }: { e: Enterprise }) {
const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer')
const mine = visits.filter((v) => v.enterpriseId === e.enterpriseId)
return (
<Card title="Field visits and interventions">
<div className="overflow-x-auto">
<table className="ul-table">
<thead>
<tr>
<th>Visit</th>
<th>Action</th>
<th>Due</th>
<th>State</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
{mine.map((v) => (
<tr key={v.visitId}>
<td className="font-mono text-[10.5px] text-slate-500">{v.visitId}</td>
<td className="text-slate-700 whitespace-normal">{v.action}</td>
<td className="text-slate-600">{v.dueDate}</td>
<td>
<StatusPill status={v.state} />
</td>
<td className="text-slate-500 whitespace-normal max-w-[320px]">{v.outcome ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
{!mine.length && <Empty text="No visit scheduled for this enterprise yet." />}
</Card>
)
}
+152
View File
@@ -0,0 +1,152 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { get } from '../../lib/api'
import type { Band, Enterprise, Sector } from '../../lib/api'
import { ActionChip, Empty, GhostBtn, RiskBadge, Sparkline, inputCls } from '../../components/ui/bits'
import { SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises'
import { scopedEnterprises } from '../../lib/demo/ops'
import { inrCompact } from '../../lib/demo/seed'
import { exportCsv } from '../../lib/csv'
import { useScope } from './officerScope'
const BAND_FILTERS: { key: Band | 'ALL'; label: string }[] = [
{ key: 'ALL', label: 'All bands' },
{ key: 'RED', label: 'High risk' },
{ key: 'WATCH', label: 'Watch' },
{ key: 'STANDARD', label: 'Standard' },
]
/** The full enterprise register — searchable and filterable, feeding the detail page. */
export function EnterprisesPage() {
const navigate = useNavigate()
const { block } = useScope()
const [q, setQ] = useState('')
const [band, setBand] = useState<Band | 'ALL'>('ALL')
const [sector, setSector] = useState<Sector | 'ALL'>('ALL')
const { data } = useQuery({
queryKey: ['enterprises', block],
queryFn: () => get<Enterprise[]>('/officer/enterprises', { block }),
})
const all = data ?? scopedEnterprises(block)
const rows = useMemo(() => {
const needle = q.trim().toLowerCase()
return all.filter(
(e) =>
(band === 'ALL' || e.band === band) &&
(sector === 'ALL' || e.sector === sector) &&
(!needle || e.name.toLowerCase().includes(needle) || e.enterpriseId.toLowerCase().includes(needle)),
)
}, [all, band, sector, q])
function handleExport() {
exportCsv(
`udyamlinc-enterprises-${block}.csv`,
['Enterprise ID', 'Name', 'Sector', 'Block', 'District', 'Score', 'Band', 'Vintage (months)', 'Outstanding', 'Instalment'],
rows.map((e) => [
e.enterpriseId,
e.name,
SECTORS[e.sector].label,
e.block,
e.district,
e.score,
e.band,
e.vintageMonths,
e.loanOutstanding,
e.monthlyInstalment,
]),
)
}
return (
<div>
<div className="flex flex-wrap items-center gap-2 mb-3">
<input
className={`${inputCls} max-w-[260px]`}
placeholder="Search enterprise or ID…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<select
className={`${inputCls} max-w-[170px]`}
value={sector}
onChange={(e) => setSector(e.target.value as Sector | 'ALL')}
>
<option value="ALL">All sectors</option>
{SECTOR_LIST.map((s) => (
<option key={s} value={s}>
{SECTORS[s].label}
</option>
))}
</select>
<div className="flex gap-1.5">
{BAND_FILTERS.map((b) => (
<ActionChip key={b.key} active={band === b.key} onClick={() => setBand(b.key)}>
{b.label}
</ActionChip>
))}
</div>
<span className="text-[11px] text-slate-500 ml-1">{rows.length} enterprises</span>
<GhostBtn className="ml-auto" onClick={handleExport}>
Export CSV
</GhostBtn>
</div>
<div className="bg-white border border-slate-200 rounded-[11px] overflow-hidden">
<div className="overflow-x-auto">
<table className="ul-table">
<thead>
<tr>
<th>Enterprise</th>
<th>Sector</th>
<th>Block</th>
<th className="text-right">Vintage</th>
<th className="text-right">Outstanding</th>
<th className="text-right">Instalment</th>
<th className="text-right">Score</th>
<th>Status</th>
<th>Trend</th>
</tr>
</thead>
<tbody>
{rows.map((e) => (
<tr
key={e.enterpriseId}
className="cursor-pointer"
onClick={() => navigate(`/officer/enterprises/${e.enterpriseId}`)}
>
<td>
<span className="block font-semibold text-slate-700">{e.name}</span>
<span className="block font-mono text-[10px] text-slate-400">{e.enterpriseId}</span>
</td>
<td className="text-slate-600">{SECTORS[e.sector].short}</td>
<td className="text-slate-600">{e.block}</td>
<td className="text-right text-slate-600">{e.vintageMonths} mo</td>
<td className="text-right text-slate-600">{inrCompact(e.loanOutstanding)}</td>
<td className="text-right text-slate-600">{inrCompact(e.monthlyInstalment)}</td>
<td
className={`text-right font-bold ${
e.band === 'RED' ? 'text-risk-red' : e.band === 'WATCH' ? 'text-amber' : 'text-green'
}`}
>
{e.score}
</td>
<td>
<RiskBadge band={e.band} />
</td>
<td>
<Sparkline values={e.trend} band={e.band} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{!rows.length && <Empty text="No enterprise matches this filter." />}
</div>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { Card, KpiTile } from '../../components/ui/bits'
import { ForecastFan } from '../../components/charts/ForecastFan'
import { BandMigrationBar } from '../../components/charts/BandMigrationBar'
import { bandMigration, portfolioForecast, sectorRollup } from '../../lib/demo/ops'
import { inrCompact } from '../../lib/demo/seed'
import { useScope } from './officerScope'
/**
* Portfolio-level view of the same forecast the enterprise pages show one at a
* time: the h = 1…6 aggregate, how each sector pack is carrying risk, and how
* the book migrated between watchlist bands over the last six months.
*/
export function ForecastsPage() {
const { block } = useScope()
const aggregate = portfolioForecast(block)
const sectors = sectorRollup(block)
const migration = bandMigration(block)
const worst = aggregate.reduce((a, p) => (p.p10 - p.obligation < a.p10 - a.obligation ? p : a), aggregate[0])
const covered = aggregate.filter((p) => p.p10 >= p.obligation).length
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<KpiTile label="Horizon" value="6 months" delta="P10 / P50 / P90" />
<KpiTile label="Months covered at P10" value={`${covered} / 6`} tone={covered >= 5 ? 'good' : 'watch'} />
<KpiTile label="Tightest month" value={worst.label} delta={`P10 ${inrCompact(worst.p10)} vs ${inrCompact(worst.obligation)} due`} tone="red" />
<KpiTile label="Sector packs live" value={sectors.length} delta="each modelled separately" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Portfolio net cash position — mean of the book">
<div className="p-4">
<ForecastFan points={aggregate} height={260} />
</div>
</Card>
<Card title="Band migration — last 6 months">
<div className="p-4">
<BandMigrationBar rows={migration} height={260} />
</div>
</Card>
</div>
<Card title="Sector risk packs" badge="dairy · poultry · food proc. · handicrafts · rural retail">
<div className="overflow-x-auto">
<table className="ul-table">
<thead>
<tr>
<th>Sector pack</th>
<th className="text-right">Enterprises</th>
<th className="text-right">High risk</th>
<th className="text-right">Watch</th>
<th className="text-right">Standard</th>
<th className="text-right">Avg score</th>
<th>Mix</th>
</tr>
</thead>
<tbody>
{sectors.map((s) => (
<tr key={s.sector}>
<td className="font-semibold text-slate-700">{s.label}</td>
<td className="text-right text-slate-600">{s.count}</td>
<td className="text-right text-risk-red font-semibold">{s.red}</td>
<td className="text-right text-amber font-semibold">{s.watch}</td>
<td className="text-right text-green font-semibold">{s.standard}</td>
<td className="text-right font-bold text-slate-700">{s.avgScore}</td>
<td className="w-[180px]">
<span className="flex h-[7px] rounded-full overflow-hidden bg-slate-100">
<span className="bg-risk-red" style={{ width: `${(s.red / Math.max(1, s.count)) * 100}%` }} />
<span className="bg-amber" style={{ width: `${(s.watch / Math.max(1, s.count)) * 100}%` }} />
<span className="bg-green" style={{ width: `${(s.standard / Math.max(1, s.count)) * 100}%` }} />
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)
}
+248
View File
@@ -0,0 +1,248 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { get } from '../../lib/api'
import type { Enterprise, PortfolioSummary } from '../../lib/api'
import {
DriverBar,
GhostBtn,
KpiTile,
PrimaryBtn,
RiskBadge,
ScoreDial,
Sparkline,
} from '../../components/ui/bits'
import {
DRIVER_META,
SECTORS,
breachPoint,
forecastFor,
reasonCodesFor,
subScoresFor,
} from '../../lib/demo/enterprises'
import { portfolioSummary } from '../../lib/demo/ops'
import { inr, monthRef } from '../../lib/demo/seed'
import { exportCsv } from '../../lib/csv'
import { useScope } from './officerScope'
import { ScheduleVisitModal } from './ScheduleVisitModal'
/**
* The console's home screen and the deck's headline wireframe: KPI tiles, the
* "Enterprises by risk" register, and a risk detail panel that explains the
* selected enterprise in the officer's own language.
*/
export function OfficerDashboard() {
const navigate = useNavigate()
const { block, month } = useScope()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [scheduling, setScheduling] = useState<Enterprise | null>(null)
const { data } = useQuery({
queryKey: ['portfolio', block, month],
queryFn: () => get<PortfolioSummary>('/officer/portfolio', { block, month }),
})
// Demo fallback: with the backend down, the screen still shows the full book.
const summary = data ?? portfolioSummary(block, month)
const rows = summary.rows
const selected = useMemo(
() => rows.find((e) => e.enterpriseId === selectedId) ?? rows[0],
[rows, selectedId],
)
function handleExport() {
exportCsv(
`udyamlinc-portfolio-${block}-${month}.csv`,
['Enterprise ID', 'Name', 'Sector', 'Block', 'Score', 'Band', 'Loan outstanding', 'Instalment'],
rows.map((e) => [
e.enterpriseId,
e.name,
SECTORS[e.sector].label,
e.block,
e.score,
e.band,
e.loanOutstanding,
e.monthlyInstalment,
]),
)
}
return (
<div>
{/* ── KPI tiles ─────────────────────────────────────── */}
<div className="flex flex-wrap gap-3 mb-4">
<KpiTile
label="Total enterprises"
value={summary.total}
delta={`${summary.totalDelta >= 0 ? '+' : ''}${summary.totalDelta} this month`}
/>
<KpiTile
label="High risk"
value={summary.red}
delta={`${summary.redDelta >= 0 ? '+' : ''}${summary.redDelta} since last week`}
tone="red"
/>
<KpiTile
label="Watch"
value={summary.watch}
delta={`${summary.watchDelta >= 0 ? '+' : ''}${summary.watchDelta} since last week`}
tone="watch"
/>
<KpiTile
label="Standard"
value={summary.standard}
delta={summary.standardDelta === 0 ? 'stable' : `${summary.standardDelta > 0 ? '+' : ''}${summary.standardDelta} since last week`}
tone="good"
/>
</div>
<div className="flex gap-4 items-start flex-col xl:flex-row">
{/* ── enterprises by risk ─────────────────────────── */}
<div className="flex-1 min-w-0 bg-white border border-slate-200 rounded-[11px] overflow-hidden">
<div className="px-4 py-2.5 border-b border-slate-200 flex items-center">
<h3 className="text-[12.5px] font-bold text-forest">Enterprises by risk</h3>
<span className="text-[10px] text-slate-400 ml-2">updated {summary.updatedMinutesAgo} minutes ago</span>
<GhostBtn className="ml-auto" onClick={handleExport}>
Export CSV
</GhostBtn>
</div>
<div className="overflow-x-auto max-h-[560px] overflow-y-auto">
<table className="ul-table">
<thead className="sticky top-0 z-10">
<tr>
<th>Enterprise</th>
<th>Sector</th>
<th>Block</th>
<th className="text-right">Score</th>
<th>Status</th>
<th>6-month trend</th>
<th />
</tr>
</thead>
<tbody>
{rows.slice(0, 40).map((e) => (
<tr
key={e.enterpriseId}
onClick={() => setSelectedId(e.enterpriseId)}
className={`cursor-pointer ${e.enterpriseId === selected?.enterpriseId ? '!bg-green-soft' : ''}`}
>
<td>
<div className="flex items-center gap-2.5">
<span
className={`w-6 h-6 rounded-full grid place-items-center text-[10px] font-bold ${
e.band === 'RED' ? 'band-red' : e.band === 'WATCH' ? 'band-watch' : 'band-standard'
}`}
>
{e.name[0]}
</span>
<span className="leading-tight">
<span className="block font-semibold text-slate-700">{e.name}</span>
<span className="block font-mono text-[10px] text-slate-400">{e.enterpriseId}</span>
</span>
</div>
</td>
<td className="text-slate-600">{SECTORS[e.sector].short}</td>
<td className="text-slate-600">{e.block}</td>
<td
className={`text-right font-bold ${
e.band === 'RED' ? 'text-risk-red' : e.band === 'WATCH' ? 'text-amber' : 'text-green'
}`}
>
{e.score}
</td>
<td>
<RiskBadge band={e.band} />
</td>
<td>
<Sparkline values={e.trend} band={e.band} />
</td>
<td className="text-right">
<button
onClick={(ev) => {
ev.stopPropagation()
navigate(`/officer/enterprises/${e.enterpriseId}`)
}}
className="text-slate-400 hover:text-forest px-1"
aria-label={`Open ${e.name}`}
>
⋯
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{rows.length > 40 && (
<div className="px-4 py-2 border-t border-slate-100 text-[10.5px] text-slate-400">
Showing the 40 highest-risk of {rows.length} — the full register is under Enterprises.
</div>
)}
</div>
{/* ── risk detail ─────────────────────────────────── */}
{selected && <RiskDetail enterprise={selected} onSchedule={() => setScheduling(selected)} />}
</div>
{scheduling && <ScheduleVisitModal enterprise={scheduling} onClose={() => setScheduling(null)} />}
</div>
)
}
/** Right-hand panel: score dial, top drivers, the reason sentence and two actions. */
function RiskDetail({ enterprise, onSchedule }: { enterprise: Enterprise; onSchedule: () => void }) {
const navigate = useNavigate()
const sub = subScoresFor(enterprise)
const reason = reasonCodesFor(enterprise)[0]
const points = forecastFor(enterprise)
const breach = breachPoint(points)
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
return (
<div className="w-full xl:w-[320px] shrink-0 bg-white border border-slate-200 rounded-[11px] overflow-hidden">
<div className="px-4 py-2.5 border-b border-slate-200">
<h3 className="text-[12.5px] font-bold text-forest">Risk detail</h3>
<div className="text-[10.5px] text-slate-500">
{enterprise.name} · {enterprise.enterpriseId}
</div>
</div>
<div className="p-4">
<div className="grid place-items-center mb-4">
<ScoreDial score={enterprise.score} band={enterprise.band} size={116} />
</div>
<div className="font-mono text-[9.5px] tracking-[0.12em] text-slate-400 mb-2">TOP DRIVERS</div>
<div className="space-y-2 mb-4">
{DRIVER_META.map((d) => (
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
))}
</div>
<div className="bg-risk-soft border border-risk-red/15 rounded-lg px-3 py-2.5 mb-4">
<p className="text-[11.5px] text-slate-700 leading-relaxed">
{reason.sentenceEn}
{breachRef && ` Shortfall expected by ${breachRef.fullEn}.`}
</p>
{breach && (
<p className="text-[10.5px] text-risk-red font-semibold mt-1.5">
Projected gap {inr(breach.obligation - breach.p10)} at P10
</p>
)}
</div>
<div className="flex gap-2">
<PrimaryBtn className="flex-1" onClick={onSchedule}>
Schedule visit
</PrimaryBtn>
<GhostBtn className="flex-1" onClick={() => navigate(`/officer/enterprises/${enterprise.enterpriseId}`)}>
Full forecast
</GhostBtn>
</div>
</div>
</div>
)
}
+220
View File
@@ -0,0 +1,220 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { clearSessions, get, officerSession } from '../../lib/api'
import type { Freshness } from '../../lib/api'
import { demoFreshness, worstStaleness } from '../../lib/demo/feeds'
import { UdyamMark } from '../../components/ui/bits'
import { BLOCKS } from '../../lib/demo/enterprises'
import { ALL_BLOCKS, alertsFor, weeklyVisitProgress } from '../../lib/demo/ops'
import { monthRef, monthWindow } from '../../lib/demo/seed'
import { OfficerScopeProvider, useScope } from './officerScope'
/*
* Field officer console shell — dark forest sidebar on the left, identity bar
* across the top, work area on the right. This is the layout on the deck's
* wireframe slide; it deliberately differs from BharatLinc's horizontal MegaNav
* because the console is navigated by module, not by tab.
*/
const NAV: { label: string; to: string; end?: boolean }[] = [
{ label: 'Dashboard', to: '/officer', end: true },
{ label: 'Enterprises', to: '/officer/enterprises' },
{ label: 'Risk alerts', to: '/officer/alerts' },
{ label: 'Forecasts', to: '/officer/forecasts' },
{ label: 'Field visits', to: '/officer/visits' },
{ label: 'Trip-wires', to: '/officer/rules' },
{ label: 'Data feeds', to: '/officer/feeds' },
{ label: 'Reports', to: '/officer/reports' },
{ label: 'Settings', to: '/officer/settings' },
]
/**
* Data age, in the chrome rather than on a screen someone has to go and find.
*
* <p>A score computed from a three-week-old mandi price looks exactly like one
* computed this morning, so the age travels with every screen. Amber past ten
* days, which is the tolerance the price-derived features carry.
*/
function FreshnessChip() {
const { data, isError } = useQuery({
queryKey: ['freshness'],
queryFn: () => get<Freshness[]>('/admin/ingestion/freshness'),
})
const rows = data ?? demoFreshness()
const worst = worstStaleness(rows)
const stale = worst > 10
return (
<NavLink
to="/officer/feeds"
className={`pill ${stale ? 'pill-bad' : 'pill-ok'} !text-[10px]`}
title={
isError
? 'Showing demo freshness — the backend is not reachable'
: rows.map((r) => `${r.districtName}: mandi ${r.marketStalenessDays ?? '—'}d`).join(' · ')
}
>
Data {worst}d old
</NavLink>
)
}
export function OfficerShell() {
return (
<OfficerScopeProvider>
<ShellBody />
</OfficerScopeProvider>
)
}
function ShellBody() {
const navigate = useNavigate()
const session = officerSession()
const { block, month, setBlock, setMonth } = useScope()
const openAlerts = alertsFor(block).filter((a) => a.state === 'OPEN').length
const progress = weeklyVisitProgress(block)
const months = monthWindow(4, -3).reverse() // current month first, three back
const initials = (session?.fullName ?? 'Field Officer')
.split(' ')
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase()
function signOut() {
clearSessions()
navigate('/login', { replace: true })
}
return (
<div className="min-h-screen flex bg-[#f4f7f5]">
{/* ── sidebar ─────────────────────────────────────────── */}
<aside className="w-[212px] shrink-0 bg-forest-deep text-white flex flex-col sticky top-0 h-screen">
<div className="px-4 py-4 flex items-center gap-2.5 border-b border-white/10">
<UdyamMark size={24} onDark />
<div className="leading-tight">
<div className="text-[14px] font-bold">
Udyam<span className="text-amber-hi">Linc</span>
</div>
<div className="text-[9px] text-[#8fae9c] font-mono tracking-[0.1em]">FIELD OFFICER CONSOLE</div>
</div>
</div>
<nav className="flex-1 py-3 overflow-y-auto">
{NAV.map((n) => (
<NavLink
key={n.to}
to={n.to}
end={n.end}
className={({ isActive }) =>
[
'flex items-center gap-2.5 px-4 py-[9px] text-[12px] font-medium border-l-[3px] transition-colors',
isActive
? 'bg-white/[0.08] text-white border-amber font-semibold'
: 'text-[#a9c4b5] border-transparent hover:bg-white/[0.05] hover:text-white',
].join(' ')
}
>
<span className="w-2.5 h-2.5 rounded-[3px] bg-current opacity-70" />
{n.label}
{n.to === '/officer/alerts' && openAlerts > 0 && (
<span className="ml-auto bg-risk-red text-white text-[9.5px] font-bold px-1.5 rounded-full">
{openAlerts}
</span>
)}
</NavLink>
))}
</nav>
{/* weekly visit progress — the card pinned to the sidebar on the deck */}
<div className="m-3 rounded-lg bg-white/[0.07] border border-white/10 px-3 py-2.5">
<div className="text-[9.5px] text-[#8fae9c] font-mono tracking-[0.1em]">THIS WEEK</div>
<div className="text-[22px] font-bold leading-tight">
{progress.done} <span className="text-[13px] text-[#8fae9c]">/ {progress.target}</span>
</div>
<div className="text-[9.5px] text-[#8fae9c] mb-1.5">high-risk visits done</div>
<div className="h-[5px] rounded-full bg-white/15 overflow-hidden">
<div
className="h-full rounded-full bg-green-hi"
style={{ width: `${Math.min(100, (progress.done / progress.target) * 100)}%` }}
/>
</div>
</div>
</aside>
{/* ── work area ───────────────────────────────────────── */}
<div className="flex-1 min-w-0 flex flex-col">
<header className="bg-white border-b border-slate-200 px-6 py-2.5 flex items-center gap-3 sticky top-0 z-30">
<div className="min-w-0">
<div className="text-[13.5px] font-bold text-forest leading-tight">Portfolio overview</div>
<div className="text-[10.5px] text-slate-500 truncate">
{block === ALL_BLOCKS ? (session?.branch ?? 'All blocks') : `${block} block`} · {monthRef(new Date(`${month}-01T00:00:00`)).fullEn}
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<FreshnessChip />
<select
value={block}
onChange={(e) => setBlock(e.target.value)}
className="border border-slate-300 rounded-md px-2 py-1 text-[11.5px] bg-white outline-none focus:border-green"
aria-label="Block scope"
>
<option value={ALL_BLOCKS}>All blocks</option>
{BLOCKS.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
<select
value={month}
onChange={(e) => setMonth(e.target.value)}
className="border border-slate-300 rounded-md px-2 py-1 text-[11.5px] bg-white outline-none focus:border-green"
aria-label="Month scope"
>
{months.map((m) => (
<option key={m.key} value={m.key}>
{m.fullEn}
</option>
))}
</select>
<NavLink
to="/officer/alerts"
className="relative w-8 h-8 grid place-items-center rounded-full hover:bg-slate-100"
aria-label={`${openAlerts} open alerts`}
>
<span className="text-[14px]">🔔</span>
{openAlerts > 0 && <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-risk-red" />}
</NavLink>
<div className="flex items-center gap-2 pl-2 border-l border-slate-200">
<span className="w-7 h-7 rounded-full bg-forest text-white grid place-items-center text-[10.5px] font-bold">
{initials}
</span>
<div className="leading-tight">
<div className="text-[11.5px] font-semibold text-slate-700">{session?.fullName ?? 'Field Officer'}</div>
<div className="text-[9.5px] text-slate-400">Field Officer</div>
</div>
<button onClick={signOut} className="text-[10.5px] text-slate-400 hover:text-risk-red ml-1">
Sign out
</button>
</div>
</div>
</header>
<main className="flex-1 px-6 py-4 max-w-[1560px] w-full">
<Outlet />
</main>
<footer className="text-center text-[10px] text-slate-400 py-3 font-mono">
UdyamLinc · Predicting stress. Protecting enterprises. — Team LincFusion, LincPay Solutions Pvt. Ltd.
</footer>
</div>
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
import { Card, GhostBtn, KpiTile } from '../../components/ui/bits'
import { ModelComparisonBar } from '../../components/charts/ModelComparisonBar'
import { SECTORS } from '../../lib/demo/enterprises'
import { BENCHMARKS, alertsFor, scopedEnterprises, sectorRollup, visitsFor } from '../../lib/demo/ops'
import { exportCsv } from '../../lib/csv'
import { useScope } from './officerScope'
/**
* MIS exports and the supervisory pack. The model figures are the ones the deck
* publishes — measured on the simulated 2,400-enterprise dataset, not on live
* institutional data, and labelled as such.
*/
export function ReportsPage() {
const { block, month } = useScope()
const enterprises = scopedEnterprises(block)
const alerts = alertsFor(block)
const visits = visitsFor(block)
const REPORTS: { name: string; detail: string; run: () => void }[] = [
{
name: 'Portfolio register',
detail: 'Every enterprise with its band, score and obligations.',
run: () =>
exportCsv(
`udyam-portfolio-${block}-${month}.csv`,
['Enterprise ID', 'Name', 'Sector', 'Block', 'Score', 'Band', 'Outstanding', 'Instalment'],
enterprises.map((e) => [
e.enterpriseId,
e.name,
SECTORS[e.sector].label,
e.block,
e.score,
e.band,
e.loanOutstanding,
e.monthlyInstalment,
]),
),
},
{
name: 'Early warning register',
detail: 'Open and dispositioned alerts with reason codes and projected gaps.',
run: () =>
exportCsv(
`udyam-alerts-${block}-${month}.csv`,
['Alert ID', 'Enterprise', 'Band', 'Origin', 'Score', 'Shortfall', 'Breach month', 'State', 'Reason'],
alerts.map((a) => [
a.alertId,
a.enterpriseName,
a.band,
a.origin,
a.score,
a.shortfall,
a.breachMonth,
a.state,
a.reason.sentenceEn,
]),
),
},
{
name: 'Intervention log',
detail: 'Field visits, recommended actions, due dates and recorded outcomes.',
run: () =>
exportCsv(
`udyam-visits-${block}-${month}.csv`,
['Visit ID', 'Enterprise', 'Action', 'Block', 'Due', 'State', 'Outcome', 'Officer'],
visits.map((v) => [v.visitId, v.enterpriseName, v.action, v.block, v.dueDate, v.state, v.outcome ?? '', v.officer]),
),
},
{
name: 'Sector pack summary',
detail: 'Band mix and average score for each of the five packs.',
run: () =>
exportCsv(
`udyam-sectors-${block}-${month}.csv`,
['Sector', 'Enterprises', 'High risk', 'Watch', 'Standard', 'Average score'],
sectorRollup(block).map((s) => [s.label, s.count, s.red, s.watch, s.standard, s.avgScore]),
),
},
]
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<KpiTile label="AUC-ROC · 90-day stress" value={BENCHMARKS.aucRoc.toFixed(2)} delta="full signal set" tone="good" />
<KpiTile label="Median lead time" value={`${BENCHMARKS.medianLeadTimeMonths} mo`} delta="before first missed instalment" />
<KpiTile label="Alert precision · Red band" value={BENCHMARKS.alertPrecisionRed.toFixed(2)} delta="cost of a false positive is one visit" tone="watch" />
<KpiTile label="On-device scoring" value={`< ${BENCHMARKS.onDeviceLatencyMs} ms`} delta="zero connectivity" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Model comparison — 90-day stress detection (AUC-ROC)">
<div className="p-4">
<ModelComparisonBar />
<p className="text-[10.5px] text-slate-400 mt-2 leading-snug">
Measured on a simulated {BENCHMARKS.datasetSize.toLocaleString('en-IN')}-enterprise dataset across{' '}
{BENCHMARKS.sectorsCovered} sectors and {BENCHMARKS.districtsCovered} districts. Production calibration
will use institutional historical portfolios under a data-sharing agreement.
</p>
</div>
</Card>
<Card title="Validation method">
<ul className="p-4 space-y-2.5 text-[12px] text-slate-600">
{[
'Time-series split — no forward leakage across folds.',
'Backtested against simulated stress events.',
'Precision measured at the Red band, where the cost of a false positive is one visit.',
'Sector packs evaluated independently before pooling.',
].map((t) => (
<li key={t} className="flex gap-2.5">
<span className="w-1.5 h-1.5 rounded-full bg-green mt-1.5 shrink-0" />
{t}
</li>
))}
</ul>
</Card>
</div>
<Card title="MIS exports" badge={`${block === 'ALL' ? 'all blocks' : block} · ${month}`}>
<div className="overflow-x-auto">
<table className="ul-table">
<thead>
<tr>
<th>Report</th>
<th>Contents</th>
<th className="text-right">Rows</th>
<th />
</tr>
</thead>
<tbody>
{REPORTS.map((r, i) => (
<tr key={r.name}>
<td className="font-semibold text-slate-700">{r.name}</td>
<td className="whitespace-normal text-slate-600">{r.detail}</td>
<td className="text-right text-slate-500">
{[enterprises.length, alerts.length, visits.length, sectorRollup(block).length][i]}
</td>
<td className="text-right">
<GhostBtn onClick={r.run}>Download CSV</GhostBtn>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)
}
+184
View File
@@ -0,0 +1,184 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { get } from '../../lib/api'
import type { FeatureValueRow, RuleOutcome, RuleStatus, TripWireRule } from '../../lib/api'
import { Card, Empty } from '../../components/ui/bits'
import { DEMO_FEATURES, DEMO_RULES, demoFeatureValues, demoRuleOutcomes } from '../../lib/demo/feeds'
import { scopedEnterprises } from '../../lib/demo/ops'
import { useScope } from './officerScope'
/**
* The trip-wires, and why each one did or did not fire.
*
* <p>The rules are deliberately independent of the model: they read features and
* never the score, so an alert one of them raised still stands after the model
* has been retrained. What this screen adds is the part that is otherwise
* invisible — a rule that is quiet because the enterprise is fine looks
* identical to a rule that is quiet because its feed has been down for a
* fortnight, and only one of those is good news.
*/
export function RulesPage() {
const { block } = useScope()
const enterprises = scopedEnterprises(block)
const [enterpriseId, setEnterpriseId] = useState(enterprises[0]?.enterpriseId ?? 'MSME-4417')
const rulesQ = useQuery({ queryKey: ['rules'], queryFn: () => get<TripWireRule[]>('/admin/rules') })
const outcomesQ = useQuery({
queryKey: ['rule-outcomes', enterpriseId],
queryFn: () => get<RuleOutcome[]>(`/admin/scoring/explain/${enterpriseId}`),
})
const valuesQ = useQuery({
queryKey: ['feature-values', enterpriseId],
queryFn: () => get<FeatureValueRow[]>(`/admin/features/values/${enterpriseId}`),
})
const demo = rulesQ.isError || outcomesQ.isError || valuesQ.isError
const rules = rulesQ.data ?? DEMO_RULES
const outcomes = outcomesQ.data ?? demoRuleOutcomes(enterpriseId)
const values = valuesQ.data ?? demoFeatureValues(enterpriseId)
const byCode = new Map(outcomes.map((o) => [o.ruleCode, o]))
const catalogue = new Map(DEMO_FEATURES.map((d) => [d.code, d]))
const blocked = outcomes.filter((o) => o.status.startsWith('SKIPPED')).length
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div>
<h1 className="text-[15px] font-bold text-forest leading-tight">Trip-wires</h1>
<p className="text-[11px] text-slate-500">
Deterministic rules, evaluated against the feature snapshot and never against the score. Business teams
edit the thresholds; nobody deploys.
</p>
</div>
<div className="ml-auto flex items-center gap-2">
{demo && <span className="pill pill-pend">Demo data</span>}
<select
value={enterpriseId}
onChange={(e) => setEnterpriseId(e.target.value)}
className="border border-slate-300 rounded-md px-2 py-1 text-[11.5px] bg-white outline-none focus:border-green max-w-[280px]"
aria-label="Enterprise"
>
{enterprises.map((e) => (
<option key={e.enterpriseId} value={e.enterpriseId}>
{e.enterpriseId} · {e.name}
</option>
))}
</select>
</div>
</div>
{blocked > 0 && (
<div className="rounded-lg border border-amber/40 bg-amber/[0.07] px-3.5 py-2.5 text-[11.5px] text-slate-700">
<b>{blocked} of {outcomes.length} rules could not run</b> for this enterprise — a required feature is
missing or too old. That is the rules working as intended, not a fault: acting on stale data is worse than
not acting. The reason for each is in the table below.
</div>
)}
<Card title="Rules" badge={`evaluated for ${enterpriseId}`}>
<div className="p-4 overflow-x-auto">
{rules.length === 0 ? (
<Empty text="No trip-wire rules configured." />
) : (
<table className="ul-table w-full">
<thead>
<tr>
<th>Code</th>
<th>Rule</th>
<th>Expression</th>
<th>Severity</th>
<th className="text-right">Tolerance</th>
<th>Outcome</th>
<th>Why</th>
</tr>
</thead>
<tbody>
{rules.map((rule) => {
const outcome = byCode.get(rule.code)
return (
<tr key={rule.code}>
<td className="font-mono text-[11px]">{rule.code}</td>
<td>
<div className="font-medium text-slate-700">{rule.label}</div>
<div className="text-[10.5px] text-slate-400">{rule.labelHi}</div>
</td>
<td className="font-mono text-[10.5px] text-slate-600 max-w-[240px]">{rule.expression}</td>
<td>
<span className={`pill ${rule.severity === 'RED' ? 'pill-bad' : 'pill-pend'}`}>
{rule.severity}
</span>
</td>
<td className="text-right text-slate-500">{rule.maxStalenessDays}d</td>
<td>{outcome ? <OutcomePill status={outcome.status} /> : <span className="text-slate-400">—</span>}</td>
<td className="text-[10.5px] text-slate-500 max-w-[260px]">{outcome?.detail ?? '—'}</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</Card>
<Card title="Feature snapshot" badge="what the rules read">
<div className="p-4 overflow-x-auto">
<p className="text-[11.5px] text-slate-500 leading-snug mb-3">
A null value is a value: it records that we did not know, rather than that we never looked. The reason
column is why.
</p>
<table className="ul-table w-full">
<thead>
<tr>
<th>Feature</th>
<th>Family</th>
<th className="text-right">Value</th>
<th className="text-right">Age</th>
<th className="text-right">Tolerance</th>
<th>Missing reason</th>
</tr>
</thead>
<tbody>
{values.map((v) => {
const def = catalogue.get(v.featureCode)
const tolerance = def?.stalenessToleranceDays ?? null
const stale = tolerance !== null && v.stalenessDays !== null && v.stalenessDays > tolerance
return (
<tr key={v.featureCode}>
<td className="font-mono text-[11px]">{v.featureCode}</td>
<td className="text-slate-500 text-[11px]">{def?.family ?? '—'}</td>
<td className={`text-right font-semibold ${v.value === null ? 'text-slate-300' : ''}`}>
{v.value === null ? 'null' : v.value}
</td>
<td className={`text-right ${stale ? 'text-risk-red font-semibold' : 'text-slate-500'}`}>
{v.stalenessDays === null ? '—' : `${v.stalenessDays}d`}
</td>
<td className="text-right text-slate-400">{tolerance === null ? 'never stale' : `${tolerance}d`}</td>
<td className="text-[10.5px] text-slate-500">
{v.missingReason ? <span className="pill pill-pend">{v.missingReason}</span> : ''}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
</div>
)
}
const OUTCOME_LABEL: Record<RuleStatus, { text: string; cls: string }> = {
FIRED: { text: 'Fired', cls: 'pill-bad' },
NOT_FIRED: { text: 'Not fired', cls: 'pill-ok' },
SKIPPED_STALE: { text: 'Skipped · stale', cls: 'pill-pend' },
SKIPPED_MISSING: { text: 'Skipped · missing', cls: 'pill-pend' },
SKIPPED_SECTOR: { text: 'Other sector', cls: 'pill' },
ERROR: { text: 'Expression error', cls: 'pill-bad' },
}
function OutcomePill({ status }: { status: RuleStatus }) {
const meta = OUTCOME_LABEL[status] ?? { text: status, cls: 'pill' }
return <span className={`pill ${meta.cls}`}>{meta.text}</span>
}
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import type { Enterprise } from '../../lib/api'
import { officerSession } from '../../lib/api'
import { Field, GhostBtn, Modal, PrimaryBtn, inputCls } from '../../components/ui/bits'
import { PLAYBOOK } from '../../lib/demo/ops'
import { dayOffsetIso } from '../../lib/demo/seed'
import { scheduleVisit } from '../../lib/visitStore'
/**
* Schedule an intervention from the playbook. Actions are pre-filtered to the
* enterprise's band, so the officer picks from what the model actually
* recommends rather than a free-text field.
*/
export function ScheduleVisitModal({ enterprise, onClose }: { enterprise: Enterprise; onClose: () => void }) {
const navigate = useNavigate()
const options = PLAYBOOK.filter((p) => p.appliesTo === enterprise.band)
const [code, setCode] = useState(options[0]?.code ?? PLAYBOOK[0].code)
const [dueDate, setDueDate] = useState(dayOffsetIso(3))
function save() {
const action = PLAYBOOK.find((p) => p.code === code) ?? PLAYBOOK[0]
scheduleVisit({
enterpriseId: enterprise.enterpriseId,
enterpriseName: enterprise.name,
block: enterprise.block,
action: action.label,
actionHi: action.labelHi,
priority: enterprise.band === 'RED' ? 1 : 2,
dueDate,
officer: officerSession()?.fullName ?? 'Field Officer',
})
onClose()
navigate('/officer/visits')
}
return (
<Modal title={`Schedule visit — ${enterprise.name}`} onClose={onClose}>
<div className="text-[11.5px] text-slate-500 mb-4">
{enterprise.enterpriseId} · {enterprise.block} block · stress score{' '}
<span className="font-bold text-slate-700">{enterprise.score}</span>
</div>
<Field label="Recommended action" className="mb-3.5">
<select className={inputCls} value={code} onChange={(e) => setCode(e.target.value)}>
{(options.length ? options : PLAYBOOK).map((p) => (
<option key={p.code} value={p.code}>
{p.code} · {p.label}
</option>
))}
</select>
</Field>
<p className="text-[11px] text-slate-500 bg-slate-50 border border-slate-200 rounded-md px-3 py-2 mb-3.5">
{(PLAYBOOK.find((p) => p.code === code) ?? PLAYBOOK[0]).detail}
</p>
<Field label="Due date" className="mb-5" hint="Logged for supervisory review.">
<input type="date" className={inputCls} value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
</Field>
<div className="flex gap-2 justify-end">
<GhostBtn onClick={onClose}>Cancel</GhostBtn>
<PrimaryBtn onClick={save}>Schedule</PrimaryBtn>
</div>
</Modal>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { useState } from 'react'
import { API_BASE, officerSession } from '../../lib/api'
import type { Lang, Sector } from '../../lib/api'
import { Card, Field, GhostBtn, PrimaryBtn, inputCls } from '../../components/ui/bits'
import { BAND_THRESHOLD, SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises'
import { useLang } from '../../lib/i18n'
import { useOffline } from '../../lib/useOffline'
import { lastSyncedAt } from '../../lib/ledgerStore'
/**
* Console settings. Band thresholds are editable because the deck's claim is
* that they map to the *institution's own* watchlist categories rather than a
* fixed vendor scale — changing them here is a local preview, not a model change.
*/
export function SettingsPage() {
const session = officerSession()
const offline = useOffline()
const { lang, setLang } = useLang('en')
const [red, setRed] = useState(BAND_THRESHOLD.red)
const [watch, setWatch] = useState(BAND_THRESHOLD.watch)
const [packs, setPacks] = useState<Record<Sector, boolean>>(
() => Object.fromEntries(SECTOR_LIST.map((s) => [s, true])) as Record<Sector, boolean>,
)
const [saved, setSaved] = useState(false)
const synced = lastSyncedAt()
return (
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Band thresholds" badge="watchlist mapping">
<div className="p-4 space-y-3.5">
<p className="text-[11.5px] text-slate-500 leading-snug">
An enterprise escalates to <b>High risk</b> at or above the red threshold, and to <b>Watch</b> at or above
the watch threshold. Set these to match your own watchlist categories.
</p>
<div className="grid grid-cols-2 gap-3">
<Field label="High risk at or above">
<input
type="number"
min={watch + 1}
max={100}
className={inputCls}
value={red}
onChange={(e) => setRed(Number(e.target.value))}
/>
</Field>
<Field label="Watch at or above">
<input
type="number"
min={1}
max={red - 1}
className={inputCls}
value={watch}
onChange={(e) => setWatch(Number(e.target.value))}
/>
</Field>
</div>
<div className="flex h-2 rounded-full overflow-hidden bg-slate-100">
<span className="bg-green" style={{ width: `${watch}%` }} />
<span className="bg-amber" style={{ width: `${red - watch}%` }} />
<span className="bg-risk-red" style={{ width: `${100 - red}%` }} />
</div>
<div className="flex gap-2">
<PrimaryBtn
onClick={() => {
setSaved(true)
setTimeout(() => setSaved(false), 1800)
}}
>
Save thresholds
</PrimaryBtn>
<GhostBtn
onClick={() => {
setRed(BAND_THRESHOLD.red)
setWatch(BAND_THRESHOLD.watch)
}}
>
Reset
</GhostBtn>
{saved && <span className="text-[11.5px] text-green self-center">Saved for this session.</span>}
</div>
</div>
</Card>
<Card title="Sector risk packs">
<div className="p-4 space-y-2">
<p className="text-[11.5px] text-slate-500 leading-snug mb-2">
Each pack is modelled separately. Disabling one hides its enterprises from scoring runs.
</p>
{SECTOR_LIST.map((s) => (
<label key={s} className="flex items-center gap-2.5 py-1.5 border-b border-slate-100 last:border-0">
<input
type="checkbox"
checked={packs[s]}
onChange={(e) => setPacks({ ...packs, [s]: e.target.checked })}
className="accent-[#0b5d3b]"
/>
<span className="text-[12px] text-slate-700">{SECTORS[s].label}</span>
<span className="text-[11px] text-slate-400 ml-auto">{SECTORS[s].labelHi}</span>
</label>
))}
</div>
</Card>
<Card title="Session">
<dl className="p-4 space-y-2.5 text-[12px]">
{[
['Signed in as', session?.fullName ?? '—'],
['Username', session?.username ?? '—'],
['Role', session?.role ?? '—'],
['Scope', session?.branch ?? '—'],
['Blocks', (session?.blocks ?? []).join(', ') || '—'],
].map(([k, v]) => (
<div key={k} className="flex justify-between gap-3">
<dt className="text-slate-500">{k}</dt>
<dd className="font-semibold text-slate-700 text-right">{v}</dd>
</div>
))}
</dl>
</Card>
<Card title="Connectivity & language">
<div className="p-4 space-y-3.5">
<div className="flex items-center justify-between text-[12px]">
<span className="text-slate-500">API endpoint</span>
<span className="font-mono text-[10.5px] text-slate-600 truncate max-w-[220px]">{API_BASE}</span>
</div>
<div className="flex items-center justify-between text-[12px]">
<span className="text-slate-500">Connection</span>
<span className={`pill ${offline ? 'pill-bad' : 'pill-ok'}`}>{offline ? 'Offline' : 'Online'}</span>
</div>
<div className="flex items-center justify-between text-[12px]">
<span className="text-slate-500">Last ledger sync</span>
<span className="font-semibold text-slate-700">
{synced ? new Date(synced).toLocaleString() : 'Never'}
</span>
</div>
<Field label="Reason-code language" hint="Applies to the Hindi sentence shown under each reason code.">
<select className={inputCls} value={lang} onChange={(e) => setLang(e.target.value as Lang)}>
<option value="en">English</option>
<option value="hi">हिन्दी</option>
</select>
</Field>
</div>
</Card>
</div>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { officerSession } from '../../lib/api'
import type { FieldVisit, VisitState } from '../../lib/api'
import {
ActionChip,
Empty,
Field,
GhostBtn,
KpiTile,
Modal,
PrimaryBtn,
StatusPill,
inputCls,
} from '../../components/ui/bits'
import { todayIso } from '../../lib/demo/seed'
import { logOutcome, useVisits } from '../../lib/visitStore'
import { useScope } from './officerScope'
const FILTERS: { key: VisitState | 'ALL'; label: string }[] = [
{ key: 'ALL', label: 'All' },
{ key: 'PLANNED', label: 'Planned' },
{ key: 'OVERDUE', label: 'Overdue' },
{ key: 'DONE', label: 'Done' },
]
/**
* The field officer playbook: ranked interventions with due dates. Logging an
* outcome closes the loop the deck's process flow describes — the outcome and
* any override feed the next retraining cycle.
*/
export function VisitsPage() {
const navigate = useNavigate()
const { block } = useScope()
const officer = officerSession()?.fullName ?? 'Field Officer'
const { visits } = useVisits(block, officer)
const [filter, setFilter] = useState<VisitState | 'ALL'>('ALL')
const [logging, setLogging] = useState<FieldVisit | null>(null)
const rows = visits
.filter((v) => filter === 'ALL' || v.state === filter)
.sort((a, b) => a.priority - b.priority || a.dueDate.localeCompare(b.dueDate))
const done = visits.filter((v) => v.state === 'DONE').length
const overdue = visits.filter((v) => v.state === 'OVERDUE').length
const planned = visits.filter((v) => v.state === 'PLANNED').length
return (
<div>
<div className="flex flex-wrap gap-3 mb-4">
<KpiTile label="Interventions in queue" value={visits.length} />
<KpiTile label="Planned" value={planned} tone="watch" />
<KpiTile label="Overdue" value={overdue} tone="red" />
<KpiTile label="Completed" value={done} tone="good" />
</div>
<div className="flex flex-wrap items-center gap-1.5 mb-3">
{FILTERS.map((f) => (
<ActionChip key={f.key} active={filter === f.key} onClick={() => setFilter(f.key)}>
{f.label}
</ActionChip>
))}
<span className="text-[11px] text-slate-500 ml-1">{rows.length} shown</span>
</div>
<div className="bg-white border border-slate-200 rounded-[11px] overflow-hidden">
<div className="overflow-x-auto">
<table className="ul-table">
<thead>
<tr>
<th>Priority</th>
<th>Enterprise</th>
<th>Recommended action</th>
<th>Block</th>
<th>Due</th>
<th>State</th>
<th>Outcome</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((v) => (
<tr key={v.visitId}>
<td>
<span className={`pill ${v.priority === 1 ? 'pill-bad' : 'pill-pend'}`}>
{v.priority === 1 ? 'Act first' : 'Routine'}
</span>
</td>
<td>
<button
className="font-semibold text-forest hover:underline text-left"
onClick={() => navigate(`/officer/enterprises/${v.enterpriseId}`)}
>
{v.enterpriseName}
</button>
<span className="block font-mono text-[10px] text-slate-400">{v.enterpriseId}</span>
</td>
<td className="whitespace-normal max-w-[280px] text-slate-700">{v.action}</td>
<td className="text-slate-600">{v.block}</td>
<td className={v.state === 'OVERDUE' ? 'text-risk-red font-semibold' : 'text-slate-600'}>
{v.dueDate}
</td>
<td>
<StatusPill status={v.state} />
</td>
<td className="whitespace-normal max-w-[280px] text-slate-500">{v.outcome ?? '—'}</td>
<td className="text-right">
{v.state !== 'DONE' && <GhostBtn onClick={() => setLogging(v)}>Log outcome</GhostBtn>}
</td>
</tr>
))}
</tbody>
</table>
</div>
{!rows.length && <Empty text="No visits in this state." />}
</div>
{logging && <LogOutcomeModal visit={logging} onClose={() => setLogging(null)} />}
</div>
)
}
const PRESET_OUTCOMES = [
'Stock and receivables verified against the ledger.',
'Instalment date moved to align with the earning season.',
'Working-capital top-up recommended to the branch.',
'Owner unavailable — revisit required.',
'No action needed; figures explained by a one-off event.',
]
function LogOutcomeModal({ visit, onClose }: { visit: FieldVisit; onClose: () => void }) {
const [preset, setPreset] = useState(PRESET_OUTCOMES[0])
const [note, setNote] = useState('')
function save() {
logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset)
onClose()
}
return (
<Modal title={`Log outcome — ${visit.enterpriseName}`} onClose={onClose}>
<div className="text-[11.5px] text-slate-500 mb-4">
{visit.visitId} · {visit.action} · due {visit.dueDate}
</div>
<Field label="Outcome" className="mb-3.5">
<select className={inputCls} value={preset} onChange={(e) => setPreset(e.target.value)}>
{PRESET_OUTCOMES.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</Field>
<Field
label="Note (optional)"
className="mb-5"
hint={`Recorded against ${todayIso()} and fed into the next retraining cycle.`}
>
<textarea className={`${inputCls} min-h-[70px]`} value={note} onChange={(e) => setNote(e.target.value)} />
</Field>
<div className="flex gap-2 justify-end">
<GhostBtn onClick={onClose}>Cancel</GhostBtn>
<PrimaryBtn onClick={save}>Mark done</PrimaryBtn>
</div>
</Modal>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { createContext, useContext, useMemo, useState } from 'react'
import { ALL_BLOCKS } from '../../lib/demo/ops'
import { currentMonthKey } from '../../lib/demo/seed'
/**
* Console scope — the block and month every officer screen reads from, set once
* in the identity bar. Keeping it in context is what makes the dashboard, the
* alert queue and the visit playbook agree on what "this month" means.
*/
interface Scope {
block: string
month: string
setBlock: (b: string) => void
setMonth: (m: string) => void
}
const ScopeCtx = createContext<Scope>({
block: ALL_BLOCKS,
month: currentMonthKey(),
setBlock: () => {},
setMonth: () => {},
})
export function OfficerScopeProvider({ children }: { children: React.ReactNode }) {
const [block, setBlock] = useState(ALL_BLOCKS)
const [month, setMonth] = useState(currentMonthKey())
const value = useMemo(() => ({ block, month, setBlock, setMonth }), [block, month])
return <ScopeCtx.Provider value={value}>{children}</ScopeCtx.Provider>
}
export function useScope(): Scope {
return useContext(ScopeCtx)
}
+66
View File
@@ -0,0 +1,66 @@
@import "tailwindcss";
/*
* UdyamLinc design tokens
* Layout language: BharatLinc / CBSNexora (LincPay console family)
* Brand palette: UdyamLinc deck — forest #0B5D3B, green #1E7A46, amber #E8A020
*/
@theme {
--color-forest-deep: #06301f;
--color-forest: #0b5d3b;
--color-forest-mid: #0f6f47;
--color-green: #1e7a46;
--color-green-hi: #3fb871;
--color-green-soft: #eaf5ef;
--color-amber: #e8a020;
--color-amber-hi: #f2b544;
--color-amber-soft: #fdf4e3;
--color-risk-red: #c0392b;
--color-risk-soft: #fdeceb;
--color-ink: #17321f;
--color-tagline: #6b8477;
--font-sans: "Segoe UI", Roboto, -apple-system, "Helvetica Neue", Arial, sans-serif;
--font-mono: "Cascadia Mono", ui-monospace, "Roboto Mono", Consolas, monospace;
}
body {
@apply bg-[#f4f7f5] text-slate-900 font-sans text-[13px] antialiased;
}
/* Modern data table — same rhythm as BharatLinc's .bl-table */
.ul-table { @apply w-full border-collapse text-[12.5px]; }
.ul-table thead th {
@apply bg-slate-50 text-[9.5px] uppercase tracking-[0.06em] text-slate-500 text-left px-3.5 py-2 border-b border-slate-200 whitespace-nowrap font-semibold;
}
.ul-table tbody td { @apply px-3.5 py-2 border-b border-slate-100 whitespace-nowrap; }
.ul-table tbody tr:nth-child(even) { @apply bg-[#fafbfa]; }
.ul-table tbody tr:hover { @apply bg-[#f0f8f3]; }
/* status pill with leading dot */
.pill { @apply inline-flex items-center gap-1.5 text-[10.5px] font-semibold px-2.5 py-0.5 rounded-[10px]; }
.pill::before { content: ""; @apply w-1.5 h-1.5 rounded-full bg-current; }
.pill-ok { @apply bg-emerald-50 text-emerald-600; }
.pill-pend { @apply bg-amber-50 text-amber-600; }
.pill-info { @apply bg-blue-50 text-blue-600; }
.pill-bad { @apply bg-red-50 text-red-600; }
.pill-mut { @apply bg-slate-100 text-slate-500; }
/* Risk bands — the three states every screen speaks in */
.band-red { @apply bg-risk-soft text-risk-red; }
.band-watch { @apply bg-amber-soft text-amber; }
.band-standard { @apply bg-green-soft text-green; }
/* Card chrome shared by console + PWA */
.ul-card { @apply bg-white border border-slate-200 rounded-lg; }
/* Enterprise PWA: bottom tab bar sits above the home indicator */
.ul-safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
@keyframes ul-blink { 50% { opacity: 0.25; } }
.ul-blink { animation: ul-blink 1.2s infinite; }
@keyframes ul-roll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
.ul-tape { animation: ul-roll 28s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.ul-tape, .ul-blink { animation: none; }
}
+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 }
}
+20
View File
@@ -0,0 +1,20 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
// Offline-first: the service worker precaches the shell so the enterprise PWA
// opens with zero connectivity. Registered after load so it never blocks paint.
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
/* a failed registration must never break the app */
})
})
}
+311
View File
@@ -0,0 +1,311 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { errMsg, post } from '../lib/api'
import type { EnterpriseSession, OfficerSession } from '../lib/api'
import { UdyamMark, inputCls } from '../components/ui/bits'
import { ENTERPRISES, findEnterprise } from '../lib/demo/enterprises'
/*
* Two sign-in surfaces behind one screen, the way BharatLinc's LoginPage holds
* BOU / Switch / Portal: "Field Officer" issues an officer session, "Enterprise"
* issues an enterprise session for the offline PWA.
*/
type Mode = 'OFFICER' | 'ENTERPRISE'
/** Left-panel slides — the mission / vision / promise from the deck's team slide. */
const SLIDES = [
{
kicker: 'PREDICTIVE CASH-FLOW INTELLIGENCE',
title: 'Support before the shortfall',
body: 'Forecasting financial stress three to six months before the first missed instalment — from a three-tap ledger, UPI activity, mandi prices and climate signals.',
},
{
kicker: 'OUR MISSION',
title: 'Make financial health observable',
body: 'So that support arrives before stress becomes default. Every rural enterprise monitored with the foresight a large corporate borrower receives.',
},
{
kicker: 'OUR PROMISE',
title: 'Consent-first and explainable',
body: 'Auditable by design — no personally identifiable data, ever. Every alert carries the reason a field officer can act on.',
},
]
const STATS = [
{ value: '3–6', label: 'months of early warning' },
{ value: '5', label: 'rural trades covered' },
{ value: '15 sec', label: 'daily effort per owner' },
{ value: '0', label: 'personal details stored' },
]
/** Demo credentials — mirrored by UdyamDataSeeder on the backend. */
const DEMO = {
officer: { username: 'aditya.vyas', password: 'Udyam@123' },
enterprise: { id: 'MSME-4417', pin: '1234' },
}
export function LoginPage() {
const navigate = useNavigate()
const [mode, setMode] = useState<Mode>('OFFICER')
const [identifier, setIdentifier] = useState('')
const [secret, setSecret] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [slide, setSlide] = useState(0)
useEffect(() => {
const id = setInterval(() => setSlide((s) => (s + 1) % SLIDES.length), 6500)
return () => clearInterval(id)
}, [])
/**
* Sign in against the backend; if it is unreachable (the UI-first default),
* fall back to the seeded demo credentials so every screen stays walkable.
*/
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setError('')
const id = identifier.trim()
if (!id || !secret.trim()) {
setError(mode === 'OFFICER' ? 'Enter username and password' : 'उद्यम आईडी और पिन डालें')
return
}
setBusy(true)
try {
if (mode === 'OFFICER') {
const session = await post<OfficerSession>('/auth/officer-login', { username: id, password: secret })
sessionStorage.setItem('ul-officer-auth', JSON.stringify(session))
navigate('/officer', { replace: true })
} else {
const session = await post<EnterpriseSession>('/auth/app-login', { enterpriseId: id, pin: secret })
localStorage.setItem('ul-app-auth', JSON.stringify(session))
navigate('/app', { replace: true })
}
} catch (err) {
const fallback = demoLogin(mode, id, secret)
if (fallback) {
navigate(fallback, { replace: true })
return
}
setError(errMsg(err))
} finally {
setBusy(false)
}
}
function fillDemo() {
if (mode === 'OFFICER') {
setIdentifier(DEMO.officer.username)
setSecret(DEMO.officer.password)
} else {
setIdentifier(DEMO.enterprise.id)
setSecret(DEMO.enterprise.pin)
}
}
const s = SLIDES[slide]
const hi = mode === 'ENTERPRISE'
return (
<div className="min-h-screen flex bg-forest-deep text-white">
{/* ── left: product branding + rotating slides ─────────── */}
<div
className="hidden lg:flex flex-col flex-[1.4] px-14 py-12 relative overflow-hidden"
style={{
background:
'radial-gradient(rgba(63,184,113,0.06) 1px, transparent 1px), linear-gradient(150deg, #06301f 0%, #0b5d3b 58%, #0f6f47 100%)',
backgroundSize: '26px 26px, cover',
}}
>
<div className="flex items-center gap-2.5">
<UdyamMark size={26} onDark />
<span className="text-[15px] font-bold">
Udyam<span className="text-amber-hi">Linc</span>
</span>
<span className="font-mono text-[9px] tracking-[0.2em] text-[#9dbdaa] border border-white/15 rounded-full px-2.5 py-0.5 ml-1">
NABARD HACKATHON
</span>
</div>
<div className="flex-1 flex flex-col justify-center max-w-[560px]">
<div className="font-mono text-[10px] tracking-[0.22em] text-amber-hi mb-4">{s.kicker}</div>
<h1 className="text-[38px] leading-[1.15] font-bold mb-5">{s.title}</h1>
<p className="text-[14px] leading-relaxed text-[#c3d8cc]">{s.body}</p>
<div className="flex gap-1.5 mt-8">
{SLIDES.map((_, i) => (
<button
key={i}
onClick={() => setSlide(i)}
aria-label={`Slide ${i + 1}`}
className={`h-[3px] rounded-full transition-all ${i === slide ? 'w-8 bg-amber' : 'w-4 bg-white/25'}`}
/>
))}
</div>
</div>
<div className="grid grid-cols-4 gap-3">
{STATS.map((st) => (
<div key={st.label} className="bg-white/[0.06] border border-white/10 rounded-lg px-3 py-2.5">
<div className="text-[19px] font-bold text-amber-hi leading-tight">{st.value}</div>
<div className="text-[9.5px] text-[#a8c3b4] leading-tight mt-0.5">{st.label}</div>
</div>
))}
</div>
<div className="text-[10px] text-[#7d9b8a] mt-8 font-mono">
Team LincFusion · LincPay Solutions Pvt. Ltd., Bhopal · NPCI / NBBL certified
</div>
</div>
{/* ── right: sign-in ───────────────────────────────────── */}
<div className="flex-1 min-w-[380px] bg-white text-slate-900 flex flex-col justify-center px-10 py-12">
<div className="w-full max-w-[380px] mx-auto">
<div className="flex items-center gap-2 mb-1 lg:hidden">
<UdyamMark size={24} />
<span className="text-[16px] font-bold text-forest">
Udyam<span className="text-amber">Linc</span>
</span>
</div>
<h2 className="text-[19px] font-bold text-forest">{hi ? 'अपने उद्यम में जाएँ' : 'Sign in to the console'}</h2>
<p className="text-[12px] text-slate-500 mt-1 mb-5">
{hi ? 'उद्यम आईडी और पिन से अंदर आएँ' : 'Early warning and portfolio monitoring for field officers'}
</p>
{/* mode tabs */}
<div className="flex bg-slate-100 rounded-lg p-1 mb-5">
{(['OFFICER', 'ENTERPRISE'] as Mode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => {
setMode(m)
setIdentifier('')
setSecret('')
setError('')
}}
className={[
'flex-1 text-[11.5px] font-semibold py-1.5 rounded-md transition-colors',
mode === m ? 'bg-white text-forest shadow-sm' : 'text-slate-500 hover:text-slate-700',
].join(' ')}
>
{m === 'OFFICER' ? 'Field Officer' : 'Enterprise · उद्यम'}
</button>
))}
</div>
<form onSubmit={handleLogin}>
<label className="block mb-3.5">
<span className="block font-mono text-[10px] tracking-[0.14em] text-slate-500 mb-1.5">
{hi ? 'उद्यम आईडी' : 'USERNAME'}
</span>
<input
className={inputCls}
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder={hi ? 'MSME-4417' : 'e.g. aditya.vyas'}
autoComplete="username"
/>
</label>
<label className="block mb-4">
<span className="block font-mono text-[10px] tracking-[0.14em] text-slate-500 mb-1.5">
{hi ? 'पिन' : 'PASSWORD'}
</span>
<input
className={inputCls}
type="password"
inputMode={hi ? 'numeric' : 'text'}
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder={hi ? '4 अंकों का पिन' : 'Enter your password'}
autoComplete="current-password"
/>
</label>
{error && (
<div className="text-[11.5px] text-risk-red bg-risk-soft border border-risk-red/20 rounded-md px-3 py-2 mb-3.5">
{error}
</div>
)}
<button
type="submit"
disabled={busy}
className="w-full bg-forest hover:bg-forest-mid disabled:opacity-60 text-white text-[13px] font-semibold py-2.5 rounded-md transition-colors"
>
{busy ? '…' : hi ? 'अंदर आएँ' : 'Sign in'}
</button>
</form>
<div className="mt-5 border border-dashed border-slate-300 rounded-lg px-3.5 py-3 bg-slate-50">
<div className="flex items-center justify-between mb-1.5">
<span className="font-mono text-[9.5px] tracking-[0.14em] text-slate-500">DEMO CREDENTIALS</span>
<button type="button" onClick={fillDemo} className="text-[10.5px] font-semibold text-forest hover:underline">
fill
</button>
</div>
<div className="font-mono text-[11px] text-slate-600 leading-relaxed">
{mode === 'OFFICER' ? (
<>
{DEMO.officer.username} · {DEMO.officer.password}
</>
) : (
<>
{DEMO.enterprise.id} · {DEMO.enterprise.pin}
</>
)}
</div>
<p className="text-[10px] text-slate-400 mt-1.5 leading-snug">
Backend optional — with the API down, sign-in falls through to the seeded prototype data.
</p>
</div>
<p className="text-[10px] text-slate-400 text-center mt-6 leading-relaxed">
{hi
? 'आपकी निजी जानकारी कहीं नहीं रखी जाती।'
: 'Consent-first, explainable and auditable by design — no personally identifiable data.'}
</p>
</div>
</div>
</div>
)
}
/**
* Offline/no-backend sign-in. Officer credentials are fixed; any enterprise in
* the demo portfolio opens with the shared PIN, so a reviewer can inspect any
* row from the console on the phone surface.
*/
function demoLogin(mode: Mode, id: string, secret: string): string | null {
if (mode === 'OFFICER') {
if (id !== DEMO.officer.username || secret !== DEMO.officer.password) return null
const session: OfficerSession = {
token: 'demo-officer-token',
username: DEMO.officer.username,
fullName: 'Aditya Vyas',
role: 'FIELD_OFFICER',
blocks: ['Sehore', 'Vidisha', 'Raisen', 'Bhopal'],
branch: 'Sehore & Vidisha blocks',
}
sessionStorage.setItem('ul-officer-auth', JSON.stringify(session))
return '/officer'
}
if (secret !== DEMO.enterprise.pin) return null
const e = findEnterprise(id.toUpperCase()) ?? ENTERPRISES.find((x) => x.enterpriseId === DEMO.enterprise.id)
if (!e) return null
const session: EnterpriseSession = {
token: 'demo-app-token',
enterpriseId: e.enterpriseId,
name: e.name,
nameHi: e.nameHi,
sector: e.sector,
block: e.block,
lang: 'hi',
}
localStorage.setItem('ul-app-auth', JSON.stringify(session))
return '/app'
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
// 5180 belongs to BharatLinc-UI — UdyamLinc runs alongside it
port: 5182,
},
})