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