Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6eea1360f | ||
|
|
9d307c2d20 | ||
|
|
1ffc1cbd4c | ||
|
|
13c628fa4b | ||
|
|
41c49113c2 | ||
|
|
39ca34be68 | ||
|
|
aa1484c285 | ||
|
|
318b83625a |
+8
-2
@@ -4,8 +4,14 @@ 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.
|
||||
# Vite inlines this at build time, so it is a build arg and not a runtime env
|
||||
# var — changing it later means rebuilding the bundle, not restarting nginx.
|
||||
#
|
||||
# Relative on purpose. A bundle carrying no hostname serves from any domain and
|
||||
# is same-origin with the API that nginx proxies at /api/, so the deployment
|
||||
# never has a CORS origin to keep in sync.
|
||||
ARG VITE_UDYAM_API_BASE_URL=/api/v1/udyam
|
||||
ENV VITE_UDYAM_API_BASE_URL=$VITE_UDYAM_API_BASE_URL
|
||||
RUN npm run build
|
||||
|
||||
# ── serve stage: nginx serving the static SPA on 5173 ──
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
Runtime config, loaded before the app and deliberately not a module: a
|
||||
classic script blocks until it has run, so window.__UDYAM_CONFIG__ is set
|
||||
before any module code reads it. Served unbundled from the static root so
|
||||
the API base can be changed on the server without rebuilding.
|
||||
-->
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+31
@@ -19,6 +19,37 @@ server {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# Runtime config — never cached. It is how the API base is changed without a
|
||||
# rebuild, and a cached copy would silently pin the old URL for every browser
|
||||
# that has already loaded the app, with no way to tell from the server side.
|
||||
location = /config.js {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# The API, same-origin. The bundle is built with VITE_UDYAM_API_BASE_URL set
|
||||
# to the relative /api/v1/udyam, so it carries no hostname at all and the
|
||||
# same dist/ serves from any domain. That also puts the API on the UI's own
|
||||
# origin, which means CORS never enters the picture — the single largest
|
||||
# source of "works locally, fails deployed" in a split deployment.
|
||||
#
|
||||
# The backend's context-path is already /api/v1/udyam, so the prefix is
|
||||
# passed through unchanged rather than rewritten.
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8082;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Ingestion and the three-year backfill are long-running admin calls;
|
||||
# the default 60s read timeout cuts them off mid-run and reports a
|
||||
# gateway error over a job that is still working.
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# SPA fallback — React Router owns the paths
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Runtime configuration — edited on the server, not baked into the bundle.
|
||||
*
|
||||
* Vite inlines import.meta.env.VITE_* at build time, so a bundle built for one
|
||||
* backend URL cannot be pointed at another without rebuilding. That is the wrong
|
||||
* shape for a deployment where the backend goes up first and the UI follows, or
|
||||
* where the same artifact is promoted from demo to staging. This file is served
|
||||
* from the static root unbundled, so ops can change the API base and reload.
|
||||
*
|
||||
* Precedence, highest first:
|
||||
* 1. apiBaseUrl below, if it is a non-empty string
|
||||
* 2. VITE_UDYAM_API_BASE_URL, compiled in at build time
|
||||
* 3. "/api/v1/udyam" — same-origin, which is what the nginx and httpd configs
|
||||
* in DEPLOYMENT.md set up
|
||||
*
|
||||
* Leave it empty for a same-origin deployment; that is the common case and needs
|
||||
* no edit. Set it when the API lives on another host:
|
||||
*
|
||||
* apiBaseUrl: "https://udyamlinc-api.lincpay.tech/api/v1/udyam"
|
||||
*
|
||||
* A cross-origin value also needs that origin in the backend's
|
||||
* UDYAM_CORS_ALLOWED_ORIGINS, or the browser blocks every call while the API
|
||||
* itself looks healthy.
|
||||
*
|
||||
* This file must never be cached — the server configs send no-store for it.
|
||||
* Cached, it silently pins the old URL for anyone who has already loaded the app.
|
||||
*/
|
||||
window.__UDYAM_CONFIG__ = {
|
||||
apiBaseUrl: '',
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
+1
-1
@@ -6,7 +6,7 @@
|
||||
* 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 CACHE = 'udyamlinc-v2'
|
||||
const SHELL = ['/', '/app', '/index.html', '/manifest.webmanifest', '/udyamlinc-tile.svg']
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
+19
-1
@@ -1,6 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { DemoPage } from './pages/DemoPage'
|
||||
import { enterpriseSession, officerSession, homeFor, isApexRole } from './lib/api'
|
||||
|
||||
// Field officer console
|
||||
@@ -11,7 +12,9 @@ import { RegionalOfficesPage } from './features/nabard/RegionalOfficesPage'
|
||||
import { BankScorecardPage } from './features/nabard/BankScorecardPage'
|
||||
import { DigestPage } from './features/nabard/DigestPage'
|
||||
import { InstitutionsPage } from './features/nabard/InstitutionsPage'
|
||||
import { SourceCoveragePage } from './features/nabard/SourceCoveragePage'
|
||||
import { OfficerDashboard } from './features/officer/OfficerDashboard'
|
||||
import { OfficerDashboardClassic } from './features/officer/OfficerDashboardClassic'
|
||||
import { EnterprisesPage } from './features/officer/EnterprisesPage'
|
||||
import { SectorPortfolioPage } from './features/officer/SectorPortfolioPage'
|
||||
import { EnterpriseDetailPage } from './features/officer/EnterpriseDetailPage'
|
||||
@@ -19,6 +22,7 @@ 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 { DataSourcesPage } from './features/officer/DataSourcesPage'
|
||||
import { DataFeedsPage } from './features/officer/DataFeedsPage'
|
||||
import { DataEntryPage } from './features/officer/DataEntryPage'
|
||||
import { IntegrationPage } from './features/officer/IntegrationPage'
|
||||
@@ -81,6 +85,16 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
{/* Both surfaces on one screen, for the jury round. Officer session
|
||||
required — the borrower half signs itself in per selection. */}
|
||||
<Route
|
||||
path="/demo"
|
||||
element={
|
||||
<RequireOfficer>
|
||||
<DemoPage />
|
||||
</RequireOfficer>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Field officer console — portfolio, risk detail and recommended action */}
|
||||
@@ -92,7 +106,9 @@ export default function App() {
|
||||
</RequireOfficer>
|
||||
}
|
||||
>
|
||||
<Route index element={<OfficerDashboard />} />
|
||||
<Route index element={<OfficerDashboardClassic />} />
|
||||
{/* Two readings of one book: by score, and by when trouble arrives. */}
|
||||
<Route path="early-warning" element={<OfficerDashboard />} />
|
||||
<Route path="portfolio" element={<SectorPortfolioPage />} />
|
||||
<Route path="enterprises" element={<EnterprisesPage />} />
|
||||
<Route path="enterprises/:enterpriseId" element={<EnterpriseDetailPage />} />
|
||||
@@ -100,6 +116,7 @@ export default function App() {
|
||||
<Route path="forecasts" element={<ForecastsPage />} />
|
||||
<Route path="visits" element={<VisitsPage />} />
|
||||
<Route path="rules" element={<RulesPage />} />
|
||||
<Route path="sources" element={<DataSourcesPage />} />
|
||||
<Route path="feeds" element={<DataFeedsPage />} />
|
||||
<Route path="data-entry" element={<DataEntryPage />} />
|
||||
<Route path="integration" element={<IntegrationPage />} />
|
||||
@@ -121,6 +138,7 @@ export default function App() {
|
||||
<Route path="banks" element={<BankScorecardPage />} />
|
||||
<Route path="digest" element={<DigestPage />} />
|
||||
<Route path="institutions" element={<InstitutionsPage />} />
|
||||
<Route path="sources" element={<SourceCoveragePage />} />
|
||||
</Route>
|
||||
|
||||
{/* Enterprise PWA — offline, Hindi-first */}
|
||||
|
||||
@@ -1,31 +1,71 @@
|
||||
import { Bar } from 'react-chartjs-2'
|
||||
import type { ChartOptions } from 'chart.js'
|
||||
import { BENCHMARKS } from '../../lib/demo/ops'
|
||||
import { ARM_LABEL } from '../../lib/api'
|
||||
import type { MetricArm } from '../../lib/api'
|
||||
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 }) {
|
||||
/**
|
||||
* The four arms of the backtest, measured on identical folds.
|
||||
*
|
||||
* <p>This chart used to plot three hard-coded numbers against an invented
|
||||
* "bureau-score baseline" that nothing had computed. It now plots what
|
||||
* `model_metric` holds, and the baselines are the real ones: a single obligation
|
||||
* ratio, and current DPD alone. That matters because AUC 0.76 in isolation is
|
||||
* either a result or a restatement of "a borrower who is late today is late in
|
||||
* three months" — only the comparison says which.
|
||||
*
|
||||
* <p>Baselines are drawn in a muted grey and the trained models in brand blue, so
|
||||
* the reader can tell a fitted model from a single column at a glance without
|
||||
* reading the legend. Bars are sorted by value rather than by kind, because
|
||||
* hiding a baseline that beats the model would be the whole problem again.
|
||||
*/
|
||||
export function ModelComparisonBar({
|
||||
arms,
|
||||
metric = 'auc_roc',
|
||||
height = 190,
|
||||
}: {
|
||||
arms: Record<string, MetricArm>
|
||||
metric?: 'auc_roc' | 'pr_auc'
|
||||
height?: number
|
||||
}) {
|
||||
const entries = Object.entries(arms)
|
||||
.map(([key, arm]) => ({ key, arm, value: arm[metric] }))
|
||||
.filter((e): e is { key: string; arm: MetricArm; value: number } => typeof e.value === 'number')
|
||||
.sort((a, b) => b.value - a.value)
|
||||
|
||||
const data = {
|
||||
labels: ['UdyamLinc — full signal set', 'Repayment history only', 'Bureau-score baseline'],
|
||||
labels: entries.map((e) => ARM_LABEL[e.key] ?? e.key),
|
||||
datasets: [
|
||||
{
|
||||
label: 'AUC-ROC',
|
||||
data: [BENCHMARKS.aucRoc, BENCHMARKS.aucRepaymentOnly, BENCHMARKS.aucBureau],
|
||||
backgroundColor: [CHART_COLOURS.forest, CHART_COLOURS.greenHi, '#94a3b8'],
|
||||
label: metric === 'auc_roc' ? 'AUC-ROC' : 'PR-AUC',
|
||||
data: entries.map((e) => e.value),
|
||||
backgroundColor: entries.map((e) => (e.arm.baseline ? '#9aa8b4' : CHART_COLOURS.forest)),
|
||||
borderRadius: 4,
|
||||
barThickness: 22,
|
||||
barThickness: 20,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// PR-AUC on a 4% positive rate lives near the bottom of the axis; forcing it to
|
||||
// 0–1 would render four indistinguishable stubs. AUC keeps the full axis because
|
||||
// 0.5 is a meaningful floor there and cropping it would flatter every arm.
|
||||
const max = metric === 'auc_roc' ? 1 : Math.min(1, Math.max(...entries.map((e) => e.value)) * 1.4)
|
||||
|
||||
const options: ChartOptions<'bar'> = {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
afterLabel: (ctx) => (entries[ctx.dataIndex].arm.baseline ? 'single feature, not a model' : ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { min: 0, max: 1, grid: { color: CHART_COLOURS.grid }, ticks: { stepSize: 0.1 } },
|
||||
x: { min: 0, max, grid: { color: CHART_COLOURS.grid }, ticks: { stepSize: metric === 'auc_roc' ? 0.1 : 0.05 } },
|
||||
y: { grid: { display: false }, ticks: { font: { size: 10.5 } } },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e08019' : '#28ae64'
|
||||
|
||||
const data = {
|
||||
labels: DRIVER_META.map((d) => d.label),
|
||||
|
||||
@@ -27,15 +27,15 @@ ChartJS.register(
|
||||
Legend,
|
||||
)
|
||||
|
||||
ChartJS.defaults.font.family = '"Segoe UI", Roboto, -apple-system, Arial, sans-serif'
|
||||
ChartJS.defaults.font.family = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
|
||||
ChartJS.defaults.font.size = 11
|
||||
ChartJS.defaults.color = '#64748b'
|
||||
ChartJS.defaults.color = '#87939c'
|
||||
|
||||
export const CHART_COLOURS = {
|
||||
forest: '#0b5d3b',
|
||||
green: '#1e7a46',
|
||||
greenHi: '#3fb871',
|
||||
amber: '#e8a020',
|
||||
forest: '#2a71b8', // s1 — the design's primary series
|
||||
green: '#28ae64', // s3
|
||||
greenHi: '#4cc582',
|
||||
amber: '#e08019', // s2
|
||||
red: '#c0392b',
|
||||
grid: '#e8eeea',
|
||||
grid: '#edf0f3',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* A select that is a menu, not a native `<select>`.
|
||||
*
|
||||
* <p>The scope controls in the console header were the last two OS-rendered
|
||||
* dropdowns in the chrome: a native `<select>` takes its type, its row height and
|
||||
* its open panel from the operating system, so it sits beside styled menus
|
||||
* looking like a different application. This renders the same panel the header
|
||||
* navigation uses, so "choose a block" and "choose a screen" look like the same
|
||||
* gesture — which they are.
|
||||
*
|
||||
* <p>Behaviour follows the header menus deliberately: opens on click rather than
|
||||
* hover, closes on outside click, Escape and selection, and keeps focus on the
|
||||
* trigger so the keyboard path is unchanged. A native select is still the right
|
||||
* answer on the phone-sized PWA, and this is not used there.
|
||||
*/
|
||||
export function MenuSelect<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
label,
|
||||
align = 'right',
|
||||
minWidth = 132,
|
||||
}: {
|
||||
value: T
|
||||
options: { value: T; label: string; hint?: string }[]
|
||||
onChange: (value: T) => void
|
||||
/** Accessible name — the control shows the current value, not this. */
|
||||
label: string
|
||||
align?: 'left' | 'right'
|
||||
minWidth?: number
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const current = options.find((o) => o.value === value)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!root.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="relative" ref={root}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
style={{ minWidth }}
|
||||
className={`flex items-center gap-1.5 w-full border rounded-[8px] px-2.5 py-[5px] text-[11.5px] font-medium bg-white transition-colors ${
|
||||
open ? 'border-lp-blue text-ink' : 'border-line-s text-ink-2 hover:border-lp-blue hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{current?.label ?? value}</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={`ml-auto shrink-0 text-ink-3 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
className={`ul-menu absolute top-[calc(100%+6px)] z-50 py-1 max-h-[280px] overflow-y-auto ${
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
}`}
|
||||
style={{ minWidth: Math.max(minWidth, 170) }}
|
||||
>
|
||||
<div className="px-3 pt-1 pb-1.5 text-[9.5px] uppercase tracking-[0.09em] text-ink-3 font-bold">
|
||||
{label}
|
||||
</div>
|
||||
{options.map((o) => {
|
||||
const selected = o.value === value
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() => {
|
||||
onChange(o.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`flex items-center gap-2 w-full text-left px-3 py-[7px] border-l-2 transition-colors ${
|
||||
selected ? 'bg-lp-blue-soft border-lp-blue' : 'border-transparent hover:bg-surface-3'
|
||||
}`}
|
||||
>
|
||||
<Check
|
||||
size={13}
|
||||
className={`shrink-0 ${selected ? 'text-lp-blue' : 'text-transparent'}`}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className={`block text-[12.5px] truncate ${
|
||||
selected ? 'text-lp-blue font-semibold' : 'text-ink font-medium'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</span>
|
||||
{o.hint && <span className="block text-[10.5px] text-ink-3 leading-snug">{o.hint}</span>}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CircleDashed, Database, Download, Gauge, Layers, Siren } from 'lucide-react'
|
||||
import { Card } from './bits'
|
||||
import { STATUS_HELP, STATUS_LABEL, STATUS_PILL, STATUS_TILE } from '../../lib/sourceStatus'
|
||||
import type { SourceGroupSpec, SourceSpec, SourceStatus } from '../../lib/sourceStatus'
|
||||
|
||||
/**
|
||||
* One visual language for every way data enters the platform.
|
||||
*
|
||||
* <p>Lives here rather than beside either page because both consoles render the
|
||||
* same inventory and must render it identically. A bank officer and a NABARD
|
||||
* supervisor looking at the same source have to see the same card — if the two
|
||||
* drifted, the apex tier would eventually be told a source was live that the
|
||||
* bank tier knew was not.
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function SourceCard({ s }: { s: SourceSpec }) {
|
||||
const dim = s.status === 'NOT_CONNECTED'
|
||||
// Fall back to a dashed ring rather than an arbitrary glyph: a source with no
|
||||
// icon assigned should look unfinished, not look like something it is not.
|
||||
const Icon = s.icon ?? CircleDashed
|
||||
return (
|
||||
<div
|
||||
className={`ul-card p-3.5 flex flex-col gap-2.5 h-full ${dim ? 'bg-slate-50/60 border-dashed' : ''}`}
|
||||
>
|
||||
{/* identity — tile tinted by status, so a grid reads before it is read */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
className={`w-8 h-8 rounded-[9px] grid place-items-center shrink-0 ${STATUS_TILE[s.status]}`}
|
||||
>
|
||||
<Icon size={16} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-[12.5px] font-bold leading-tight ${dim ? 'text-slate-500' : 'text-forest'}`}>
|
||||
{s.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-wide mt-0.5">{s.kind}</div>
|
||||
</div>
|
||||
<span className={`pill ${STATUS_PILL[s.status]} ml-auto shrink-0`} title={STATUS_HELP[s.status]}>
|
||||
{STATUS_LABEL[s.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* the four facts, in the same order on every card */}
|
||||
<dl className="grid grid-cols-[64px_1fr] gap-x-2 gap-y-1 text-[10.5px]">
|
||||
<dt className="text-slate-400">Arrives</dt>
|
||||
<dd className="font-mono text-[10px] text-slate-600 break-all leading-snug">{s.mechanism}</dd>
|
||||
|
||||
<dt className="text-slate-400">Auth</dt>
|
||||
<dd className="text-slate-600">{s.auth}</dd>
|
||||
|
||||
<dt className="text-slate-400">Writes</dt>
|
||||
<dd className="font-mono text-[10px] text-slate-600">{s.writes}</dd>
|
||||
|
||||
<dt className="text-slate-400">Cadence</dt>
|
||||
<dd className="text-slate-600">{s.cadence}</dd>
|
||||
</dl>
|
||||
|
||||
{/* what it feeds — the reason the source is on the book at all */}
|
||||
{s.feeds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{s.feeds.map((f) => (
|
||||
<span key={f} className="font-mono text-[9px] bg-green-soft text-forest px-1.5 py-0.5 rounded-md">
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s.note && <p className="text-[10.5px] text-slate-500 leading-snug">{s.note}</p>}
|
||||
|
||||
{/* last activity pinned to the bottom so cards align across a row */}
|
||||
<div className="mt-auto pt-2 border-t border-slate-100 flex items-center gap-2">
|
||||
<span className="text-[10.5px] text-slate-500 leading-snug">{s.activity}</span>
|
||||
{s.link && (
|
||||
<Link
|
||||
to={s.link.to}
|
||||
className="ml-auto shrink-0 text-[10.5px] font-semibold text-green hover:text-forest"
|
||||
>
|
||||
{s.link.label} →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A titled band of cards. Groups follow the problem statement's data families. */
|
||||
export function SourceGroup({ icon: Icon, letter, title, blurb, sources }: SourceGroupSpec) {
|
||||
const live = sources.filter((s) => s.status === 'LIVE').length
|
||||
return (
|
||||
<Card
|
||||
title={`${letter} · ${title}`}
|
||||
badge={`${live}/${sources.length} live`}
|
||||
actions={
|
||||
<span className="flex items-center gap-1.5 text-[10.5px] text-slate-400">
|
||||
{Icon && <Icon size={13} className="text-slate-300" />}
|
||||
{blurb}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="p-3.5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{sources.map((s) => (
|
||||
<SourceCard key={s.id} s={s} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** The three words the inventory is written in, defined once where both pages show it. */
|
||||
export function StatusLegend() {
|
||||
return (
|
||||
<div className="ul-card px-3.5 py-2.5 flex flex-wrap items-center gap-x-5 gap-y-1.5">
|
||||
<span className="text-[10.5px] font-semibold text-slate-500">Status means</span>
|
||||
{(['LIVE', 'SIMULATED', 'NOT_CONNECTED'] as SourceStatus[]).map((s) => (
|
||||
<span key={s} className="flex items-center gap-1.5">
|
||||
<span className={`pill ${STATUS_PILL[s]}`}>{STATUS_LABEL[s]}</span>
|
||||
<span className="text-[10.5px] text-slate-500">{STATUS_HELP[s]}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Where the sources land, in one strip. Step 1 is the subject of both pages. */
|
||||
export function PipelineStrip() {
|
||||
const steps = [
|
||||
{ n: '1', label: 'Sources', sub: 'API · CBS · UPI · upload · ledger', icon: Database },
|
||||
{ n: '2', label: 'Ingestion', sub: 'validated, deduped, stamped', icon: Download },
|
||||
{ n: '3', label: 'Feature store', sub: '15 features, point-in-time', icon: Layers },
|
||||
{ n: '4', label: 'Score & forecast', sub: 'stress score · P10/P50/P90', icon: Gauge },
|
||||
{ n: '5', label: 'Alerts', sub: 'trip-wires · officer playbook', icon: Siren },
|
||||
]
|
||||
return (
|
||||
<div className="ul-card p-3 flex flex-wrap items-stretch gap-2">
|
||||
{steps.map((s, i) => (
|
||||
<div key={s.n} className="flex items-stretch gap-2 flex-1 min-w-[150px]">
|
||||
<div className={`flex-1 rounded-md px-2.5 py-2 ${i === 0 ? 'bg-green-soft' : 'bg-slate-50'}`}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`w-5 h-5 rounded-[5px] grid place-items-center shrink-0 ${
|
||||
i === 0 ? 'bg-forest text-white' : 'bg-slate-300 text-white'
|
||||
}`}
|
||||
>
|
||||
<s.icon size={12} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span className={`text-[11.5px] font-bold ${i === 0 ? 'text-forest' : 'text-slate-600'}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[9px] text-slate-400">{s.n}</span>
|
||||
</div>
|
||||
<div className="text-[9.5px] text-slate-500 mt-0.5 leading-snug">{s.sub}</div>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<span className="self-center text-slate-300 text-[13px] leading-none">→</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+91
-20
@@ -8,7 +8,10 @@ import { BANDS } from '../../lib/demo/enterprises'
|
||||
/* the bottom (RiskBadge / ScoreDial / Sparkline / DriverBar) are new. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
export function StatusPill({ status }: { status: string | null | undefined }) {
|
||||
// Defensive because this renders wire data on six screens. A row that arrives
|
||||
// without a status should show as unknown; it should not take the page down.
|
||||
if (!status) return <span className="pill pill-mut">—</span>
|
||||
const s = status.toUpperCase()
|
||||
const cls =
|
||||
s.includes('DONE') || s.includes('CLOSED') || s.includes('SYNCED') || s.includes('ACTIVE')
|
||||
@@ -37,11 +40,11 @@ export function Card({
|
||||
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}`}>
|
||||
<div className={`ul-card 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>}
|
||||
<div className="px-4 py-3 border-b border-line flex items-center gap-2.5">
|
||||
<h3 className="text-[13.5px] font-semibold text-ink tracking-[-0.012em]">{title}</h3>
|
||||
{badge && <span className="font-mono text-[9.5px] bg-lp-blue-soft text-lp-blue px-1.5 py-0.5 rounded-md">{badge}</span>}
|
||||
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
@@ -83,7 +86,7 @@ export function Modal({
|
||||
|
||||
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">
|
||||
<div className="flex gap-1 border-b border-line mb-4 overflow-x-auto">
|
||||
{items.map((i) => (
|
||||
<NavLink
|
||||
key={i.to}
|
||||
@@ -92,7 +95,7 @@ export function SubNav({ items }: { items: { label: string; to: string; end?: bo
|
||||
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',
|
||||
isActive ? 'text-lp-blue border-lp-blue' : 'text-ink-3 border-transparent hover:text-ink',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
@@ -124,7 +127,7 @@ export function Field({
|
||||
}
|
||||
|
||||
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'
|
||||
'w-full border border-line-s rounded-[8px] px-2.5 py-1.5 text-[12.5px] outline-none focus:border-lp-blue focus:ring-2 focus:ring-lp-blue/15 bg-white'
|
||||
|
||||
export function PrimaryBtn({
|
||||
children,
|
||||
@@ -134,7 +137,7 @@ export function PrimaryBtn({
|
||||
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}`}
|
||||
className={`bg-lp-blue hover:bg-lp-blue-d disabled:opacity-50 disabled:cursor-not-allowed text-white text-[12.5px] font-semibold px-3.5 py-[7px] rounded-[8px] transition-colors ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -149,7 +152,7 @@ export function GhostBtn({
|
||||
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}`}
|
||||
className={`border border-line-s hover:bg-surface-3 disabled:opacity-50 text-ink-2 text-[12.5px] font-semibold px-3.5 py-[7px] rounded-[8px] transition-colors ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -166,7 +169,7 @@ export function ActionChip({
|
||||
{...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',
|
||||
active ? 'bg-lp-blue text-white border-lp-blue' : 'bg-white text-ink-2 border-line-s hover:border-lp-blue',
|
||||
].join(' ')}
|
||||
>
|
||||
{children}
|
||||
@@ -176,7 +179,7 @@ export function ActionChip({
|
||||
|
||||
/** 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>
|
||||
return <div className="py-10 text-center text-[12px] text-ink-3">{text}</div>
|
||||
}
|
||||
|
||||
// ── UdyamLinc risk primitives ───────────────────────────────────
|
||||
@@ -194,7 +197,7 @@ export function ScoreDial({ score, band, size = 96 }: { score: number; band: Ban
|
||||
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'
|
||||
const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e08019' : '#28ae64'
|
||||
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`}>
|
||||
@@ -238,7 +241,7 @@ export function Sparkline({
|
||||
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 colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e08019' : '#28ae64'
|
||||
const pts = values
|
||||
.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * (width - 4) + 2
|
||||
@@ -285,16 +288,16 @@ export function KpiTile({
|
||||
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'
|
||||
tone === 'red' ? 'bg-risk-red' : tone === 'watch' ? 'bg-amber' : tone === 'good' ? 'bg-green' : 'bg-lp-blue'
|
||||
const deltaCls = tone === 'red' ? 'text-risk-red' : tone === 'watch' ? 'text-amber' : 'text-ink-3'
|
||||
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="text-[10px] uppercase tracking-[0.07em] text-ink-3 font-bold">{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 className="text-[28px] font-bold text-ink leading-none mt-1.5 tracking-[-0.03em] num">{value}</div>
|
||||
{delta && <div className={`text-[11.5px] mt-1.5 ${deltaCls}`}>{delta}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -303,8 +306,76 @@ export function KpiTile({
|
||||
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="M32 3 58 17.5v29L32 61 6 46.5v-29z" fill={onDark ? '#4cc582' : '#0b4a37'} />
|
||||
<path d="M34.5 15 20 36h10l-2.5 13L44 27H33.5z" fill={onDark ? '#f2b544' : '#e8a020'} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The design system's `.alert` box — a tinted panel with a leading glyph, a bold
|
||||
* first line and a plain second.
|
||||
*
|
||||
* <p>Four tones and no more. `high` and `watch` map to the risk scale so a box
|
||||
* cannot be tinted a severity the band does not carry; `info` is brand blue for
|
||||
* a statement of fact; `good` is the only one that says nothing is wrong.
|
||||
*/
|
||||
export function AlertBox({
|
||||
tone,
|
||||
glyph,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
tone: 'high' | 'watch' | 'info' | 'good'
|
||||
glyph: React.ReactNode
|
||||
title: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const cls = {
|
||||
high: 'bg-risk-soft border-risk-red/35 text-[#9c2b1f]',
|
||||
watch: 'bg-amber-soft border-amber/40 text-[#8a4e05]',
|
||||
info: 'bg-lp-blue-soft border-lp-blue/30 text-lp-blue',
|
||||
good: 'bg-green-soft border-green/30 text-[#186b3d]',
|
||||
}[tone]
|
||||
return (
|
||||
<div className={`flex gap-2.5 px-3.5 py-2.5 rounded-[8px] border text-[12px] leading-[1.5] ${cls}`}>
|
||||
<span className="shrink-0 mt-px">{glyph}</span>
|
||||
<div className="min-w-0">
|
||||
<b className="block mb-0.5">{title}</b>
|
||||
{children && <span className="text-ink-2 block">{children}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a screen shows instead of borrower rows it cannot verify.
|
||||
*
|
||||
* <p>Deliberately not an error toast. The console's other failure states fall
|
||||
* back to demo data, which is right for a price series and wrong for a
|
||||
* portfolio: the demo borrowers have no owner, so substituting them shows one
|
||||
* bank another bank's book. This panel is the substitute — it explains why the
|
||||
* screen is empty rather than letting an empty screen read as "no alerts".
|
||||
*/
|
||||
export function Unavailable({
|
||||
what,
|
||||
onRetry,
|
||||
}: {
|
||||
what: string
|
||||
onRetry?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="ul-card p-8 text-center">
|
||||
<p className="text-[13px] font-semibold text-ink">{what} unavailable</p>
|
||||
<p className="text-[11.5px] text-ink-3 mt-1 max-w-[48ch] mx-auto leading-snug">
|
||||
The backend is not reachable. Borrower data is scoped to your own institution and is never
|
||||
reconstructed locally, so nothing is shown rather than something that might not be yours.
|
||||
</p>
|
||||
{onRetry && (
|
||||
<GhostBtn className="mt-3" onClick={onRetry}>
|
||||
Try again
|
||||
</GhostBtn>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { enterpriseSession } from '../../lib/api'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { enterpriseSession, fetchAppAlerts, fetchAppReasons } from '../../lib/api'
|
||||
import { Empty } from '../../components/ui/bits'
|
||||
import { findEnterprise, reasonCodesFor, tripWiresFor } from '../../lib/demo/enterprises'
|
||||
import { alertForEnterprise } from '../../lib/demo/ops'
|
||||
@@ -15,11 +16,28 @@ export function AppAlertsPage() {
|
||||
const { lang, t } = useLang('hi')
|
||||
const hi = lang === 'hi'
|
||||
|
||||
// Live alerts for this borrower. The token decides whose they are.
|
||||
const alertsQ = useQuery({ queryKey: ["app-alerts"], queryFn: fetchAppAlerts, enabled: Boolean(session) })
|
||||
// The scored reasons, not a sentence generated from the demo sector table.
|
||||
const reasonsQ = useQuery({ queryKey: ["app-reasons"], queryFn: fetchAppReasons, enabled: Boolean(session) })
|
||||
|
||||
const e = session ? findEnterprise(session.enterpriseId) : undefined
|
||||
if (!session || !e) return <Empty text="—" />
|
||||
|
||||
const alert = alertForEnterprise(e.enterpriseId)
|
||||
const reasons = reasonCodesFor(e)
|
||||
const live = alertsQ.data?.[0]
|
||||
const alert = live
|
||||
? { ...live, titleHi: live.titleHi ?? live.title, shortfall: live.shortfall ?? 0,
|
||||
breachMonth: live.breachMonth ?? "",
|
||||
reason: { driver: "", driverHi: "", weight: 0,
|
||||
sentenceEn: live.reasonEn ?? "", sentenceHi: live.reasonHi ?? "" } }
|
||||
: alertsQ.isSuccess ? null : alertForEnterprise(e.enterpriseId)
|
||||
// Fall back to the demo sentences only while the request is in flight — once it
|
||||
// has answered, an empty list means this borrower has no scored reasons, and
|
||||
// inventing three would be a lie told on the borrower's own screen.
|
||||
const live_reasons = reasonsQ.data
|
||||
const reasons = live_reasons
|
||||
? live_reasons.slice(0, 3)
|
||||
: reasonsQ.isSuccess ? [] : reasonCodesFor(e)
|
||||
const fired = tripWiresFor(e).filter((w) => w.fired)
|
||||
|
||||
if (!alert) {
|
||||
@@ -52,6 +70,11 @@ export function AppAlertsPage() {
|
||||
{hi ? 'ऐसा क्यों लग रहा है' : 'Why this is expected'}
|
||||
</div>
|
||||
<ul className="space-y-2.5">
|
||||
{reasons.length === 0 && (
|
||||
<li className="text-[12px] text-slate-500">
|
||||
{hi ? 'इस महीने कोई मापी गई वजह दर्ज नहीं है।' : 'No measured reason is on record for this run.'}
|
||||
</li>
|
||||
)}
|
||||
{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" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { enterpriseSession } from '../../lib/api'
|
||||
import { enterpriseSession, fetchAppHome, fetchAppLedger, toLedgerEntry } from '../../lib/api'
|
||||
import type { ForecastPoint } from '../../lib/api'
|
||||
import { Empty } from '../../components/ui/bits'
|
||||
import { breachPoint, findEnterprise, forecastFor } from '../../lib/demo/enterprises'
|
||||
@@ -23,13 +24,31 @@ export function AppHomePage() {
|
||||
const [sheet, setSheet] = useState<LedgerKind | null>(null)
|
||||
|
||||
const e = session ? findEnterprise(session.enterpriseId) : undefined
|
||||
const { entries } = useLedger(session?.enterpriseId ?? '')
|
||||
const ledgerQ = useQuery({ queryKey: ["app-ledger"], queryFn: fetchAppLedger, enabled: Boolean(session) })
|
||||
const server = (ledgerQ.data ?? []).map(toLedgerEntry)
|
||||
const { entries } = useLedger(session?.enterpriseId ?? "", server)
|
||||
|
||||
// Live record for this borrower. The token carries the enterprise id, so the
|
||||
// server decides whose data this is — the app never asks for an id.
|
||||
const homeQ = useQuery({ queryKey: ["app-home"], queryFn: fetchAppHome, enabled: Boolean(session) })
|
||||
const home = homeQ.data
|
||||
|
||||
if (!session || !e) return <Empty text="—" />
|
||||
|
||||
const points = forecastFor(e)
|
||||
const alert = alertForEnterprise(e.enterpriseId)
|
||||
const breach = breachPoint(points)
|
||||
// Server forecast where we have one; the in-file generator only if the API is
|
||||
// unreachable, so a borrower with no signal still sees their own screen.
|
||||
const points = home?.forecast?.length
|
||||
? home.forecast.map((p) => ({
|
||||
month: p.month,
|
||||
label: monthRef(new Date(`${p.month}-01T00:00:00`)).label,
|
||||
labelHi: monthRef(new Date(`${p.month}-01T00:00:00`)).labelHi,
|
||||
p10: p.p10, p50: p.p50, p90: p.p90, obligation: p.obligation,
|
||||
}))
|
||||
: forecastFor(e)
|
||||
const alert = home?.alert
|
||||
? { ...home.alert, titleHi: home.alert.titleHi ?? home.alert.title }
|
||||
: alertForEnterprise(e.enterpriseId)
|
||||
const breach = breachPoint(points as never)
|
||||
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
|
||||
const totals = dayTotals(entries)
|
||||
const hi = lang === 'hi'
|
||||
@@ -51,7 +70,7 @@ export function AppHomePage() {
|
||||
{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')}
|
||||
{t('home.estimate')}: {inr(alert.shortfall ?? 0)} {t('home.shortfall')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[16px] opacity-70">›</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { enterpriseSession } from '../../lib/api'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { enterpriseSession, fetchAppLedger, toLedgerEntry } from '../../lib/api'
|
||||
import type { LedgerKind } from '../../lib/api'
|
||||
import { Empty } from '../../components/ui/bits'
|
||||
import { CATEGORIES, useLedger } from '../../lib/ledgerStore'
|
||||
@@ -15,7 +16,9 @@ export function AppLedgerPage() {
|
||||
const { lang, t } = useLang('hi')
|
||||
const [range, setRange] = useState<Range>('WEEK')
|
||||
const [sheet, setSheet] = useState<LedgerKind | null>(null)
|
||||
const { entries } = useLedger(session?.enterpriseId ?? '')
|
||||
const ledgerQ = useQuery({ queryKey: ["app-ledger"], queryFn: fetchAppLedger, enabled: Boolean(session) })
|
||||
const server = (ledgerQ.data ?? []).map(toLedgerEntry)
|
||||
const { entries } = useLedger(session?.enterpriseId ?? "", server)
|
||||
const hi = lang === 'hi'
|
||||
|
||||
const cutoff = useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { clearSessions, enterpriseSession } from '../../lib/api'
|
||||
import { clearSessions, enterpriseSession, fetchAppHome, setAppConsent } from '../../lib/api'
|
||||
import type { Lang } from '../../lib/api'
|
||||
import { Empty } from '../../components/ui/bits'
|
||||
import { SECTORS, findEnterprise } from '../../lib/demo/enterprises'
|
||||
@@ -16,20 +17,41 @@ import { useOffline } from '../../lib/useOffline'
|
||||
export function AppProfilePage() {
|
||||
const navigate = useNavigate()
|
||||
const session = enterpriseSession()
|
||||
const queryClient = useQueryClient()
|
||||
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
|
||||
// Live record, so the profile shows what the lender actually holds.
|
||||
const homeQ = useQuery({ queryKey: ["app-home"], queryFn: fetchAppHome, enabled: Boolean(session) })
|
||||
const api = homeQ.data?.enterprise
|
||||
|
||||
// Consent is the server's record, not a switch on this screen. Withdrawing it
|
||||
// has to stop scoring everywhere, so the toggle writes and then re-reads.
|
||||
const consent = api ? api.consentActive !== false : true
|
||||
const consentM = useMutation({
|
||||
mutationFn: (next: boolean) => setAppConsent(next),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['app-home'] }),
|
||||
})
|
||||
const demo = session ? findEnterprise(session.enterpriseId) : undefined
|
||||
const e = api
|
||||
? { ...(demo ?? ({} as never)),
|
||||
enterpriseId: String(api.enterpriseId ?? ""),
|
||||
name: String(api.name ?? ""), nameHi: String(api.nameHi ?? ""),
|
||||
block: String(api.block ?? ""), ownerCount: Number(api.ownerCount ?? 0),
|
||||
vintageMonths: Number(api.vintageMonths ?? 0),
|
||||
loanOutstanding: Number(api.loanOutstanding ?? 0),
|
||||
monthlyInstalment: Number(api.monthlyInstalment ?? 0),
|
||||
score: Number(api.score ?? 0), band: (api.band ?? "STANDARD") as never }
|
||||
: demo
|
||||
if (!session || !e) return <Empty text="—" />
|
||||
|
||||
const pending = pendingCount(e.enterpriseId)
|
||||
const synced = lastSyncedAt()
|
||||
|
||||
function doSync() {
|
||||
const n = syncPending(e!.enterpriseId)
|
||||
async function doSync() {
|
||||
const n = await syncPending(e!.enterpriseId)
|
||||
setJustSynced(n)
|
||||
setTimeout(() => setJustSynced(null), 2200)
|
||||
}
|
||||
@@ -76,10 +98,15 @@ export function AppProfilePage() {
|
||||
: '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"
|
||||
onClick={() => consentM.mutate(!consent)}
|
||||
disabled={consentM.isPending}
|
||||
className="mt-2.5 text-[12px] font-semibold text-risk-red border border-risk-red/30 rounded-lg px-3 py-1.5 disabled:opacity-50"
|
||||
>
|
||||
{consent ? t('profile.revoke') : hi ? 'सहमति दोबारा दें' : 'Give consent again'}
|
||||
{consentM.isPending
|
||||
? hi ? 'भेजा जा रहा है…' : 'Sending…'
|
||||
: consent
|
||||
? t('profile.revoke')
|
||||
: hi ? 'सहमति दोबारा दें' : 'Give consent again'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -96,7 +123,7 @@ export function AppProfilePage() {
|
||||
{t('profile.lastSynced')}: {synced ? new Date(synced).toLocaleString() : t('profile.never')}
|
||||
</div>
|
||||
<button
|
||||
onClick={doSync}
|
||||
onClick={() => void 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"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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'
|
||||
import { pendingCount, useAutoSync } from '../../lib/ledgerStore'
|
||||
|
||||
/*
|
||||
* Enterprise PWA shell — a phone-width frame with a forest header and a four-tab
|
||||
@@ -22,11 +22,14 @@ export function EnterpriseShell() {
|
||||
const session = enterpriseSession()
|
||||
const { lang, t } = useLang('hi')
|
||||
const offline = useOffline()
|
||||
// Queued entries go the moment the radio returns, on whichever screen the
|
||||
// owner happens to be looking at.
|
||||
useAutoSync(session?.enterpriseId)
|
||||
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)]">
|
||||
<div className="w-full max-w-[420px] bg-page 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 />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { LedgerKind } from '../../lib/api'
|
||||
import { CATEGORIES, addEntry } from '../../lib/ledgerStore'
|
||||
import { CATEGORIES, addEntry, syncPending } from '../../lib/ledgerStore'
|
||||
import { useLang } from '../../lib/i18n'
|
||||
import { inr } from '../../lib/demo/seed'
|
||||
|
||||
@@ -32,14 +33,23 @@ export function EntrySheet({
|
||||
const [amount, setAmount] = useState('')
|
||||
const [category, setCategory] = useState(CATEGORIES[kind][0].code)
|
||||
const [done, setDone] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const hi = lang === 'hi'
|
||||
const meta = KIND_META[kind]
|
||||
const value = Number(amount) || 0
|
||||
|
||||
function save() {
|
||||
if (value <= 0) return
|
||||
// Local first, always. The entry is the owner's the moment they tap save;
|
||||
// reaching the lender is our problem, not theirs.
|
||||
addEntry({ enterpriseId, kind, amount: value, category })
|
||||
setDone(true)
|
||||
// Then try to send. A failure is silent by design — the entry stays PENDING
|
||||
// and the next pass picks it up.
|
||||
void syncPending(enterpriseId).then(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-ledger'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-home'] })
|
||||
})
|
||||
setTimeout(onClose, 900)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { Building2, Gauge, Landmark, MapPinned, ShieldCheck, Siren } from 'lucide-react'
|
||||
import { Building2, Database, Gauge, Landmark, LogOut, MapPinned, ShieldCheck, Siren } from 'lucide-react'
|
||||
import { clearSessions, isRegionalOfficeRole, officerSession } from '../../lib/api'
|
||||
|
||||
const NAV = [
|
||||
@@ -8,6 +8,7 @@ const NAV = [
|
||||
{ label: 'Partner banks', to: '/nabard/banks', icon: Landmark },
|
||||
{ label: 'Early warning', to: '/nabard/digest', icon: Siren },
|
||||
{ label: 'Institutions', to: '/nabard/institutions', icon: Building2 },
|
||||
{ label: 'Data coverage', to: '/nabard/sources', icon: Database },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -17,75 +18,96 @@ const NAV = [
|
||||
* branch officer are not two ranks of one job — they see different things and
|
||||
* may do different things, and a portal that looked identical would invite the
|
||||
* assumption that the apex user is simply a bank user with more rights.
|
||||
*
|
||||
* <p>Both now sit under a fixed header, because the design system has one, but
|
||||
* the apex tier keeps a flat row of six rather than the console's grouped
|
||||
* dropdowns. Six is under the count where grouping helps, and a supervisor
|
||||
* reading across offices should see the whole of their own vocabulary at once
|
||||
* rather than open a menu to remember what is in it.
|
||||
*/
|
||||
export function NabardShell() {
|
||||
const navigate = useNavigate()
|
||||
const session = officerSession()
|
||||
const tier = isRegionalOfficeRole(session?.role) ? 'NABARD Regional Office' : 'NABARD Head Office'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-slate-50">
|
||||
<aside className="w-[210px] shrink-0 bg-[#123c2b] text-white flex flex-col px-3 py-5">
|
||||
<div className="flex items-center gap-2.5 px-2 pb-5">
|
||||
<span className="w-9 h-9 rounded-[9px] bg-[#0f6b4c] grid place-items-center shrink-0">
|
||||
<ShieldCheck size={19} />
|
||||
</span>
|
||||
<span>
|
||||
<b className="text-[16px] block leading-tight">UdyamLinc</b>
|
||||
{/*
|
||||
Head office and a regional office are both NABARD and see the same
|
||||
kinds of figures over different territory. Naming which one is
|
||||
signed in stops a supervisor reading a single office's numbers as
|
||||
the national picture.
|
||||
*/}
|
||||
<span className="text-[10.5px] text-[#bcd6cb]">
|
||||
{isRegionalOfficeRole(session?.role) ? 'NABARD Regional Office' : 'NABARD Head Office'}
|
||||
<div className="min-h-screen flex flex-col bg-page">
|
||||
<header className="sticky top-0 z-40">
|
||||
<div className="bg-forest-deep text-white flex items-center gap-4 px-4 h-14 shadow-[0_2px_14px_rgba(0,0,0,0.25)]">
|
||||
<NavLink to="/nabard" className="flex items-center gap-2.5 shrink-0">
|
||||
<span className="w-[30px] h-[30px] rounded-[9px] bg-forest-mid grid place-items-center shrink-0">
|
||||
<ShieldCheck size={17} />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="leading-tight hidden sm:block">
|
||||
<span className="block text-[14px] font-bold tracking-[-0.02em]">UdyamLinc</span>
|
||||
{/*
|
||||
Head office and a regional office are both NABARD and see the same
|
||||
kinds of figures over different territory. Naming which one is
|
||||
signed in stops a supervisor reading a single office's numbers as
|
||||
the national picture.
|
||||
*/}
|
||||
<span className="block text-[10px] text-white/55">{tier}</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{NAV.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
end={n.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[12.5px] transition-colors ${
|
||||
isActive ? 'bg-white/15 text-white font-semibold' : 'text-[#cfe2da] hover:bg-white/10'
|
||||
}`
|
||||
}
|
||||
<span className="w-px h-6 bg-white/20 hidden md:block" />
|
||||
|
||||
<nav className="flex items-center gap-0.5 min-w-0">
|
||||
{NAV.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
end={n.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-1.5 px-3 h-[34px] rounded-[8px] text-[13px] whitespace-nowrap transition-colors ${
|
||||
isActive
|
||||
? 'bg-white text-forest-deep font-semibold'
|
||||
: 'text-white/70 font-medium hover:bg-white/10 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<n.icon size={14} strokeWidth={2} />
|
||||
{n.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 shrink-0">
|
||||
<span className="hidden lg:block text-[11px] text-white/55">{session?.username}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
clearSessions()
|
||||
navigate('/login', { replace: true })
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-[11.5px] text-white/70 hover:text-white bg-white/10 hover:bg-white/20 rounded-[8px] px-2.5 py-1.5"
|
||||
>
|
||||
<n.icon size={15} />
|
||||
{n.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<LogOut size={13} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Stated in the chrome, on every screen, rather than buried in a policy
|
||||
page: this tier cannot reach a borrower. A supervisor should be able to
|
||||
tell a partner bank what this portal can see without checking.
|
||||
*/}
|
||||
<div className="mt-auto pt-4 border-t border-white/15 text-[10px] text-[#9dbfb1] leading-relaxed">
|
||||
<b className="text-[#dcebe4] block mb-0.5">Aggregate view only</b>
|
||||
This portal holds no borrower identity. Figures are counts and rates
|
||||
across refinanced portfolios; cells below the minimum size are withheld.
|
||||
<div className="bg-green-soft border-b border-line px-5 py-2 flex items-center gap-2 text-[11px] text-[#186b3d]">
|
||||
<ShieldCheck size={14} className="shrink-0" />
|
||||
<span>
|
||||
<b>Aggregate view only.</b> This portal holds no borrower identity. Figures are counts and rates
|
||||
across refinanced portfolios; cells below the minimum size are withheld.
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
clearSessions()
|
||||
navigate('/login', { replace: true })
|
||||
}}
|
||||
className="mt-3 text-[11px] text-[#9dbfb1] hover:text-white text-left px-2"
|
||||
>
|
||||
Sign out{session ? ` · ${session.username}` : ''}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 min-w-0 px-6 py-5 max-w-[1200px]">
|
||||
<main className="flex-1 min-w-0 px-5 py-4 w-full max-w-[1400px] ul-pane">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="text-center text-[10px] text-ink-3 py-3 font-mono">
|
||||
UdyamLinc · Supervisory view — Team LincFusion, LincPay Solutions Pvt. Ltd.
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
CalendarClock,
|
||||
CloudRain,
|
||||
IndianRupee,
|
||||
Landmark,
|
||||
MapPinned,
|
||||
Thermometer,
|
||||
Wheat,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { get } from '../../lib/api'
|
||||
import type {
|
||||
ConnectorProfileRow,
|
||||
ConnectorSyncRun,
|
||||
Freshness,
|
||||
IngestionRun,
|
||||
InstitutionRow,
|
||||
ManualEntryBatch,
|
||||
} from '../../lib/api'
|
||||
import { Card, KpiTile, StatusPill } from '../../components/ui/bits'
|
||||
import { PipelineStrip, SourceGroup, StatusLegend } from '../../components/ui/SourceCard'
|
||||
import type { SourceStatus } from '../../lib/sourceStatus'
|
||||
import { buildInventory, mandateCoverage } from '../../lib/sourceInventory'
|
||||
import { demoFreshness, demoIngestionRuns } from '../../lib/demo/feeds'
|
||||
import { demoProfiles, demoRuns } from '../../lib/demo/connectors'
|
||||
import { demoBatches } from '../../lib/demo/manualEntry'
|
||||
import { demoInstitutions } from '../../lib/demo/apex'
|
||||
|
||||
/**
|
||||
* What the platform is reading, seen from the apex tier.
|
||||
*
|
||||
* <p>Supervision is a question about coverage, not about operation. NABARD does
|
||||
* not run an ingestion job or map a bank's columns, so this page carries no
|
||||
* controls and no drill-through into the operational screens — it answers "is
|
||||
* the network observing what it claims to observe", and stops there.
|
||||
*
|
||||
* <p>It is also the one apex screen where showing a gap is the entire purpose.
|
||||
* A supervisor who cannot see that consented transaction data has never been
|
||||
* connected will read an alert count as complete when it is one family short.
|
||||
*
|
||||
* <p><b>The tenancy rule is unchanged and needs no exception here.</b> Every
|
||||
* figure on this page describes a feed, a table or an institution's
|
||||
* connection — never a borrower. There is no row to suppress because there is
|
||||
* no borrower row in the question.
|
||||
*/
|
||||
/** One observation-reach figure. Amber tile when the number is short of its own target. */
|
||||
function ReachTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
warn = false,
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
sub: string
|
||||
warn?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="ul-card px-3.5 py-3 flex gap-2.5">
|
||||
<span
|
||||
className={`w-8 h-8 rounded-[9px] grid place-items-center shrink-0 ${
|
||||
warn ? 'bg-amber-soft text-amber' : 'bg-green-soft text-forest'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10.5px] text-slate-500 leading-snug">{label}</div>
|
||||
<div className="text-[22px] font-bold text-forest leading-tight mt-0.5">{value}</div>
|
||||
<div className="text-[10.5px] text-slate-400 leading-snug">{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SourceCoveragePage() {
|
||||
const freshnessQ = useQuery({
|
||||
queryKey: ['freshness'],
|
||||
queryFn: () => get<Freshness[]>('/admin/ingestion/freshness'),
|
||||
})
|
||||
const runsQ = useQuery({
|
||||
queryKey: ['ingestion-runs'],
|
||||
queryFn: () => get<IngestionRun[]>('/admin/ingestion/runs', { limit: 20 }),
|
||||
})
|
||||
const profilesQ = useQuery({
|
||||
queryKey: ['connector-profiles'],
|
||||
queryFn: () => get<ConnectorProfileRow[]>('/admin/connectors/profiles'),
|
||||
})
|
||||
const connectorRunsQ = useQuery({
|
||||
queryKey: ['connector-runs'],
|
||||
queryFn: () => get<ConnectorSyncRun[]>('/admin/connectors/runs', { limit: 30 }),
|
||||
})
|
||||
const batchesQ = useQuery({
|
||||
queryKey: ['manual-batches'],
|
||||
queryFn: () => get<ManualEntryBatch[]>('/admin/manual/batches', { limit: 20 }),
|
||||
})
|
||||
const institutionsQ = useQuery({
|
||||
queryKey: ['apex-institutions'],
|
||||
queryFn: () => get<InstitutionRow[]>('/nabard/institutions'),
|
||||
})
|
||||
|
||||
const demo =
|
||||
freshnessQ.isError ||
|
||||
runsQ.isError ||
|
||||
profilesQ.isError ||
|
||||
connectorRunsQ.isError ||
|
||||
batchesQ.isError ||
|
||||
institutionsQ.isError
|
||||
|
||||
const freshness = freshnessQ.data ?? demoFreshness()
|
||||
const profiles = profilesQ.data ?? demoProfiles()
|
||||
const institutions = (institutionsQ.data ?? demoInstitutions()).filter((i) => i.type !== 'APEX')
|
||||
|
||||
const inv = buildInventory({
|
||||
freshness,
|
||||
runs: runsQ.data ?? demoIngestionRuns(),
|
||||
profiles,
|
||||
connectorRuns: connectorRunsQ.data ?? demoRuns(),
|
||||
batches: batchesQ.data ?? demoBatches(),
|
||||
// The apex tier gets the inventory, not the controls.
|
||||
withLinks: false,
|
||||
})
|
||||
|
||||
const count = (s: SourceStatus) => inv.all.filter((x) => x.status === s).length
|
||||
const mandate = mandateCoverage(inv)
|
||||
|
||||
// Keyed by the family name the builder returns, so a renamed family loses its
|
||||
// glyph rather than silently wearing the wrong one.
|
||||
const FAMILY_ICON: Record<string, LucideIcon> = {
|
||||
'Financial records': Landmark,
|
||||
'Digital transaction proxies': IndianRupee,
|
||||
'Market intelligence': Wheat,
|
||||
'Climate data': CloudRain,
|
||||
}
|
||||
|
||||
// A lender counts as connected when it has a profile that is not a simulated
|
||||
// dialect. Anything else — a simulated adapter, no profile at all — is a bank
|
||||
// whose book reaches the platform by upload, and a supervisor should see that.
|
||||
const connected = new Set(
|
||||
profiles.filter((p) => !p.simulated && p.institutionCode).map((p) => p.institutionCode as string),
|
||||
)
|
||||
const lendersConnected = institutions.filter((i) => connected.has(i.code)).length
|
||||
|
||||
const marketAges = freshness
|
||||
.map((f) => f.marketStalenessDays)
|
||||
.filter((d): d is number => d != null)
|
||||
const freshDistricts = marketAges.filter((d) => d <= 10).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-[15px] font-bold text-forest leading-tight">Data source coverage</h1>
|
||||
<p className="text-[11px] text-slate-500 max-w-[780px]">
|
||||
What the network is reading, and what it is not. Read-only: the apex tier supervises whether a
|
||||
source is connected and never operates one. Every figure here describes a feed or an
|
||||
institution’s connection — no borrower appears in this question, so none is suppressed from it.
|
||||
</p>
|
||||
</div>
|
||||
{demo && <StatusPill status="Demo data" />}
|
||||
</div>
|
||||
|
||||
<PipelineStrip />
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<KpiTile label="Sources on the inventory" value={inv.all.length} delta="across five families" />
|
||||
<KpiTile label="Live today" value={count('LIVE')} delta="real data flowing" tone="good" />
|
||||
<KpiTile
|
||||
label="Simulated"
|
||||
value={count('SIMULATED')}
|
||||
delta="stamped, never mistakable for real"
|
||||
tone="watch"
|
||||
/>
|
||||
<KpiTile label="Not connected" value={count('NOT_CONNECTED')} delta="seam built, source pending" />
|
||||
<KpiTile
|
||||
label="Lenders on a live extract"
|
||||
value={`${lendersConnected}/${institutions.length}`}
|
||||
delta="the rest deliver by upload"
|
||||
tone={lendersConnected === institutions.length ? 'good' : 'watch'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── the mandate, family by family ─────────────────────────── */}
|
||||
<Card
|
||||
title="Against the mandate"
|
||||
badge="four families"
|
||||
actions={
|
||||
<span className="text-[10.5px] text-slate-400">
|
||||
Financial records · transaction proxies · market intelligence · climate
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<table className="ul-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data family</th>
|
||||
<th>What stands behind it</th>
|
||||
<th className="text-right">Live</th>
|
||||
<th>Coverage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mandate.map((m) => {
|
||||
const pct = m.total === 0 ? 0 : Math.round((m.live / m.total) * 100)
|
||||
const tone = m.live === 0 ? 'bg-risk-red' : pct >= 60 ? 'bg-green' : 'bg-amber'
|
||||
const Icon = FAMILY_ICON[m.family]
|
||||
const tile =
|
||||
m.live === 0 ? 'bg-risk-soft text-risk-red' : 'bg-green-soft text-forest'
|
||||
return (
|
||||
<tr key={m.family}>
|
||||
<td className="font-semibold text-slate-700">
|
||||
<span className="flex items-center gap-2">
|
||||
{Icon && (
|
||||
<span className={`w-6 h-6 rounded-[7px] grid place-items-center shrink-0 ${tile}`}>
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
{m.family}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-slate-500 whitespace-normal">{m.detail}</td>
|
||||
<td className="text-right font-mono text-[11.5px]">
|
||||
{m.live}/{m.total}
|
||||
</td>
|
||||
<td className="w-[190px]">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex-1 h-[7px] rounded-full bg-slate-100 overflow-hidden">
|
||||
<span className={`block h-full rounded-full ${tone}`} style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
<span className="font-mono text-[10.5px] text-slate-500 w-8 text-right">{pct}%</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="px-3.5 py-2.5 text-[10.5px] text-slate-500 border-t border-slate-100 leading-snug">
|
||||
Consented transaction data is the one family with nothing live behind it. Account Aggregator
|
||||
registration under Sahamati is an institutional agreement rather than an engineering task, so the
|
||||
table, the query and the feature are built and the trip-wire that reads them reports{' '}
|
||||
<span className="font-mono text-[10px]">SKIPPED_MISSING</span> rather than quietly passing.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* ── observation reach ─────────────────────────────────────── */}
|
||||
<Card title="Observation reach" badge="districts & lenders">
|
||||
<div className="p-3.5 grid gap-3 sm:grid-cols-3">
|
||||
<ReachTile
|
||||
icon={MapPinned}
|
||||
label="Districts with a commodity basket"
|
||||
value={inv.districtsCovered}
|
||||
sub="39 commodities tracked nightly"
|
||||
/>
|
||||
<ReachTile
|
||||
icon={CalendarClock}
|
||||
label="Districts fresh within 10 days"
|
||||
value={
|
||||
<>
|
||||
{freshDistricts}
|
||||
<span className="text-[13px] text-slate-400 font-semibold">/{inv.districtsCovered}</span>
|
||||
</>
|
||||
}
|
||||
sub={
|
||||
inv.worstMarketAge != null
|
||||
? `oldest observation ${inv.worstMarketAge} days`
|
||||
: 'nothing ingested yet'
|
||||
}
|
||||
warn={freshDistricts < inv.districtsCovered}
|
||||
/>
|
||||
<ReachTile
|
||||
icon={Thermometer}
|
||||
label="Climate series"
|
||||
value="30 yr"
|
||||
sub="baseline behind every rainfall and heat anomaly"
|
||||
/>
|
||||
</div>
|
||||
<p className="px-3.5 pb-3 text-[10.5px] text-slate-500 leading-snug">
|
||||
A trip-wire refuses to fire on data older than its tolerance, so a district falling behind
|
||||
withholds alerts rather than issuing stale ones. That is why staleness is a supervisory figure and
|
||||
not an operational footnote.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<StatusLegend />
|
||||
|
||||
{inv.groups.map((g) => (
|
||||
<SourceGroup key={g.letter} {...g} />
|
||||
))}
|
||||
|
||||
<p className="text-[10.5px] text-slate-400 leading-snug">
|
||||
Every stored row carries its origin, and a simulated row can never overwrite a real one. Coverage is
|
||||
reported in three buckets — real, simulated and unknown — and unknown is never folded into real.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { officerSession } from '../../lib/api'
|
||||
import { fetchAlerts, fetchEnterprises, joinAlerts, 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'
|
||||
@@ -25,7 +26,20 @@ export function AlertsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { block } = useScope()
|
||||
const [filter, setFilter] = useState<AlertState | 'ALL'>('OPEN')
|
||||
const { alerts, audit } = useAlerts(block)
|
||||
// Two scoped lists, intersected: the alert carries only an enterprise id, and
|
||||
// the borrower fields come from the enterprise list, which is filtered to this
|
||||
// institution. An alert whose borrower is not on this book resolves to nothing
|
||||
// and is dropped.
|
||||
const alertsQ = useQuery({
|
||||
queryKey: ['officer-alerts', block],
|
||||
queryFn: () => fetchAlerts(block),
|
||||
})
|
||||
const entsQ = useQuery({
|
||||
queryKey: ['officer-enterprises', block],
|
||||
queryFn: () => fetchEnterprises(block),
|
||||
})
|
||||
const joined = joinAlerts(alertsQ.data ?? [], entsQ.data ?? [])
|
||||
const { alerts, audit } = useAlerts(block, joined)
|
||||
const actor = officerSession()?.fullName ?? 'Field Officer'
|
||||
|
||||
const rows = alerts.filter((a) => filter === 'ALL' || a.state === filter)
|
||||
@@ -34,6 +48,26 @@ export function AlertsPage() {
|
||||
setAlertState(alert, to, actor)
|
||||
}
|
||||
|
||||
// No demo fallback here, on purpose. The demo portfolio carries no owner, so
|
||||
// showing it would list other banks' borrowers to whoever is signed in. An
|
||||
// unreachable server is a thing to say, not a thing to paper over.
|
||||
if (alertsQ.isError || entsQ.isError) {
|
||||
return (
|
||||
<Card title="Risk alerts">
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-[13px] font-semibold text-ink">Alert queue unavailable</p>
|
||||
<p className="text-[11.5px] text-ink-3 mt-1 max-w-[46ch] mx-auto leading-snug">
|
||||
The backend is not reachable. Alerts are scoped to your own institution and are never
|
||||
reconstructed locally, so nothing is shown rather than something that might not be yours.
|
||||
</p>
|
||||
<GhostBtn className="mt-3" onClick={() => alertsQ.refetch()}>
|
||||
Try again
|
||||
</GhostBtn>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_320px] items-start">
|
||||
<div>
|
||||
@@ -80,8 +114,8 @@ export function AlertsPage() {
|
||||
</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}
|
||||
{a.enterpriseId} · {SECTORS[a.sector]?.short ?? a.sector} · {a.block} · score{' '}
|
||||
<b className="text-slate-700">{a.score}</b> · raised {a.raisedAt?.slice(0, 16).replace('T', ' ')}
|
||||
</div>
|
||||
|
||||
<p className="text-[12.5px] font-semibold text-slate-700 mt-2">{a.title}</p>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
|
||||
/**
|
||||
* Two ways to read the same book.
|
||||
*
|
||||
* <p><b>Portfolio overview</b> ranks by composite score — the register an officer
|
||||
* already knows how to read, and the view that has been demonstrated.
|
||||
* <b>Early warning</b> ranks by when the downside first stops covering the
|
||||
* instalment, which is the thing this platform can show that a bureau score
|
||||
* cannot.
|
||||
*
|
||||
* <p>They are tabs rather than one merged screen because they answer different
|
||||
* questions and sort by different keys. Merging them would mean picking one sort
|
||||
* order and losing the other.
|
||||
*/
|
||||
const TABS = [
|
||||
{ to: '/officer', label: 'Portfolio overview', end: true },
|
||||
{ to: '/officer/early-warning', label: 'Early warning', end: false },
|
||||
]
|
||||
|
||||
export function DashboardTabs() {
|
||||
return (
|
||||
<div className="flex gap-1 border-b border-line mb-4">
|
||||
{TABS.map((t) => (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.end}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'px-3.5 py-2 text-[12px] font-semibold whitespace-nowrap border-b-2 -mb-px transition-colors',
|
||||
isActive
|
||||
? 'text-lp-blue border-lp-blue'
|
||||
: 'text-ink-3 border-transparent hover:text-ink',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { get } from '../../lib/api'
|
||||
import type {
|
||||
ConnectorProfileRow,
|
||||
ConnectorSyncRun,
|
||||
Freshness,
|
||||
IngestionRun,
|
||||
ManualEntryBatch,
|
||||
} from '../../lib/api'
|
||||
import { KpiTile } from '../../components/ui/bits'
|
||||
import { PipelineStrip, SourceGroup, StatusLegend } from '../../components/ui/SourceCard'
|
||||
import type { SourceStatus } from '../../lib/sourceStatus'
|
||||
import { buildInventory } from '../../lib/sourceInventory'
|
||||
import { demoFreshness, demoIngestionRuns } from '../../lib/demo/feeds'
|
||||
import { demoProfiles, demoRuns } from '../../lib/demo/connectors'
|
||||
import { demoBatches } from '../../lib/demo/manualEntry'
|
||||
|
||||
/**
|
||||
* Every way data enters this system, on one screen, in one visual language.
|
||||
*
|
||||
* <p>The console already had three screens that each answer part of the
|
||||
* question — `/officer/feeds` for the public APIs, `/officer/integration` for
|
||||
* core banking, `/officer/data-entry` for uploads. Nobody arriving at the
|
||||
* platform for the first time can hold those three together, and the honest
|
||||
* answer to "where does your data come from" is a single inventory or it is not
|
||||
* an answer.
|
||||
*
|
||||
* <p>So every source is rendered through one card with the same four facts in
|
||||
* the same four places: how it arrives, what it writes, how often, and what it
|
||||
* last did. The only thing that varies between a live government API and an
|
||||
* unbuilt consent feed is the status pill — which is the point. A source that is
|
||||
* not connected occupies the same space as one that is, rather than being absent
|
||||
* and unnoticed.
|
||||
*/
|
||||
export function DataSourcesPage() {
|
||||
const freshnessQ = useQuery({
|
||||
queryKey: ['freshness'],
|
||||
queryFn: () => get<Freshness[]>('/admin/ingestion/freshness'),
|
||||
})
|
||||
const runsQ = useQuery({
|
||||
queryKey: ['ingestion-runs'],
|
||||
queryFn: () => get<IngestionRun[]>('/admin/ingestion/runs', { limit: 20 }),
|
||||
})
|
||||
const profilesQ = useQuery({
|
||||
queryKey: ['connector-profiles'],
|
||||
queryFn: () => get<ConnectorProfileRow[]>('/admin/connectors/profiles'),
|
||||
})
|
||||
const connectorRunsQ = useQuery({
|
||||
queryKey: ['connector-runs'],
|
||||
queryFn: () => get<ConnectorSyncRun[]>('/admin/connectors/runs', { limit: 30 }),
|
||||
})
|
||||
const batchesQ = useQuery({
|
||||
queryKey: ['manual-batches'],
|
||||
queryFn: () => get<ManualEntryBatch[]>('/admin/manual/batches', { limit: 20 }),
|
||||
})
|
||||
|
||||
const demo =
|
||||
freshnessQ.isError || runsQ.isError || profilesQ.isError || connectorRunsQ.isError || batchesQ.isError
|
||||
|
||||
const inv = buildInventory({
|
||||
freshness: freshnessQ.data ?? demoFreshness(),
|
||||
runs: runsQ.data ?? demoIngestionRuns(),
|
||||
profiles: profilesQ.data ?? demoProfiles(),
|
||||
connectorRuns: connectorRunsQ.data ?? demoRuns(),
|
||||
batches: batchesQ.data ?? demoBatches(),
|
||||
withLinks: true,
|
||||
})
|
||||
|
||||
const count = (s: SourceStatus) => inv.all.filter((x) => x.status === s).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-bold text-forest leading-tight">Data sources</h1>
|
||||
<p className="text-[11px] text-slate-500 max-w-[760px]">
|
||||
Every route by which data enters UdyamLinc — public APIs, core banking extracts, consented
|
||||
transaction aggregates, manual uploads and the borrower’s own ledger. A source that is not yet
|
||||
connected is shown here rather than left out, because the gap is part of the answer.
|
||||
</p>
|
||||
</div>
|
||||
{demo && <span className="pill pill-pend ml-auto shrink-0">Demo data</span>}
|
||||
</div>
|
||||
|
||||
<PipelineStrip />
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<KpiTile label="Sources on the inventory" value={inv.all.length} delta="across five families" />
|
||||
<KpiTile label="Live today" value={count('LIVE')} delta="real data flowing" tone="good" />
|
||||
<KpiTile
|
||||
label="Simulated"
|
||||
value={count('SIMULATED')}
|
||||
delta="stamped, never mistakable for real"
|
||||
tone="watch"
|
||||
/>
|
||||
<KpiTile label="Not connected" value={count('NOT_CONNECTED')} delta="seam built, source pending" />
|
||||
<KpiTile
|
||||
label="Districts covered"
|
||||
value={inv.districtsCovered}
|
||||
delta="commodity baskets ingested"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StatusLegend />
|
||||
|
||||
{inv.groups.map((g) => (
|
||||
<SourceGroup key={g.letter} {...g} />
|
||||
))}
|
||||
|
||||
<p className="text-[10.5px] text-slate-400 leading-snug">
|
||||
Every stored row carries its origin, and a simulated row can never overwrite a real one. Coverage is
|
||||
reported in three buckets — real, simulated and unknown — and unknown is never folded into real.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { officerSession } from '../../lib/api'
|
||||
import type { Enterprise } from '../../lib/api'
|
||||
import { enterpriseDetail, isNotFound, officerSession } from '../../lib/api'
|
||||
import type {
|
||||
Enterprise,
|
||||
FieldVisit,
|
||||
LedgerEntry,
|
||||
EnterpriseDetail,
|
||||
ForecastPointRow,
|
||||
ReasonCodeRow,
|
||||
StressScoreRow,
|
||||
} from '../../lib/api'
|
||||
import {
|
||||
ActionChip,
|
||||
Card,
|
||||
@@ -22,8 +31,6 @@ import {
|
||||
breachPoint,
|
||||
findEnterprise,
|
||||
forecastFor,
|
||||
reasonCodesFor,
|
||||
stressScoreFor,
|
||||
subScoresFor,
|
||||
tripWiresFor,
|
||||
} from '../../lib/demo/enterprises'
|
||||
@@ -43,18 +50,110 @@ const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'VISITS', label: 'Visits' },
|
||||
]
|
||||
|
||||
/** Everything the officer needs about one enterprise, in five tabs. */
|
||||
/**
|
||||
* Sector label that cannot throw.
|
||||
*
|
||||
* <p>`SECTORS[e.sector].label` reads fine until the day a sector is added to the
|
||||
* database and not to the front end, and then it takes the whole page down
|
||||
* rather than showing one unfamiliar word. The codes match today; this is so
|
||||
* they may stop matching without costing an officer their screen.
|
||||
*/
|
||||
function sectorLabel(sector: string | undefined): string {
|
||||
if (!sector) return "Unknown sector"
|
||||
return SECTORS[sector as keyof typeof SECTORS]?.label ?? sector.replace(/_/g, " ").toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the wire record with the shape this screen is written against.
|
||||
*
|
||||
* <p>They are not the same object and were never meant to be. The API stores an
|
||||
* enterprise with foreign keys — {@code sectorId}, {@code districtId} — and
|
||||
* returns the resolved {@code sector} and {@code district} as siblings, because
|
||||
* a row in a table should not carry its own joins. The screen wants one flat
|
||||
* record. Reading the API object directly gave `SECTORS[undefined]`, which threw
|
||||
* on every enterprise.
|
||||
*
|
||||
* <p>`trend` has no server equivalent yet — the six-month score history would
|
||||
* need a query across scoring runs. Until that exists it comes from the demo
|
||||
* enterprise where there is one, and otherwise flat-lines at the current score
|
||||
* rather than inventing a shape.
|
||||
*/
|
||||
function mergeEnterprise(
|
||||
detail: EnterpriseDetail | undefined,
|
||||
demo: Enterprise | undefined,
|
||||
): Enterprise | undefined {
|
||||
if (!detail) return demo
|
||||
const api = detail.enterprise as unknown as Record<string, unknown>
|
||||
const score = Number(api.score ?? demo?.score ?? 0)
|
||||
|
||||
return {
|
||||
...(demo ?? ({} as Enterprise)),
|
||||
id: Number(api.id ?? demo?.id ?? 0),
|
||||
enterpriseId: String(api.enterpriseId ?? demo?.enterpriseId ?? ''),
|
||||
name: String(api.name ?? demo?.name ?? ''),
|
||||
nameHi: String(api.nameHi ?? demo?.nameHi ?? ''),
|
||||
// Resolved names travel beside the record, not inside it.
|
||||
sector: (detail.sector ?? demo?.sector) as Enterprise['sector'],
|
||||
district: detail.district ?? demo?.district ?? '',
|
||||
block: String(api.block ?? demo?.block ?? ''),
|
||||
ownerCount: Number(api.ownerCount ?? demo?.ownerCount ?? 0),
|
||||
vintageMonths: Number(api.vintageMonths ?? demo?.vintageMonths ?? 0),
|
||||
loanOutstanding: Number(api.loanOutstanding ?? demo?.loanOutstanding ?? 0),
|
||||
monthlyInstalment: Number(api.monthlyInstalment ?? demo?.monthlyInstalment ?? 0),
|
||||
score,
|
||||
band: (api.band ?? demo?.band ?? 'STANDARD') as Enterprise['band'],
|
||||
trend: demo?.trend ?? Array(6).fill(score),
|
||||
lastUpdated: String(api.lastScoredAt ?? demo?.lastUpdated ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the officer needs about one enterprise, in five tabs.
|
||||
*
|
||||
* <p>Reads the live record. This screen used to compute the score, the
|
||||
* sub-scores and the drivers in the browser from a JavaScript copy of the rule
|
||||
* weights — which meant the "SHAP" badge sat over numbers no model had produced,
|
||||
* while the real SHAP values sat unread in `reason_code`. Everything on this page
|
||||
* now comes from the scoring run that actually happened, and the engine that
|
||||
* produced it is named on the score card.
|
||||
*/
|
||||
export function EnterpriseDetailPage() {
|
||||
const { enterpriseId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState<Tab>('OVERVIEW')
|
||||
const [scheduling, setScheduling] = useState(false)
|
||||
|
||||
const e = findEnterprise(enterpriseId)
|
||||
const { data, error, isError, isLoading } = useQuery({
|
||||
queryKey: ['enterprise-detail', enterpriseId],
|
||||
queryFn: () => enterpriseDetail(enterpriseId),
|
||||
enabled: Boolean(enterpriseId),
|
||||
})
|
||||
|
||||
// A 404 is an answer, not a failure. The tenancy model returns one for a
|
||||
// borrower on another bank's book, so falling back to demo data here would
|
||||
// show exactly the row the backend refused.
|
||||
const refused = isNotFound(error)
|
||||
const demoEnterprise = refused ? undefined : findEnterprise(enterpriseId)
|
||||
const e = mergeEnterprise(data, demoEnterprise)
|
||||
const demo = !refused && (isError || !data)
|
||||
|
||||
if (isLoading) return <Empty text="Loading…" />
|
||||
if (refused) return <Empty text={`No enterprise ${enterpriseId} on your book.`} />
|
||||
if (!e) return <Empty text={`No enterprise found for ${enterpriseId}.`} />
|
||||
|
||||
const points = forecastFor(e)
|
||||
const breach = breachPoint(points)
|
||||
// Live forecast rows where we have them; the in-file generator only where the
|
||||
// backend gave us nothing, so an unreachable API degrades rather than blanks.
|
||||
const points = data?.forecast?.length
|
||||
? data.forecast.map((p) => ({
|
||||
month: p.month,
|
||||
horizon: p.horizon,
|
||||
p10: p.p10,
|
||||
p50: p.p50,
|
||||
p90: p.p90,
|
||||
obligation: p.obligation,
|
||||
}))
|
||||
: forecastFor(demoEnterprise ?? e)
|
||||
const breach = breachPoint(points as never)
|
||||
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
|
||||
|
||||
return (
|
||||
@@ -69,12 +168,13 @@ export function EnterpriseDetailPage() {
|
||||
<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 ·{' '}
|
||||
<div className="text-[11.5px] text-ink-3">
|
||||
{e.enterpriseId} · {sectorLabel(e.sector)} · {e.block} block, {e.district} district ·{' '}
|
||||
{e.vintageMonths} months on book
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{demo && <span className="pill pill-pend">Demo data</span>}
|
||||
<PrimaryBtn onClick={() => setScheduling(true)}>Schedule visit</PrimaryBtn>
|
||||
<GhostBtn onClick={() => setTab('FORECAST')}>Full forecast</GhostBtn>
|
||||
</div>
|
||||
@@ -100,28 +200,77 @@ export function EnterpriseDetailPage() {
|
||||
))}
|
||||
</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} />}
|
||||
{tab === 'OVERVIEW' && <OverviewTab e={e} score={data?.score ?? null} reasons={data?.reasons ?? []} />}
|
||||
{tab === 'FORECAST' && <ForecastTab e={e} live={data?.forecast ?? []} />}
|
||||
{tab === 'DRIVERS' && <DriversTab e={e} score={data?.score ?? null} reasons={data?.reasons ?? []} />}
|
||||
{tab === 'LEDGER' && <LedgerTab e={e} live={data?.ledger ?? []} />}
|
||||
{tab === 'VISITS' && <VisitsTab e={e} live={data?.visits ?? []} />}
|
||||
|
||||
{scheduling && <ScheduleVisitModal enterprise={e} onClose={() => setScheduling(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewTab({ e }: { e: Enterprise }) {
|
||||
const score = stressScoreFor(e)
|
||||
const reasons = reasonCodesFor(e)
|
||||
/** How the engine that produced a score should be described, and coloured. */
|
||||
const ENGINE_LABEL: Record<string, { text: string; pill: string; note: string }> = {
|
||||
MODEL: {
|
||||
text: 'Model',
|
||||
pill: 'pill-ok',
|
||||
note: 'Gradient-boosted stress head. Drivers below are SHAP contributions.',
|
||||
},
|
||||
RULE: {
|
||||
text: 'Rules',
|
||||
pill: 'pill-info',
|
||||
note: 'Sector-weighted rule engine. Drivers are each family’s weighted share.',
|
||||
},
|
||||
RULE_FALLBACK: {
|
||||
text: 'Rules — model unreachable',
|
||||
pill: 'pill-pend',
|
||||
note: 'The run was configured for the model; the inference service could not be reached, so this score is rule-derived.',
|
||||
},
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
e,
|
||||
score,
|
||||
reasons,
|
||||
}: {
|
||||
e: Enterprise
|
||||
score: StressScoreRow | null
|
||||
reasons: ReasonCodeRow[]
|
||||
}) {
|
||||
const engine = ENGINE_LABEL[score?.engine ?? ''] ?? {
|
||||
text: score?.engine ?? 'Unknown',
|
||||
pill: 'pill-mut',
|
||||
note: 'Engine not recorded on this score.',
|
||||
}
|
||||
|
||||
const families: { key: string; label: string; value: number | null }[] = [
|
||||
{ key: 'liquidity', label: 'Liquidity', value: score?.liquidity ?? null },
|
||||
{ key: 'behavioural', label: 'Repayment behaviour', value: score?.behavioural ?? null },
|
||||
{ key: 'trendVolatility', label: 'Market volatility', value: score?.trendVolatility ?? null },
|
||||
{ key: 'obligationLoad', label: 'Obligation load', value: score?.obligationLoad ?? null },
|
||||
{ key: 'externalShock', label: 'External shock', value: score?.externalShock ?? null },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card title="Composite Stress Score" badge={score.modelVersion}>
|
||||
<Card title="Composite Stress Score" badge={score ? `run ${score.runId ?? '—'}` : 'not scored'}>
|
||||
<div className="p-4 grid place-items-center">
|
||||
<ScoreDial score={e.score} band={e.band} size={132} />
|
||||
<ScoreDial score={score?.score ?? e.score} band={(score?.band ?? e.band) as never} size={132} />
|
||||
|
||||
{/* The engine is stated on the face of the score, not in a tooltip. A
|
||||
number produced by a fallback must not look like one produced by
|
||||
the model it was configured to use. */}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<span className={`pill ${engine.pill}`}>{engine.text}</span>
|
||||
{score && <span className="pill pill-mut">{score.confidence}% confidence</span>}
|
||||
</div>
|
||||
<p className="text-[10.5px] text-ink-3 text-center leading-snug mt-2">{engine.note}</p>
|
||||
|
||||
<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>
|
||||
<Sparkline values={e.trend} band={(score?.band ?? e.band) as never} width={240} height={44} />
|
||||
<div className="text-[10px] text-ink-3 text-center mt-1">6-month score history</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -131,28 +280,64 @@ function OverviewTab({ e }: { e: Enterprise }) {
|
||||
{[
|
||||
['Loan outstanding', inr(e.loanOutstanding)],
|
||||
['Monthly instalment', inr(e.monthlyInstalment)],
|
||||
['Sector pack', SECTORS[e.sector].label],
|
||||
['Sector pack', sectorLabel(e.sector)],
|
||||
['Block / district', `${e.block} / ${e.district}`],
|
||||
['Owners on record', String(e.ownerCount)],
|
||||
['Vintage', `${e.vintageMonths} months`],
|
||||
['Last scored', score?.computedAt ? score.computedAt.slice(0, 16).replace('T', ' ') : '—'],
|
||||
].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>
|
||||
<dt className="text-ink-3">{k}</dt>
|
||||
<dd className="font-semibold text-ink num">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="px-4 pb-4">
|
||||
<div className="text-[10px] uppercase tracking-[0.07em] text-ink-3 font-bold mb-2">
|
||||
Five risk families
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{families.map((f) => (
|
||||
<div key={f.key} className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-ink-2 w-[124px] shrink-0">{f.label}</span>
|
||||
<span className="flex-1 h-[6px] rounded-full bg-surface-3 overflow-hidden">
|
||||
<span
|
||||
className={`block h-full rounded-full ${
|
||||
(f.value ?? 0) >= 65 ? 'bg-risk-red' : (f.value ?? 0) >= 35 ? 'bg-amber' : 'bg-green'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, f.value ?? 0)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="font-mono text-[10.5px] text-ink-3 w-7 text-right">
|
||||
{f.value ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Why this score" badge="SHAP">
|
||||
<Card
|
||||
title="Why this score"
|
||||
badge={score?.engine === 'MODEL' ? 'SHAP' : 'rule weights'}
|
||||
actions={<span className="text-[10.5px] text-ink-3">Top drivers, ranked</span>}
|
||||
>
|
||||
<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>
|
||||
{reasons.length === 0 && <Empty text="No reason codes stored for this enterprise." />}
|
||||
{reasons.slice(0, 5).map((r) => (
|
||||
<div key={r.id} className="flex gap-2.5">
|
||||
<span className="w-6 h-6 rounded-[7px] bg-lp-blue-soft text-lp-blue grid place-items-center text-[10.5px] font-bold shrink-0">
|
||||
{r.rank}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11.5px] font-semibold text-ink">{r.driverHi || r.driver}</span>
|
||||
<span className="font-mono text-[10px] text-ink-3">+{r.weight}</span>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-ink-2 leading-snug">{r.sentenceEn}</p>
|
||||
<p className="text-[11px] text-ink-3 leading-snug">{r.sentenceHi}</p>
|
||||
</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>
|
||||
@@ -161,11 +346,29 @@ function OverviewTab({ e }: { e: Enterprise }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ForecastTab({ e }: { e: Enterprise }) {
|
||||
const points = forecastFor(e)
|
||||
function ForecastTab({ e, live }: { e: Enterprise; live: ForecastPointRow[] }) {
|
||||
// Live rows carry the engine that produced them; the in-file generator does
|
||||
// not, so the badge can always say which one the reader is looking at.
|
||||
const engine = live.length ? live[0].engine : null
|
||||
const points = live.length
|
||||
? live.map((p) => ({
|
||||
month: p.month,
|
||||
label: monthRef(new Date(`${p.month}-01T00:00:00`)).label,
|
||||
labelHi: monthRef(new Date(`${p.month}-01T00:00:00`)).labelHi,
|
||||
p10: p.p10,
|
||||
p50: p.p50,
|
||||
p90: p.p90,
|
||||
obligation: p.obligation,
|
||||
}))
|
||||
: 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">
|
||||
<Card
|
||||
title="Net cash position — 6-month quantile forecast"
|
||||
badge={engine ? engine.toLowerCase() : "demo"}
|
||||
actions={<span className="text-[10.5px] text-ink-3">Missing inputs widen the band; they never lower its centre.</span>}
|
||||
className="lg:col-span-2"
|
||||
>
|
||||
<div className="p-4">
|
||||
<ForecastFan points={points} height={280} />
|
||||
</div>
|
||||
@@ -207,14 +410,47 @@ function ForecastTab({ e }: { e: Enterprise }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DriversTab({ e }: { e: Enterprise }) {
|
||||
const sub = subScoresFor(e)
|
||||
function DriversTab({
|
||||
e,
|
||||
score,
|
||||
reasons,
|
||||
}: {
|
||||
e: Enterprise
|
||||
score: StressScoreRow | null
|
||||
reasons: ReasonCodeRow[]
|
||||
}) {
|
||||
// The five families as the run stored them. Only where the backend gave us
|
||||
// nothing do we fall back to the in-browser approximation — and the badge says
|
||||
// which of the two you are looking at.
|
||||
const live = score != null
|
||||
const sub = live
|
||||
? {
|
||||
liquidity: score.liquidity ?? 0,
|
||||
behavioural: score.behavioural ?? 0,
|
||||
trendVolatility: score.trendVolatility ?? 0,
|
||||
obligationLoad: score.obligationLoad ?? 0,
|
||||
externalShock: score.externalShock ?? 0,
|
||||
}
|
||||
: subScoresFor(e)
|
||||
|
||||
const wires = tripWiresFor(e)
|
||||
const isModel = score?.engine === 'MODEL'
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card title="Five risk families" badge={`${SECTORS[e.sector].label} weights`}>
|
||||
<Card
|
||||
title="Five risk families"
|
||||
badge={live ? (isModel ? 'SHAP · run ' + (score?.runId ?? '—') : 'rule weights') : 'demo'}
|
||||
actions={
|
||||
<span className="text-[10.5px] text-ink-3">
|
||||
{isModel
|
||||
? 'Contribution to the model’s score'
|
||||
: 'Each family’s weighted share of the composite'}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="p-4">
|
||||
<SubScoreRadar sub={sub} band={e.band} />
|
||||
<SubScoreRadar sub={sub as never} band={(score?.band ?? e.band) as never} />
|
||||
<div className="space-y-2 mt-4">
|
||||
{DRIVER_META.map((d) => (
|
||||
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
|
||||
@@ -223,7 +459,38 @@ function DriversTab({ e }: { e: Enterprise }) {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Deterministic trip-wires" badge="rule engine">
|
||||
<Card title="Reason codes" badge={isModel ? 'SHAP sentences' : 'rule sentences'}>
|
||||
<div className="p-4 space-y-3">
|
||||
{reasons.length === 0 && <Empty text="No reason codes stored for this enterprise." />}
|
||||
{reasons.map((r) => (
|
||||
<div key={r.id} className="flex items-start gap-2.5">
|
||||
<span className="w-6 h-6 rounded-[7px] bg-lp-blue-soft text-lp-blue grid place-items-center text-[10.5px] font-bold shrink-0">
|
||||
{r.rank}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11.5px] font-semibold text-ink capitalize">{r.driver}</span>
|
||||
<span className="flex-1 h-[6px] rounded-full bg-surface-3 overflow-hidden">
|
||||
<span
|
||||
className="block h-full rounded-full bg-lp-blue"
|
||||
style={{ width: `${Math.min(100, r.weight)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="font-mono text-[10.5px] text-ink-3 w-7 text-right">+{r.weight}</span>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-ink-2 leading-snug mt-0.5">{r.sentenceEn}</p>
|
||||
<p className="text-[11px] text-ink-3 leading-snug">{r.sentenceHi}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="px-4 py-2 text-[10.5px] text-ink-3 border-t border-line leading-snug">
|
||||
Sentences are rendered once and stored, so the wording an officer acted on survives a later
|
||||
change to the templates.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="Deterministic trip-wires" badge="rule engine" className="lg:col-span-2">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="ul-table">
|
||||
<thead>
|
||||
@@ -259,8 +526,11 @@ function DriversTab({ e }: { e: Enterprise }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LedgerTab({ e }: { e: Enterprise }) {
|
||||
const entries = loadLedger(e.enterpriseId).slice(0, 40)
|
||||
function LedgerTab({ e, live }: { e: Enterprise; live: LedgerEntry[] }) {
|
||||
// Server entries first — the payload carries this borrower's last sixty,
|
||||
// tenant-checked. loadLedger reads whatever this browser happens to hold from
|
||||
// the PWA demo, which is this device's data, not the enterprise's.
|
||||
const entries = (live.length ? live : 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
|
||||
|
||||
@@ -301,8 +571,10 @@ function LedgerTab({ e }: { e: Enterprise }) {
|
||||
)
|
||||
}
|
||||
|
||||
function VisitsTab({ e }: { e: Enterprise }) {
|
||||
const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer')
|
||||
function VisitsTab({ e, live }: { e: Enterprise; live: FieldVisit[] }) {
|
||||
// The detail payload already carries this enterprise's visits, tenant-checked
|
||||
// on the way out. Locally scheduled ones merge over them.
|
||||
const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer', live)
|
||||
const mine = visits.filter((v) => v.enterpriseId === e.enterpriseId)
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,8 +4,9 @@ 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 { Unavailable } 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'
|
||||
@@ -25,12 +26,15 @@ export function EnterprisesPage() {
|
||||
const [band, setBand] = useState<Band | 'ALL'>('ALL')
|
||||
const [sector, setSector] = useState<Sector | 'ALL'>('ALL')
|
||||
|
||||
const { data } = useQuery({
|
||||
const { data, isError, refetch } = useQuery({
|
||||
queryKey: ['enterprises', block],
|
||||
queryFn: () => get<Enterprise[]>('/officer/enterprises', { block }),
|
||||
})
|
||||
|
||||
const all = data ?? scopedEnterprises(block)
|
||||
// No fallback: the demo portfolio has no owner, so it cannot be shown to a
|
||||
// specific bank. An unreachable server is stated, not substituted.
|
||||
// Memoised so the derivations below do not recompute on every render.
|
||||
const all = useMemo(() => data ?? [], [data])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
@@ -61,6 +65,8 @@ export function EnterprisesPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !data) return <Unavailable what="Enterprise register" onRetry={() => void refetch()} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Card, KpiTile } from '../../components/ui/bits'
|
||||
import { Card, Empty, KpiTile, Unavailable } from '../../components/ui/bits'
|
||||
import { useTenantEnterprises } from './useTenantData'
|
||||
import { ForecastFan } from '../../components/charts/ForecastFan'
|
||||
import { BandMigrationBar } from '../../components/charts/BandMigrationBar'
|
||||
import { bandMigration, portfolioForecast, sectorRollup } from '../../lib/demo/ops'
|
||||
@@ -12,9 +13,15 @@ import { useScope } from './officerScope'
|
||||
*/
|
||||
export function ForecastsPage() {
|
||||
const { block } = useScope()
|
||||
const aggregate = portfolioForecast(block)
|
||||
const sectors = sectorRollup(block)
|
||||
const migration = bandMigration(block)
|
||||
// Aggregates are computed over this bank's own borrowers. They used to be
|
||||
// derived from the unscoped demo book, so a portfolio chart mixed every bank.
|
||||
const { rows: book, unavailable, retry } = useTenantEnterprises(block)
|
||||
const aggregate = portfolioForecast(block, book)
|
||||
const sectors = sectorRollup(block, book)
|
||||
const migration = bandMigration(block, book)
|
||||
|
||||
if (unavailable) return <Unavailable what="Portfolio forecast" onRetry={retry} />
|
||||
if (!aggregate.length) return <Empty text="No forecast rows for this scope yet." />
|
||||
|
||||
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
|
||||
|
||||
@@ -210,10 +210,12 @@ export function IntegrationPage() {
|
||||
<p className="text-[11px] text-slate-700 leading-relaxed">
|
||||
<b>What is real and what is not.</b> The file-drop adapter reads an actual extract from a
|
||||
directory a bank delivers to, and its rows are validated, staged and written like any other.
|
||||
The Finacle, FLEXCUBE, BaNCS and in-house-LMS adapters are <b>simulated</b>: they generate an
|
||||
extract in each core's own column dialect so the mapping layer can be exercised end to end.
|
||||
A live link to any of them is agreed per bank during onboarding — the schema conversation is
|
||||
the work, and this screen is what that conversation configures.
|
||||
The CBSNexora adapter is <b>simulated</b>: no CBSNexora instance feeds this deployment, so it
|
||||
generates an extract in that platform's own field names to exercise the mapping layer end to
|
||||
end. Where a lender runs a different core, no product is named here — the per-profile mapping
|
||||
absorbs their column names, which is precisely why the platform never needs to know. A live
|
||||
link is agreed per bank during onboarding; the schema conversation is the work, and this
|
||||
screen is what that conversation configures.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,248 +1,391 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { CalendarClock, CloudRain, Database, Siren, TrendingDown } from 'lucide-react'
|
||||
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 type { ClimateSignal, Enterprise, MarketSignal, PortfolioSummary } from '../../lib/api'
|
||||
import { Unavailable } from '../../components/ui/bits'
|
||||
import { SECTORS } from '../../lib/demo/enterprises'
|
||||
import { demoClimateSignals, demoMarketSignals } from '../../lib/demo/feeds'
|
||||
import { monthRef } from '../../lib/demo/seed'
|
||||
import { exportCsv } from '../../lib/csv'
|
||||
import { useScope } from './officerScope'
|
||||
import { ScheduleVisitModal } from './ScheduleVisitModal'
|
||||
import { DashboardTabs } from './DashboardTabs'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The officer's morning screen, organised by **when trouble arrives**.
|
||||
*
|
||||
* <p>The previous version ranked by score: five tiles counting bands, then forty
|
||||
* rows sorted by composite score. That is what a bureau score already shows. The
|
||||
* thing this platform can do that a bureau cannot is say <em>when</em> — trouble
|
||||
* four months out, while intervention is still cheap — and nothing on that screen
|
||||
* expressed time at all.
|
||||
*
|
||||
* <p>So the primary axis here is the horizon at which the downside case first
|
||||
* fails to cover the instalment. The triage strip is a funnel, the chart draws
|
||||
* the intervention window, and the list is a day's work rather than a register.
|
||||
* The full register still lives at <code>/officer/enterprises</code>; this screen
|
||||
* deliberately does not duplicate it.
|
||||
*
|
||||
* <p>The old layout is kept at <code>/officer/classic</code> — it is verified and
|
||||
* this one is new, and on a demo day that switch is worth having.
|
||||
*/
|
||||
|
||||
type Bucket = 'NOW' | 'WINDOW' | 'LATER' | 'CLEAR'
|
||||
|
||||
const BUCKETS: { key: Bucket; label: string; caption: string; tone: string }[] = [
|
||||
{ key: 'NOW', label: 'Act now', caption: 'breaching this month', tone: 'gf-now' },
|
||||
{ key: 'WINDOW', label: 'Month 2–4', caption: 'the window that matters', tone: 'gf-window' },
|
||||
{ key: 'LATER', label: 'Month 5–6', caption: 'on the horizon', tone: 'gf-watch' },
|
||||
{ key: 'CLEAR', label: 'Downside covered', caption: 'no breach forecast', tone: 'gf-clear' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Which bucket a borrower falls in — **on breach horizon alone**.
|
||||
*
|
||||
* <p>Deliberately one dimension. An earlier version mixed band into the last two
|
||||
* segments, which put every WATCH-band borrower with no forecast breach into
|
||||
* "Watching" and left "Healthy" reading zero — a funnel that bulged in the middle
|
||||
* and looked broken.
|
||||
*
|
||||
* <p>Horizon also has to win over score on its own merits. A STANDARD-band
|
||||
* enterprise whose downside stops covering the instalment in four months is the
|
||||
* entire point of this product; ranking by current score would bury it under
|
||||
* borrowers already in arrears, who are already known to everyone.
|
||||
*/
|
||||
function bucketOf(e: Enterprise): Bucket {
|
||||
const h = e.firstBreachHorizon
|
||||
if (h == null) return 'CLEAR'
|
||||
if (h <= 1) return 'NOW'
|
||||
if (h <= 4) return 'WINDOW'
|
||||
return 'LATER'
|
||||
}
|
||||
|
||||
export function OfficerDashboard() {
|
||||
const navigate = useNavigate()
|
||||
const { block, month } = useScope()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<Bucket | 'ALL'>('ALL')
|
||||
const [scheduling, setScheduling] = useState<Enterprise | null>(null)
|
||||
|
||||
const { data } = useQuery({
|
||||
const { data, isError, refetch } = 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
|
||||
// No demo fallback: the demo portfolio carries no owner, so substituting it
|
||||
// would show this bank every other bank's borrowers.
|
||||
const rows = useMemo(() => data?.rows ?? [], [data])
|
||||
|
||||
const selected = useMemo(
|
||||
() => rows.find((e) => e.enterpriseId === selectedId) ?? rows[0],
|
||||
[rows, selectedId],
|
||||
)
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<Bucket, number> = { NOW: 0, WINDOW: 0, LATER: 0, CLEAR: 0 }
|
||||
rows.forEach((e) => { c[bucketOf(e)] += 1 })
|
||||
return c
|
||||
}, [rows])
|
||||
|
||||
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,
|
||||
]),
|
||||
)
|
||||
}
|
||||
// How many borrowers first breach at each horizon — the intervention window, drawn.
|
||||
const horizon = useMemo(() => {
|
||||
const h = [0, 0, 0, 0, 0, 0]
|
||||
rows.forEach((e) => {
|
||||
const n = e.firstBreachHorizon
|
||||
if (n != null && n >= 1 && n <= 6) h[n - 1] += 1
|
||||
})
|
||||
return h
|
||||
}, [rows])
|
||||
|
||||
// The day's work: soonest breach first, then worst score. Capped, because an
|
||||
// officer visits nine borrowers a week and a list of forty is a list of none.
|
||||
const worklist = useMemo(() => {
|
||||
const pool = filter === 'ALL'
|
||||
? rows.filter((e) => bucketOf(e) !== 'CLEAR')
|
||||
: rows.filter((e) => bucketOf(e) === filter)
|
||||
return [...pool]
|
||||
.sort((a, b) =>
|
||||
(a.firstBreachHorizon ?? 99) - (b.firstBreachHorizon ?? 99) || b.score - a.score)
|
||||
.slice(0, 9)
|
||||
}, [rows, filter])
|
||||
|
||||
if (isError || !data) return <Unavailable what="Portfolio" onRetry={() => void refetch()} />
|
||||
|
||||
const maxH = Math.max(1, ...horizon)
|
||||
|
||||
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"
|
||||
/>
|
||||
<DashboardTabs />
|
||||
<div className="gf">
|
||||
{/* ── triage funnel ─────────────────────────────────────
|
||||
One strip, four segments, urgency decreasing left to right. A row of
|
||||
separate tiles counting bands reads as a census; this reads as a queue. */}
|
||||
<div className="gf-triage">
|
||||
{BUCKETS.map((b) => {
|
||||
const active = filter === b.key
|
||||
return (
|
||||
<button
|
||||
key={b.key}
|
||||
onClick={() => setFilter(active ? 'ALL' : b.key)}
|
||||
className={`gf-seg ${b.tone} ${active ? 'is-on' : ''}`}
|
||||
>
|
||||
<span className="gf-seg-label">{b.label}</span>
|
||||
<span className="gf-seg-val num">{counts[b.key]}</span>
|
||||
<span className="gf-seg-cap">{b.caption}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</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 className="gf-row2">
|
||||
{/* ── lead-time chart ───────────────────────────────── */}
|
||||
<section className="gf-card">
|
||||
<header className="gf-head">
|
||||
<h2>When the book breaches</h2>
|
||||
<span>Borrowers whose downside first misses the instalment, by month</span>
|
||||
</header>
|
||||
<div className="gf-bars">
|
||||
{horizon.map((n, i) => {
|
||||
const m = monthRef(new Date(new Date().getFullYear(), new Date().getMonth() + i + 1, 1))
|
||||
const inWindow = i >= 1 && i <= 3
|
||||
return (
|
||||
<div key={m.key} className="gf-bar">
|
||||
<span className="gf-bar-n num">{n || ''}</span>
|
||||
<span className="gf-bar-track">
|
||||
<span
|
||||
className={`gf-bar-fill ${i === 0 ? 'is-now' : inWindow ? 'is-window' : ''}`}
|
||||
style={{ height: n === 0 ? "3px" : `${Math.max(14, (n / maxH) * 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="gf-bar-x">{m.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="gf-note">
|
||||
<TrendingDown size={13} />
|
||||
The middle months are where a visit still changes the outcome. {counts.WINDOW} borrowers
|
||||
sit there now — none of them has missed a payment yet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
)}
|
||||
{/* ── context ───────────────────────────────────────── */}
|
||||
<div className="gf-side">
|
||||
<FreshnessCard />
|
||||
<MarketCard />
|
||||
<EngineCard scored={rows.length} />
|
||||
</div>
|
||||
|
||||
{/* ── risk detail ─────────────────────────────────── */}
|
||||
{selected && <RiskDetail enterprise={selected} onSchedule={() => setScheduling(selected)} />}
|
||||
</div>
|
||||
|
||||
{/* ── the day's work ────────────────────────────────────── */}
|
||||
<section className="gf-card">
|
||||
<header className="gf-head">
|
||||
<h2>Today’s list</h2>
|
||||
<span>
|
||||
{filter === 'ALL'
|
||||
? 'Soonest breach first'
|
||||
: `Filtered · ${BUCKETS.find((b) => b.key === filter)?.label}`}
|
||||
{' · '}{worklist.length} of {rows.length}
|
||||
</span>
|
||||
<button
|
||||
className="gf-link"
|
||||
onClick={() =>
|
||||
exportCsv(
|
||||
`udyam-worklist-${block}-${month}.csv`,
|
||||
['Enterprise', 'Sector', 'Score', 'Band', 'Breaches', 'Top driver'],
|
||||
worklist.map((e) => [
|
||||
e.name, SECTORS[e.sector]?.label ?? e.sector, e.score, e.band,
|
||||
e.firstBreachMonth ?? 'never', e.topDriver ?? '',
|
||||
]),
|
||||
)
|
||||
}
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{worklist.length === 0 && <p className="gf-empty">Nothing outstanding in this filter.</p>}
|
||||
|
||||
<ul className="gf-list">
|
||||
{worklist.map((e) => {
|
||||
const h = e.firstBreachHorizon
|
||||
const when = e.firstBreachMonth
|
||||
? monthRef(new Date(`${e.firstBreachMonth}-01T00:00:00`)).fullEn
|
||||
: null
|
||||
return (
|
||||
<li key={e.enterpriseId} className="gf-item">
|
||||
<button
|
||||
className="gf-item-main"
|
||||
onClick={() => navigate(`/officer/enterprises/${e.enterpriseId}`)}
|
||||
>
|
||||
<span
|
||||
className={`gf-score ${
|
||||
e.band === 'RED' ? 'is-red' : e.band === 'WATCH' ? 'is-watch' : 'is-ok'
|
||||
}`}
|
||||
>
|
||||
<b className="num">{e.score}</b>
|
||||
</span>
|
||||
<span className="gf-item-body">
|
||||
<span className="gf-item-top">
|
||||
<b>{e.name}</b>
|
||||
<span className="gf-meta">
|
||||
{SECTORS[e.sector]?.short ?? e.sector} · {e.block}
|
||||
</span>
|
||||
{when ? (
|
||||
<span className={`gf-when ${h != null && h <= 1 ? 'is-now' : ''}`}>
|
||||
<CalendarClock size={12} />
|
||||
breaches {when}
|
||||
</span>
|
||||
) : (
|
||||
<span className="gf-when is-clear">downside covered</span>
|
||||
)}
|
||||
</span>
|
||||
{/* The reason the model gave, in the officer's own words. */}
|
||||
<span className="gf-why">
|
||||
{e.topDriver ?? 'No reason code stored for this run.'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button className="gf-act" onClick={() => setScheduling(e)}>
|
||||
Schedule visit
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{scheduling && <ScheduleVisitModal enterprise={scheduling} onClose={() => setScheduling(null)} />}
|
||||
</div>
|
||||
</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
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Context cards — small, and each answers one question */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function FreshnessCard() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['freshness'],
|
||||
queryFn: () =>
|
||||
get<{ districtName: string; marketStalenessDays: number | null; climateStalenessDays: number | null }[]>(
|
||||
'/admin/ingestion/freshness',
|
||||
),
|
||||
})
|
||||
const rows = data ?? []
|
||||
const worst = (k: 'marketStalenessDays' | 'climateStalenessDays') => {
|
||||
const ages = rows.map((r) => r[k]).filter((n): n is number => n != null)
|
||||
return ages.length ? Math.max(...ages) : null
|
||||
}
|
||||
const mandi = worst('marketStalenessDays')
|
||||
const climate = worst('climateStalenessDays')
|
||||
|
||||
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}
|
||||
<section className="gf-card gf-mini">
|
||||
<header className="gf-head">
|
||||
<h2><Database size={13} /> Data freshness</h2>
|
||||
</header>
|
||||
<dl className="gf-kv">
|
||||
<div>
|
||||
<dt>Mandi prices</dt>
|
||||
<dd className={mandi != null && mandi > 10 ? 'is-stale' : ''}>{mandi ?? '—'}d old</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="grid place-items-center mb-4">
|
||||
<ScoreDial score={enterprise.score} band={enterprise.band} size={116} />
|
||||
<div>
|
||||
<dt>Climate</dt>
|
||||
<dd className={climate != null && climate > 10 ? 'is-stale' : ''}>{climate ?? '—'}d old</dd>
|
||||
</div>
|
||||
<div><dt>Districts</dt><dd>{rows.length}</dd></div>
|
||||
</dl>
|
||||
<p className="gf-note gf-note-sm">A trip-wire refuses to fire on data older than its tolerance.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<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>
|
||||
function MarketCard() {
|
||||
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 market = marketQ.data ?? demoMarketSignals()
|
||||
const climate = climateQ.data ?? demoClimateSignals()
|
||||
|
||||
<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>
|
||||
const latest = new Map<string, MarketSignal>()
|
||||
for (const r of market) {
|
||||
const held = latest.get(r.commodityName)
|
||||
if (!held || r.weekStart > held.weekStart) latest.set(r.commodityName, r)
|
||||
}
|
||||
const movers = Array.from(latest.values())
|
||||
.map((m) => {
|
||||
const hist = market
|
||||
.filter((r) => r.commodityName === m.commodityName && r.weekStart < m.weekStart)
|
||||
.map((r) => r.modalPrice)
|
||||
.filter((p): p is number => p != null)
|
||||
.slice(0, 8)
|
||||
const price = m.modalPrice ?? 0
|
||||
const mean = hist.length ? hist.reduce((s, p) => s + p, 0) / hist.length : price
|
||||
return { name: m.commodityName, price, pct: mean ? ((price - mean) / mean) * 100 : 0 }
|
||||
})
|
||||
.sort((a, b) => Math.abs(b.pct) - Math.abs(a.pct))
|
||||
.slice(0, 2)
|
||||
|
||||
<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>
|
||||
const c = climate.length ? climate.reduce((a, b) => (a.weekStart > b.weekStart ? a : b)) : null
|
||||
|
||||
return (
|
||||
<section className="gf-card gf-mini">
|
||||
<header className="gf-head">
|
||||
<h2><CloudRain size={13} /> Market & climate</h2>
|
||||
</header>
|
||||
<dl className="gf-kv">
|
||||
{c && (
|
||||
<div>
|
||||
<dt>Rain vs normal</dt>
|
||||
<dd className={Math.abs(c.rainfallAnomalyPct ?? 0) > 25 ? 'is-stale' : ''}>
|
||||
{(c.rainfallAnomalyPct ?? 0) > 0 ? '+' : ''}
|
||||
{Math.round(c.rainfallAnomalyPct ?? 0)}%
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{movers.map((m) => (
|
||||
<div key={m.name}>
|
||||
<dt>{m.name.toLowerCase()}</dt>
|
||||
<dd className={Math.abs(m.pct) > 12 ? 'is-stale' : ''}>
|
||||
₹{Math.round(m.price).toLocaleString('en-IN')}
|
||||
<small>{m.pct > 0 ? ' +' : ' '}{m.pct.toFixed(0)}%</small>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EngineCard({ scored }: { scored: number }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['model-metrics'],
|
||||
queryFn: () =>
|
||||
get<{ available: boolean; modelVersion?: string; arms?: Record<string, { baseline: boolean; auc_roc?: number }> }>(
|
||||
'/admin/model/metrics',
|
||||
),
|
||||
})
|
||||
const auc = data?.arms
|
||||
? Object.entries(data.arms)
|
||||
.filter(([, a]) => !a.baseline)
|
||||
.map(([, a]) => a.auc_roc ?? 0)
|
||||
.sort((a, b) => b - a)[0]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<section className="gf-card gf-mini">
|
||||
<header className="gf-head">
|
||||
<h2><Siren size={13} /> Scoring engine</h2>
|
||||
</header>
|
||||
<dl className="gf-kv">
|
||||
<div><dt>Engine</dt><dd className="is-live">{data?.modelVersion ?? 'model'}</dd></div>
|
||||
<div><dt>Scored</dt><dd>{scored}</dd></div>
|
||||
<div><dt>Measured AUC</dt><dd>{auc ? auc.toFixed(3) : '—'}</dd></div>
|
||||
</dl>
|
||||
<p className="gf-note gf-note-sm">Labels are generated history — calibration pending.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle, Clock, Info } from 'lucide-react'
|
||||
import { enterpriseDetail, get } from '../../lib/api'
|
||||
import type { ClimateSignal, Enterprise, MarketSignal, PortfolioSummary } from '../../lib/api'
|
||||
import {
|
||||
AlertBox,
|
||||
Card,
|
||||
DriverBar,
|
||||
Empty,
|
||||
GhostBtn,
|
||||
KpiTile,
|
||||
PrimaryBtn,
|
||||
RiskBadge,
|
||||
ScoreDial,
|
||||
Sparkline,
|
||||
Unavailable,
|
||||
} from '../../components/ui/bits'
|
||||
import {
|
||||
DRIVER_META,
|
||||
SECTORS,
|
||||
breachPoint,
|
||||
forecastFor,
|
||||
} from '../../lib/demo/enterprises'
|
||||
import { useTenantAlerts, useTenantVisits } from './useTenantData'
|
||||
import type { ReasonCodeRow, StressScoreRow } from '../../lib/api'
|
||||
import { demoClimateSignals, demoMarketSignals } from '../../lib/demo/feeds'
|
||||
import { inr, monthRef } from '../../lib/demo/seed'
|
||||
import { exportCsv } from '../../lib/csv'
|
||||
import { useScope } from './officerScope'
|
||||
import { ScheduleVisitModal } from './ScheduleVisitModal'
|
||||
import { DashboardTabs } from './DashboardTabs'
|
||||
|
||||
/**
|
||||
* 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 OfficerDashboardClassic() {
|
||||
const navigate = useNavigate()
|
||||
const { block, month } = useScope()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [scheduling, setScheduling] = useState<Enterprise | null>(null)
|
||||
|
||||
const { data, isError, refetch } = useQuery({
|
||||
queryKey: ['portfolio', block, month],
|
||||
queryFn: () => get<PortfolioSummary>('/officer/portfolio', { block, month }),
|
||||
})
|
||||
|
||||
// No demo fallback. The demo portfolio has no owner on it, so substituting it
|
||||
// here would show this bank every other bank's borrowers.
|
||||
const summary = data
|
||||
// Memoised on the query result rather than on a fresh array literal, so the
|
||||
// selection below does not recompute on every render.
|
||||
const rows = useMemo(() => summary?.rows ?? [], [summary])
|
||||
|
||||
const selected = useMemo(
|
||||
() => rows.find((e) => e.enterpriseId === selectedId) ?? rows[0],
|
||||
[rows, selectedId],
|
||||
)
|
||||
|
||||
const detailQ = useQuery({
|
||||
queryKey: ["enterprise-detail", selected?.enterpriseId],
|
||||
queryFn: () => enterpriseDetail(selected!.enterpriseId),
|
||||
enabled: Boolean(selected?.enterpriseId),
|
||||
})
|
||||
|
||||
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.sector,
|
||||
e.block,
|
||||
e.score,
|
||||
e.band,
|
||||
e.loanOutstanding,
|
||||
e.monthlyInstalment,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !summary) {
|
||||
return <Unavailable what="Portfolio" onRetry={() => void refetch()} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DashboardTabs />
|
||||
{/* ── 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="grid gap-3.5 items-start grid-cols-1 xl:grid-cols-[1.75fr_1fr]">
|
||||
{/* ── enterprises by risk ─────────────────────────── */}
|
||||
<div className="min-w-0 ul-card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-line flex items-center gap-2.5">
|
||||
<h3 className="text-[13.5px] font-semibold text-ink tracking-[-0.012em]">Enterprises by risk</h3>
|
||||
<span className="text-[11.5px] text-ink-3">
|
||||
Ranked by Composite Stress Score · updated {summary.updatedMinutesAgo} min ago
|
||||
</span>
|
||||
<GhostBtn className="ml-auto !py-1 !px-2.5 !text-[11.5px]" 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 ? 'sel' : ''}`}
|
||||
>
|
||||
<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-ink">{e.name}</span>
|
||||
<span className="block font-mono text-[10px] text-ink-3">{e.enterpriseId}</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-ink-2">{SECTORS[e.sector]?.short ?? e.sector}</td>
|
||||
<td className="text-ink-2">{e.block}</td>
|
||||
<td
|
||||
className={`text-right font-bold num ${
|
||||
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-ink-3 hover:text-lp-blue 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-line text-[10.5px] text-ink-3">
|
||||
Showing the 40 highest-risk of {rows.length} — the full register is under Portfolio → Enterprises.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── risk detail ─────────────────────────────────── */}
|
||||
{selected && (
|
||||
<RiskDetail
|
||||
enterprise={selected}
|
||||
score={detailQ.data?.score ?? null}
|
||||
reasons={detailQ.data?.reasons ?? []}
|
||||
onSchedule={() => setScheduling(selected)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── what happened, what is coming, what to do ─────── */}
|
||||
<div className="grid gap-3.5 mt-3.5 grid-cols-1 lg:grid-cols-3">
|
||||
<TripWiresFired block={block} />
|
||||
<ClimateMarketOverlay />
|
||||
<YourWeek block={block} />
|
||||
</div>
|
||||
|
||||
{scheduling && <ScheduleVisitModal enterprise={scheduling} onClose={() => setScheduling(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The three panels under the register */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* What crossed a line today, as opposed to what merely scores badly.
|
||||
*
|
||||
* <p>Kept beside the score register rather than folded into it because they
|
||||
* answer different questions. A score says how stressed an enterprise is; a
|
||||
* fired trip-wire says a specific agreed threshold was crossed, and an officer
|
||||
* can be asked to justify acting on the second in a way the first does not
|
||||
* support.
|
||||
*/
|
||||
function TripWiresFired({ block }: { block: string }) {
|
||||
const navigate = useNavigate()
|
||||
const { rows, unavailable } = useTenantAlerts(block)
|
||||
const open = rows.filter((a) => a.state === "OPEN").slice(0, 3)
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Trip-wires fired today"
|
||||
actions={<span className="text-[11.5px] text-ink-3">Rule-based, independent of the model</span>}
|
||||
>
|
||||
<div className="p-4 grid gap-2.5">
|
||||
{unavailable && <Empty text="Alert queue unavailable — backend not reachable." />}
|
||||
{!unavailable && open.length === 0 && <Empty text="No trip-wire fired in this scope today." />}
|
||||
{open.map((a) => (
|
||||
<button key={a.alertId} onClick={() => navigate('/officer/alerts')} className="text-left">
|
||||
<AlertBox
|
||||
tone={a.band === 'RED' ? 'high' : 'watch'}
|
||||
glyph={a.band === 'RED' ? <AlertTriangle size={14} /> : <Clock size={14} />}
|
||||
title={a.title}
|
||||
>
|
||||
{a.enterpriseName} — {a.reason.sentenceEn}
|
||||
</AlertBox>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The outside world, in four lines.
|
||||
*
|
||||
* <p>Reads the same ingestion endpoints the feeds screen does, so a number here
|
||||
* and a number there cannot disagree. Staleness is deliberately on the face of
|
||||
* it: a mandi price is only worth reading next to how old it is.
|
||||
*/
|
||||
function ClimateMarketOverlay() {
|
||||
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 market = marketQ.data ?? demoMarketSignals()
|
||||
const climate = climateQ.data ?? demoClimateSignals()
|
||||
|
||||
// Newest row per commodity, then the two that moved most — a panel of four
|
||||
// lines should carry the two worth acting on, not the two alphabetically first.
|
||||
const latestByCommodity = new Map<string, MarketSignal>()
|
||||
for (const row of market) {
|
||||
const held = latestByCommodity.get(row.commodityName)
|
||||
if (!held || row.weekStart > held.weekStart) latestByCommodity.set(row.commodityName, row)
|
||||
}
|
||||
const movers = Array.from(latestByCommodity.values())
|
||||
.map((m) => {
|
||||
// A week with no arrivals stores a null price rather than a zero, so it has
|
||||
// to drop out of the baseline entirely — averaging it in as zero would read
|
||||
// as a crash in a commodity that simply did not trade.
|
||||
const history = market
|
||||
.filter((r) => r.commodityName === m.commodityName && r.weekStart < m.weekStart)
|
||||
.map((r) => r.modalPrice)
|
||||
.filter((p): p is number => p != null)
|
||||
.slice(0, 8)
|
||||
const price = m.modalPrice ?? 0
|
||||
const mean = history.length ? history.reduce((s, p) => s + p, 0) / history.length : price
|
||||
return { row: m, price, changePct: mean ? ((price - mean) / mean) * 100 : 0 }
|
||||
})
|
||||
.sort((a, b) => Math.abs(b.changePct) - Math.abs(a.changePct))
|
||||
.slice(0, 2)
|
||||
|
||||
const latestClimate = climate.length
|
||||
? climate.reduce((a, b) => (a.weekStart > b.weekStart ? a : b))
|
||||
: null
|
||||
|
||||
const rows: { name: string; sub: string; value: string; tone: 'high' | 'watch' | 'std' }[] = []
|
||||
|
||||
if (latestClimate) {
|
||||
const rain = latestClimate.rainfallAnomalyPct ?? 0
|
||||
rows.push({
|
||||
name: 'Rainfall anomaly',
|
||||
sub: `${latestClimate.districtName} · vs 30-year normal`,
|
||||
value: `${rain > 0 ? '+' : ''}${Math.round(rain)}%`,
|
||||
tone: Math.abs(rain) > 25 ? 'watch' : 'std',
|
||||
})
|
||||
const heat = latestClimate.heatAnomalyC ?? 0
|
||||
rows.push({
|
||||
name: 'Heat anomaly',
|
||||
sub: heat > 1.5 ? 'Poultry mortality risk' : 'Within normal range',
|
||||
value: `${heat > 0 ? '+' : ''}${heat.toFixed(1)}°C`,
|
||||
tone: heat > 1.5 ? 'watch' : 'std',
|
||||
})
|
||||
}
|
||||
|
||||
for (const m of movers) {
|
||||
rows.push({
|
||||
name: `Mandi — ${m.row.commodityName.toLowerCase()}`,
|
||||
sub: `${m.row.districtName} · vs 8-week mean`,
|
||||
value: `₹${Math.round(m.price).toLocaleString('en-IN')}`,
|
||||
tone: Math.abs(m.changePct) > 12 ? 'high' : Math.abs(m.changePct) > 5 ? 'watch' : 'std',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Climate & market overlay"
|
||||
actions={<span className="text-[11.5px] text-ink-3">The signals behind the shock family</span>}
|
||||
>
|
||||
<div className="p-4 grid gap-3">
|
||||
{rows.length === 0 && <Empty text="No signal ingested yet — run ingestion." />}
|
||||
{rows.map((r) => (
|
||||
<div key={r.name} className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-semibold text-ink">{r.name}</div>
|
||||
<div className="text-[11px] text-ink-3 truncate">{r.sub}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`pill ${r.tone === 'high' ? 'pill-bad' : r.tone === 'watch' ? 'pill-pend' : 'pill-ok'}`}
|
||||
>
|
||||
{r.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The playbook, ranked — the screen's only instruction to a person.
|
||||
*
|
||||
* <p>Everything above it describes a state. This says what to do about it, in
|
||||
* priority order, which is the difference between a dashboard an officer reads
|
||||
* and one an officer works from.
|
||||
*/
|
||||
function YourWeek({ block }: { block: string }) {
|
||||
const navigate = useNavigate()
|
||||
const { rows, unavailable } = useTenantVisits(block)
|
||||
const visits = rows
|
||||
.filter((v) => v.state !== "DONE")
|
||||
.sort((a, b) => a.priority - b.priority || a.dueDate.localeCompare(b.dueDate))
|
||||
.slice(0, 4)
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Your week"
|
||||
actions={<span className="text-[11.5px] text-ink-3">Playbook, ranked by expected impact</span>}
|
||||
>
|
||||
<div className="p-4 grid gap-3">
|
||||
{unavailable && <Empty text="Visit list unavailable — backend not reachable." />}
|
||||
{!unavailable && visits.length === 0 && <Empty text="Nothing outstanding in this scope." />}
|
||||
{visits.map((v, i) => (
|
||||
<button
|
||||
key={v.visitId}
|
||||
onClick={() => navigate('/officer/visits')}
|
||||
className="flex items-start gap-2.5 text-left group"
|
||||
>
|
||||
<span
|
||||
className={`pill ${v.priority === 1 ? 'pill-bad' : 'pill-pend'} !px-0 w-[22px] justify-center shrink-0`}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[12px] font-semibold text-ink group-hover:text-lp-blue">
|
||||
{v.enterpriseName}
|
||||
</span>
|
||||
<span className="block text-[11px] text-ink-3 truncate">
|
||||
{v.action} · {v.state === 'OVERDUE' ? 'Overdue' : `Due ${v.dueDate}`}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** Right-hand panel: score dial, top drivers, the reason sentence and two actions. */
|
||||
function RiskDetail({
|
||||
enterprise,
|
||||
score,
|
||||
reasons,
|
||||
onSchedule,
|
||||
}: {
|
||||
enterprise: Enterprise
|
||||
score: StressScoreRow | null
|
||||
reasons: ReasonCodeRow[]
|
||||
onSchedule: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// The five families as the scoring run stored them. This panel used to call
|
||||
// subScoresFor() — a JavaScript reimplementation of the old rule weights — so
|
||||
// the bars on the console's home screen were a browser-side approximation of
|
||||
// an engine that is no longer the one scoring, while the model's SHAP values
|
||||
// sat unread in reason_code.
|
||||
const sub = score
|
||||
? {
|
||||
liquidity: score.liquidity ?? 0,
|
||||
behavioural: score.behavioural ?? 0,
|
||||
trendVolatility: score.trendVolatility ?? 0,
|
||||
obligationLoad: score.obligationLoad ?? 0,
|
||||
externalShock: score.externalShock ?? 0,
|
||||
}
|
||||
: null
|
||||
const reason = reasons[0]
|
||||
const isModel = score?.engine === 'MODEL'
|
||||
const points = forecastFor(enterprise)
|
||||
const breach = breachPoint(points)
|
||||
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
|
||||
|
||||
return (
|
||||
<div className="min-w-0 ul-card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-line flex items-center gap-2.5">
|
||||
<h3 className="text-[13.5px] font-semibold text-ink tracking-[-0.012em]">Risk detail</h3>
|
||||
<span className="text-[11.5px] text-ink-3 truncate">
|
||||
{enterprise.name} · {enterprise.enterpriseId}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="grid place-items-center">
|
||||
<ScoreDial score={score?.score ?? enterprise.score} band={(score?.band ?? enterprise.band) as never} size={124} />
|
||||
</div>
|
||||
|
||||
{/* Band, sector and block as one chip row — the three facts that decide
|
||||
whether this score is even the officer's to act on. */}
|
||||
<div className="flex items-center justify-center gap-1.5 flex-wrap mt-3 mb-4">
|
||||
<RiskBadge band={enterprise.band} />
|
||||
<span className="pill pill-mut">{SECTORS[enterprise.sector].short}</span>
|
||||
<span className="pill pill-mut">{enterprise.block}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] uppercase tracking-[0.07em] text-ink-3 font-bold mb-2.5">
|
||||
Top drivers
|
||||
<span className="font-normal normal-case tracking-normal text-ink-3">
|
||||
{" "}
|
||||
· {isModel ? "SHAP contribution" : "weighted share"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4">
|
||||
{!sub && <Empty text="Not scored yet." />}
|
||||
{sub &&
|
||||
DRIVER_META.map((d) => (
|
||||
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AlertBox
|
||||
tone={enterprise.band === 'RED' ? 'high' : enterprise.band === 'WATCH' ? 'watch' : 'info'}
|
||||
glyph={<Info size={14} />}
|
||||
title="Reason code"
|
||||
>
|
||||
{reason?.sentenceEn ?? "No reason code stored for this run."}
|
||||
{breachRef && ` Shortfall expected by ${breachRef.fullEn}.`}
|
||||
{breach && (
|
||||
<b className="block mt-1 text-risk-red">
|
||||
Projected gap {inr(breach.obligation - breach.p10)} at P10
|
||||
</b>
|
||||
)}
|
||||
</AlertBox>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,224 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Bell,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
Database,
|
||||
FileSpreadsheet,
|
||||
FolderInput,
|
||||
Gauge,
|
||||
LineChart,
|
||||
LogOut,
|
||||
MapPin,
|
||||
PencilRuler,
|
||||
Search,
|
||||
Settings,
|
||||
Siren,
|
||||
Store,
|
||||
Table2,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
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 { MenuSelect } from '../../components/ui/MenuSelect'
|
||||
import { BLOCKS } from '../../lib/demo/enterprises'
|
||||
import { ALL_BLOCKS, alertsFor, weeklyVisitProgress } from '../../lib/demo/ops'
|
||||
import { ALL_BLOCKS } from '../../lib/demo/ops'
|
||||
import { useTenantAlerts, useTenantVisits } from './useTenantData'
|
||||
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.
|
||||
* Field officer console shell — fixed header, grouped dropdown navigation.
|
||||
*
|
||||
* The console had thirteen flat items down a rail, which is more than anyone
|
||||
* scans: "Data feeds", "Data entry", "Data sources" and "Core banking" are one
|
||||
* subject wearing four labels, and nothing in a flat list said so. They are now
|
||||
* four menus that name the *question* a screen answers — where does the book
|
||||
* stand, what is going wrong, what are we reading — with the screens under them.
|
||||
*
|
||||
* Fixed rather than sticky-on-scroll: an officer moves between screens far more
|
||||
* often than they scroll one, and a header that comes and goes costs a re-aim
|
||||
* every time. The scope row underneath it is part of the same furniture, because
|
||||
* block and month change what every screen below means.
|
||||
*/
|
||||
|
||||
const NAV: { label: string; to: string; end?: boolean }[] = [
|
||||
{ label: 'Dashboard', to: '/officer', end: true },
|
||||
{ label: 'Portfolio', to: '/officer/portfolio' },
|
||||
{ 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: 'Data entry', to: '/officer/data-entry' },
|
||||
{ label: 'Core banking', to: '/officer/integration' },
|
||||
{ label: 'Reports', to: '/officer/reports' },
|
||||
{ label: 'Settings', to: '/officer/settings' },
|
||||
/**
|
||||
* Icon-tile colours.
|
||||
*
|
||||
* <p>Written as literal class pairs rather than composed at runtime, because
|
||||
* Tailwind scans source text — a class built from a variable is a class that
|
||||
* never reaches the stylesheet.
|
||||
*
|
||||
* <p>Colour here is an aid to recognition, not information: the palette repeats
|
||||
* across menus and carries no meaning, so nobody can read a hue as a severity.
|
||||
* The risk scale stays reserved for bands, and only the alerts item borrows from
|
||||
* it — because there it <em>is</em> the meaning.
|
||||
*/
|
||||
const TONES = {
|
||||
blue: 'bg-[#e7f0f9] text-[#2a71b8] border-[#cfe1f3]',
|
||||
green: 'bg-[#e7f6ee] text-[#1f8b50] border-[#cfeadd]',
|
||||
teal: 'bg-[#e3f3f6] text-[#0f7d92] border-[#c9e7ec]',
|
||||
indigo: 'bg-[#eceefa] text-[#4d5bb0] border-[#d9ddf3]',
|
||||
violet: 'bg-[#f1ecf8] text-[#6f5ba0] border-[#e2d9f0]',
|
||||
amber: 'bg-[#fdf3e6] text-[#b8710f] border-[#f7e3c6]',
|
||||
rose: 'bg-[#fbeae8] text-[#b5342a] border-[#f5d4d0]',
|
||||
} as const
|
||||
|
||||
type Tone = keyof typeof TONES
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
to: string
|
||||
icon: LucideIcon
|
||||
tone: Tone
|
||||
hint: string
|
||||
end?: boolean
|
||||
/** Renders the open-alert count. Only one item claims it. */
|
||||
badge?: 'alerts'
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
/** Direct links sit beside the menus rather than inside a menu of one. */
|
||||
const HOME: NavItem = {
|
||||
label: 'Dashboard',
|
||||
to: '/officer',
|
||||
icon: Gauge,
|
||||
tone: 'blue',
|
||||
hint: 'Where the book stands today',
|
||||
end: true,
|
||||
}
|
||||
|
||||
const REPORTS: NavItem = {
|
||||
label: 'Reports',
|
||||
to: '/officer/reports',
|
||||
icon: Table2,
|
||||
tone: 'violet',
|
||||
hint: 'Supervisory pack and exports',
|
||||
}
|
||||
|
||||
const GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Portfolio',
|
||||
icon: Store,
|
||||
items: [
|
||||
{
|
||||
label: 'Sector packs',
|
||||
to: '/officer/portfolio',
|
||||
icon: Store,
|
||||
tone: 'green',
|
||||
hint: 'Dairy, poultry, food processing, handicrafts and rural retail — each modelled separately.',
|
||||
},
|
||||
{
|
||||
label: 'Enterprises',
|
||||
to: '/officer/enterprises',
|
||||
icon: Building2,
|
||||
tone: 'indigo',
|
||||
hint: 'The full register, searchable, with score, band and trend per borrower.',
|
||||
},
|
||||
{
|
||||
label: 'Forecasts',
|
||||
to: '/officer/forecasts',
|
||||
icon: LineChart,
|
||||
tone: 'teal',
|
||||
hint: 'Net cash position projected 1–6 months at P10 / P50 / P90, against the instalment due.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Risk',
|
||||
icon: Siren,
|
||||
items: [
|
||||
{
|
||||
label: 'Risk alerts',
|
||||
to: '/officer/alerts',
|
||||
icon: Siren,
|
||||
tone: 'rose',
|
||||
hint: 'Open, acknowledged, escalated and snoozed, ranked by what is closest.',
|
||||
badge: 'alerts',
|
||||
},
|
||||
{
|
||||
label: 'Trip-wires',
|
||||
to: '/officer/rules',
|
||||
icon: PencilRuler,
|
||||
tone: 'amber',
|
||||
hint: 'Every rule with its expression, and why it fired, skipped or stayed silent.',
|
||||
},
|
||||
{
|
||||
label: 'Field visits',
|
||||
to: '/officer/visits',
|
||||
icon: MapPin,
|
||||
tone: 'violet',
|
||||
hint: 'Ranked playbook, scheduling, and the outcome log that closes the loop.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Data',
|
||||
icon: Database,
|
||||
items: [
|
||||
{
|
||||
label: 'Data sources',
|
||||
to: '/officer/sources',
|
||||
icon: Database,
|
||||
tone: 'blue',
|
||||
hint: 'Every route data enters by — APIs, core banking, uploads and the ledger.',
|
||||
},
|
||||
{
|
||||
label: 'Data feeds',
|
||||
to: '/officer/feeds',
|
||||
icon: LineChart,
|
||||
tone: 'teal',
|
||||
hint: 'Mandi prices and climate signals, with the age of every observation.',
|
||||
},
|
||||
{
|
||||
label: 'Data entry',
|
||||
to: '/officer/data-entry',
|
||||
icon: FileSpreadsheet,
|
||||
tone: 'green',
|
||||
hint: 'Upload a rate sheet or a loan book — staged, previewed, then committed.',
|
||||
},
|
||||
{
|
||||
label: 'Core banking',
|
||||
to: '/officer/integration',
|
||||
icon: FolderInput,
|
||||
tone: 'indigo',
|
||||
hint: 'CBSNexora connections, per-bank field mapping and sync history.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Page title for the breadcrumb, resolved longest-prefix-first. */
|
||||
const TITLES: { to: string; title: string; sub: string }[] = [
|
||||
{ to: '/officer/portfolio', title: 'Sector risk packs', sub: 'Five rural trades modelled separately' },
|
||||
{ to: '/officer/enterprises', title: 'Enterprises', sub: 'Every borrower on your book' },
|
||||
{ to: '/officer/alerts', title: 'Risk alerts', sub: 'Early warning, ranked by what is closest' },
|
||||
{ to: '/officer/forecasts', title: 'Forecasts', sub: 'Multi-horizon cash projection with explainability' },
|
||||
{ to: '/officer/visits', title: 'Field visits', sub: 'Playbook, log and feedback loop' },
|
||||
{ to: '/officer/rules', title: 'Trip-wires', sub: 'Every rule, its expression and its outcome' },
|
||||
{ to: '/officer/sources', title: 'Data sources', sub: 'Every route by which data enters the platform' },
|
||||
{ to: '/officer/feeds', title: 'Data feeds', sub: 'Where the numbers came from, and how old they are' },
|
||||
{ to: '/officer/data-entry', title: 'Data entry', sub: 'Staged, previewed, then committed' },
|
||||
{ to: '/officer/integration', title: 'Core banking', sub: 'Connections, field mapping and sync history' },
|
||||
{ to: '/officer/reports', title: 'Reports', sub: 'Supervisory pack and model monitoring' },
|
||||
{ to: '/officer/settings', title: 'Settings', sub: 'Console preferences' },
|
||||
{ to: '/officer/early-warning', title: 'Early warning', sub: 'Ranked by when the downside stops covering the instalment' },
|
||||
{ to: '/officer', title: 'Portfolio overview', sub: 'Where the book stands today' },
|
||||
]
|
||||
|
||||
function titleFor(pathname: string) {
|
||||
return TITLES.find((t) => pathname === t.to || pathname.startsWith(`${t.to}/`)) ?? TITLES[TITLES.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Data age, in the chrome rather than on a screen someone has to go and find.
|
||||
*
|
||||
@@ -62,6 +250,197 @@ function FreshnessChip() {
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dropdown */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* One header menu. Opens on click rather than on hover — a hover menu over a
|
||||
* dense console opens itself while someone is travelling to something else, and
|
||||
* on a touch screen it has no open gesture at all.
|
||||
*/
|
||||
function NavMenu({
|
||||
group,
|
||||
open,
|
||||
onToggle,
|
||||
onClose,
|
||||
openAlerts,
|
||||
}: {
|
||||
group: NavGroup
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
onClose: () => void
|
||||
openAlerts: number
|
||||
}) {
|
||||
const { pathname } = useLocation()
|
||||
const active = group.items.some((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
||||
const Icon = group.icon
|
||||
const groupAlerts = group.items.some((i) => i.badge === 'alerts') ? openAlerts : 0
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={`flex items-center gap-1.5 px-3 h-[34px] rounded-[8px] text-[13px] whitespace-nowrap transition-colors ${
|
||||
active
|
||||
? 'bg-white text-forest font-semibold'
|
||||
: open
|
||||
? 'bg-white/15 text-white font-medium'
|
||||
: 'text-white/70 font-medium hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} strokeWidth={2} />
|
||||
{group.label}
|
||||
{groupAlerts > 0 && (
|
||||
<span className="bg-risk-red text-white text-[9.5px] font-bold px-1.5 rounded-full leading-4 min-w-[17px] text-center">
|
||||
{groupAlerts}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={13} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/*
|
||||
A mega-panel rather than a list. Each destination gets an icon tile, a
|
||||
title and a line saying what the screen answers — the four Data screens
|
||||
are the reason: "Data feeds" and "Data sources" are indistinguishable as
|
||||
bare labels and obvious as soon as each carries its sentence.
|
||||
|
||||
One width for every menu. A three-item panel rendered narrow and tall
|
||||
while the four-item one was wide and short, so each menu opened into a
|
||||
different shape and the eye had to re-find the first item every time.
|
||||
Three items now fill two columns and leave a gap, which is the cheaper
|
||||
cost.
|
||||
*/}
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="ul-menu absolute left-0 top-[calc(100%+8px)] z-50 p-2.5 w-[min(620px,calc(100vw-2rem))]"
|
||||
>
|
||||
<div className="grid gap-1 sm:grid-cols-2">
|
||||
{group.items.map((item) => {
|
||||
const ItemIcon = item.icon
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={onClose}
|
||||
role="menuitem"
|
||||
className={({ isActive }) =>
|
||||
`flex items-start gap-3 p-2.5 rounded-[9px] transition-colors ${
|
||||
isActive ? 'bg-lp-blue-soft' : 'hover:bg-surface-3'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={`w-10 h-10 rounded-[10px] grid place-items-center shrink-0 border transition-colors ${
|
||||
isActive ? 'bg-lp-blue border-lp-blue text-white' : TONES[item.tone]
|
||||
}`}
|
||||
>
|
||||
<ItemIcon size={18} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`text-[13px] font-bold leading-tight ${
|
||||
isActive ? 'text-lp-blue' : 'text-ink'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.badge === 'alerts' && openAlerts > 0 && (
|
||||
<span className="bg-risk-red text-white text-[9px] font-bold px-1.5 rounded-[4px] leading-[15px] uppercase tracking-wide">
|
||||
{openAlerts} open
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-[11.5px] text-ink-3 leading-snug mt-0.5">
|
||||
{item.hint}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The avatar menu — identity, then the two things you do to a session. */
|
||||
function UserMenu({
|
||||
name,
|
||||
role,
|
||||
initials,
|
||||
open,
|
||||
onToggle,
|
||||
onClose,
|
||||
onSignOut,
|
||||
}: {
|
||||
name: string
|
||||
role: string
|
||||
initials: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
onClose: () => void
|
||||
onSignOut: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className="flex items-center gap-2 pl-3 ml-1 border-l border-white/20 py-1"
|
||||
>
|
||||
<span className="w-[29px] h-[29px] rounded-full bg-lp-blue text-white grid place-items-center text-[10.5px] font-bold shrink-0">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="leading-tight text-left hidden lg:block">
|
||||
<span className="block text-[12px] font-semibold text-white">{name}</span>
|
||||
<span className="block text-[10px] text-white/55">{role}</span>
|
||||
</span>
|
||||
<ChevronDown size={13} className={`text-white/60 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div role="menu" className="ul-menu absolute right-0 top-[calc(100%+6px)] z-50 w-[210px] py-1.5">
|
||||
<div className="px-3 py-2 border-b border-line lg:hidden">
|
||||
<div className="text-[12.5px] font-semibold text-ink">{name}</div>
|
||||
<div className="text-[10.5px] text-ink-3">{role}</div>
|
||||
</div>
|
||||
<NavLink
|
||||
to="/officer/settings"
|
||||
onClick={onClose}
|
||||
role="menuitem"
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[12.5px] text-ink hover:bg-surface-3"
|
||||
>
|
||||
<Settings size={14} className="text-ink-3" />
|
||||
Settings
|
||||
</NavLink>
|
||||
<button
|
||||
onClick={onSignOut}
|
||||
role="menuitem"
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12.5px] text-risk-red hover:bg-risk-soft text-left"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function OfficerShell() {
|
||||
return (
|
||||
<OfficerScopeProvider>
|
||||
@@ -72,13 +451,44 @@ export function OfficerShell() {
|
||||
|
||||
function ShellBody() {
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const session = officerSession()
|
||||
const { block, month, setBlock, setMonth } = useScope()
|
||||
const openAlerts = alertsFor(block).filter((a) => a.state === 'OPEN').length
|
||||
const progress = weeklyVisitProgress(block)
|
||||
// Scoped counts. These drove a badge and a progress bar off the unscoped demo
|
||||
// portfolio, so the header told an MPGB officer how many alerts existed across
|
||||
// every bank on the platform.
|
||||
const { rows: scopedAlerts } = useTenantAlerts(block)
|
||||
const { rows: scopedVisits } = useTenantVisits(block)
|
||||
const openAlerts = scopedAlerts.filter((a) => a.state === 'OPEN').length
|
||||
const doneThisWeek = scopedVisits.filter((v) => v.state === 'DONE').length
|
||||
const progress = { done: doneThisWeek, target: Math.max(3, scopedVisits.length) }
|
||||
const months = monthWindow(4, -3).reverse() // current month first, three back
|
||||
const crumb = titleFor(pathname)
|
||||
|
||||
const initials = (session?.fullName ?? 'Field Officer')
|
||||
// One open menu at a time, addressed by label. Null means everything closed.
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null)
|
||||
const headerRef = useRef<HTMLElement>(null)
|
||||
|
||||
// Close on outside click and on Escape. Route changes close it through the
|
||||
// item's own onClick, which also covers navigating to the page you are on.
|
||||
useEffect(() => {
|
||||
if (!openMenu) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!headerRef.current?.contains(e.target as Node)) setOpenMenu(null)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpenMenu(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [openMenu])
|
||||
|
||||
const name = session?.fullName ?? 'Field Officer'
|
||||
const initials = name
|
||||
.split(' ')
|
||||
.map((w) => w[0])
|
||||
.join('')
|
||||
@@ -90,134 +500,156 @@ function ShellBody() {
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
const toggle = (id: string) => setOpenMenu((cur) => (cur === id ? null : id))
|
||||
const close = () => setOpenMenu(null)
|
||||
|
||||
const directLink = (item: NavItem) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={close}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-1.5 px-3 h-[34px] rounded-[8px] text-[13px] whitespace-nowrap transition-colors ${
|
||||
isActive
|
||||
? 'bg-white text-forest font-semibold'
|
||||
: 'text-white/70 font-medium hover:bg-white/10 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={14} strokeWidth={2} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="min-h-screen flex flex-col bg-page">
|
||||
{/* ── fixed header: brand bar with grouped menus ─────────── */}
|
||||
<header ref={headerRef} className="sticky top-0 z-40">
|
||||
<div className="bg-forest text-white flex items-center gap-4 px-4 h-14 shadow-[0_2px_14px_rgba(0,0,0,0.25)]">
|
||||
<NavLink to="/officer" onClick={close} className="flex items-center gap-2.5 shrink-0">
|
||||
<UdyamMark size={26} onDark />
|
||||
<span className="leading-tight hidden sm:block">
|
||||
<span className="block text-[14px] font-bold tracking-[-0.02em]">UdyamLinc</span>
|
||||
<span className="block text-[10px] text-white/55">Field Officer Console</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
|
||||
<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>
|
||||
<span className="w-px h-6 bg-white/20 hidden md:block" />
|
||||
|
||||
{/* 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>
|
||||
{/* No overflow on this element. An `overflow-x: auto` here scrolls the row
|
||||
on a narrow window, but it also establishes a clipping context that cuts
|
||||
off the dropdown panels — and on Windows the always-visible scrollbars
|
||||
eat into a 56px bar. Labels collapse at small widths instead. */}
|
||||
<nav className="flex items-center gap-0.5 min-w-0">
|
||||
{directLink(HOME)}
|
||||
{GROUPS.map((g) => (
|
||||
<NavMenu
|
||||
key={g.label}
|
||||
group={g}
|
||||
open={openMenu === g.label}
|
||||
onToggle={() => toggle(g.label)}
|
||||
onClose={close}
|
||||
openAlerts={openAlerts}
|
||||
/>
|
||||
))}
|
||||
{directLink(REPORTS)}
|
||||
</nav>
|
||||
|
||||
{/* ── 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>
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
<span className="hidden xl:flex items-center gap-2 bg-white/10 rounded-[8px] px-3 py-1.5 text-[12px] text-white/55 w-[210px]">
|
||||
<Search size={13} />
|
||||
Search enterprise or ID…
|
||||
</span>
|
||||
|
||||
<NavLink
|
||||
to="/officer/alerts"
|
||||
className="relative w-8 h-8 grid place-items-center rounded-full hover:bg-slate-100"
|
||||
onClick={close}
|
||||
className="relative w-8 h-8 grid place-items-center rounded-[8px] bg-white/10 hover:bg-white/20 text-white"
|
||||
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" />}
|
||||
<Bell size={15} />
|
||||
{openAlerts > 0 && (
|
||||
<span className="absolute top-1 right-1 w-[6px] h-[6px] 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>
|
||||
<UserMenu
|
||||
name={name}
|
||||
role={(session?.role ?? 'FIELD_OFFICER').replace(/_/g, ' ').toLowerCase().replace(/^./, (c) => c.toUpperCase())}
|
||||
initials={initials}
|
||||
open={openMenu === '__user'}
|
||||
onToggle={() => toggle('__user')}
|
||||
onClose={close}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── scope row: what everything below is about ─────────── */}
|
||||
<div className="bg-white border-b border-line px-5 h-[54px] flex items-center gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14.5px] font-bold text-ink leading-tight tracking-[-0.015em]">
|
||||
{crumb.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-ink-3 truncate">
|
||||
{block === ALL_BLOCKS ? (session?.branch ?? 'All blocks') : `${block} block`} ·{' '}
|
||||
{monthRef(new Date(`${month}-01T00:00:00`)).fullEn} · {crumb.sub}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-6 py-4 max-w-[1560px] w-full">
|
||||
<Outlet />
|
||||
</main>
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
{/* weekly visit progress, which used to live in the sidebar */}
|
||||
<span
|
||||
className="hidden lg:flex items-center gap-2 text-[10.5px] text-ink-3"
|
||||
title="High-risk visits done this week"
|
||||
>
|
||||
<span className="font-semibold text-ink-2 num">
|
||||
{progress.done}/{progress.target}
|
||||
</span>
|
||||
<span className="w-16 h-[5px] rounded-full bg-surface-3 overflow-hidden">
|
||||
<span
|
||||
className="block h-full rounded-full bg-green"
|
||||
style={{ width: `${Math.min(100, (progress.done / progress.target) * 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
visits
|
||||
</span>
|
||||
|
||||
<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>
|
||||
<FreshnessChip />
|
||||
|
||||
<MenuSelect
|
||||
label="Block"
|
||||
value={block}
|
||||
onChange={setBlock}
|
||||
minWidth={124}
|
||||
options={[
|
||||
{ value: ALL_BLOCKS, label: 'All blocks', hint: `${BLOCKS.length} blocks in scope` },
|
||||
...BLOCKS.map((b) => ({ value: b as string, label: b, hint: `${b} block only` })),
|
||||
]}
|
||||
/>
|
||||
|
||||
<MenuSelect
|
||||
label="Month"
|
||||
value={month}
|
||||
onChange={setMonth}
|
||||
minWidth={130}
|
||||
options={months.map((m) => ({ value: m.key, label: m.fullEn }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-5 py-4 w-full ul-pane">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="text-center text-[10px] text-ink-3 py-3 font-mono">
|
||||
UdyamLinc · Predicting stress. Protecting enterprises. — Team LincFusion, LincPay Solutions Pvt. Ltd.
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import { Card, GhostBtn, KpiTile } from '../../components/ui/bits'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ARM_LABEL, fetchModelMetrics } from '../../lib/api'
|
||||
import { Card, GhostBtn, KpiTile, Unavailable } 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 { sectorRollup } from '../../lib/demo/ops'
|
||||
import { useTenantAlerts, useTenantEnterprises, useTenantVisits } from './useTenantData'
|
||||
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.
|
||||
* MIS exports and the supervisory pack.
|
||||
* <p>Model figures are read from `model_metric` — written by the backtest that
|
||||
* produced them, and carrying the share of synthetic labels they were measured
|
||||
* against. They were four hard-coded constants until 2026-08-24.
|
||||
*/
|
||||
/** Two decimals, or an em dash. A metric that was not computed must not print 0.00. */
|
||||
function fmt(v: number | undefined): string {
|
||||
return typeof v === "number" ? v.toFixed(3) : "—"
|
||||
}
|
||||
|
||||
export function ReportsPage() {
|
||||
const { block, month } = useScope()
|
||||
const enterprises = scopedEnterprises(block)
|
||||
const alerts = alertsFor(block)
|
||||
const visits = visitsFor(block)
|
||||
// Exports carry borrower rows, so they come from the scoped API. An export is
|
||||
// the most consequential place to get tenancy wrong: a CSV leaves the building.
|
||||
const { rows: enterprises, unavailable, retry } = useTenantEnterprises(block)
|
||||
const { rows: alerts } = useTenantAlerts(block)
|
||||
const { rows: visits } = useTenantVisits(block)
|
||||
|
||||
const REPORTS: { name: string; detail: string; run: () => void }[] = [
|
||||
{
|
||||
@@ -73,48 +84,113 @@ export function ReportsPage() {
|
||||
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]),
|
||||
sectorRollup(block, enterprises).map((s) => [s.label, s.count, s.red, s.watch, s.standard, s.avgScore]),
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const metricsQ = useQuery({ queryKey: ['model-metrics'], queryFn: fetchModelMetrics })
|
||||
const metrics = metricsQ.data
|
||||
|
||||
// The winning arm is whichever non-baseline scored best by PR-AUC — the same
|
||||
// rule the trainer uses to pick, so the headline agrees with the artifact.
|
||||
const models = Object.entries(metrics?.arms ?? {}).filter(([, a]) => !a.baseline)
|
||||
const [bestKey, best] = models.sort((a, b) => (b[1].pr_auc ?? 0) - (a[1].pr_auc ?? 0))[0] ?? []
|
||||
const bestLabel = bestKey ? (ARM_LABEL[bestKey] ?? bestKey).replace('UdyamLinc — ', '') : '—'
|
||||
const syntheticPct = Math.round((metrics?.syntheticLabelShare ?? 0) * 100)
|
||||
const basePct = metrics?.datasetRows
|
||||
? ((metrics.datasetPositives ?? 0) / metrics.datasetRows * 100).toFixed(1)
|
||||
: '—'
|
||||
|
||||
if (unavailable) return <Unavailable what="Reports" onRetry={retry} />
|
||||
|
||||
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>
|
||||
{/*
|
||||
Every figure below is read from `model_metric`, written by the backtest
|
||||
that produced it. This block used to render four constants from
|
||||
`lib/demo/ops.ts` — an AUC of 0.87 nothing had computed, and an on-device
|
||||
latency for inference that does not run on a device. A juror asking "how
|
||||
did you arrive at that?" would have been shown a literal.
|
||||
*/}
|
||||
{!metrics?.available && (
|
||||
<div className="ul-card p-5">
|
||||
<p className="text-[13px] font-semibold text-ink">No backtest has been run yet</p>
|
||||
<p className="text-[11.5px] text-ink-3 mt-1 max-w-[62ch] leading-snug">
|
||||
{metrics?.message ??
|
||||
'Model quality figures appear here once a backtest has written to model_metric. Nothing is shown in the meantime — a placeholder number is how an unmeasured claim ends up on a slide.'}
|
||||
</p>
|
||||
</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>
|
||||
{metrics?.available && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<KpiTile
|
||||
label="AUC-ROC · 90-day stress"
|
||||
value={fmt(best?.auc_roc)}
|
||||
delta={`${bestLabel} · expanding-window folds`}
|
||||
tone="good"
|
||||
/>
|
||||
<KpiTile
|
||||
label="PR-AUC"
|
||||
value={fmt(best?.pr_auc)}
|
||||
delta={`base rate ${basePct}% — read this before AUC`}
|
||||
tone="watch"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Alert precision · Red band"
|
||||
value={fmt(best?.precision_red)}
|
||||
delta="cost of a false positive is one visit"
|
||||
tone="watch"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Training rows"
|
||||
value={(metrics.datasetRows ?? 0).toLocaleString('en-IN')}
|
||||
delta={`${metrics.datasetPositives} positives · ${syntheticPct}% synthetic labels`}
|
||||
tone="red"
|
||||
/>
|
||||
</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>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card
|
||||
title="Model comparison — 90-day stress detection"
|
||||
badge={metrics.modelVersion}
|
||||
actions={<span className="text-[10.5px] text-ink-3">AUC-ROC, identical folds</span>}
|
||||
>
|
||||
<div className="p-4">
|
||||
<ModelComparisonBar arms={metrics.arms ?? {}} />
|
||||
<p className="text-[10.5px] text-ink-3 mt-2 leading-snug">
|
||||
Grey bars are single features, not models. Measured on {metrics.datasetRows?.toLocaleString('en-IN')}{' '}
|
||||
rows across 21 monthly as-of dates, {metrics.datasetPositives} of them positive.{' '}
|
||||
<b className="text-ink-2">
|
||||
{syntheticPct}% of labels are generated DPD history
|
||||
</b>
|
||||
, so these figures measure the generator as much as the borrowers. Production calibration needs a
|
||||
partner bank's repayment record.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="What this does and does not show" badge="read with the numbers">
|
||||
<ul className="p-4 space-y-2.5 text-[12px] text-ink-2">
|
||||
{[
|
||||
`Validation: ${metrics.validation ?? 'expanding-window time-series split'} — no forward leakage across folds, and the point-in-time columns make that checkable rather than asserted.`,
|
||||
'PR-AUC is the honest headline at a 4% positive rate. AUC-ROC is flattered by the majority class.',
|
||||
'The two grey arms are one feature each. A model that only matches them has not yet earned its complexity.',
|
||||
'dscr_p10 and dscr_p50 are excluded from training: both read the current forecast at every past date.',
|
||||
'Labels are generated. No figure here is calibrated against a real default.',
|
||||
].map((t) => (
|
||||
<li key={t} className="flex gap-2.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-lp-blue 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">
|
||||
@@ -133,7 +209,7 @@ export function ReportsPage() {
|
||||
<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]}
|
||||
{[enterprises.length, alerts.length, visits.length, sectorRollup(block, enterprises).length][i]}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<GhostBtn onClick={r.run}>Download CSV</GhostBtn>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { useTenantEnterprises } from './useTenantData'
|
||||
import { useScope } from './officerScope'
|
||||
|
||||
/**
|
||||
@@ -19,7 +19,9 @@ import { useScope } from './officerScope'
|
||||
*/
|
||||
export function RulesPage() {
|
||||
const { block } = useScope()
|
||||
const enterprises = scopedEnterprises(block)
|
||||
// The picker lists borrowers, so it comes from the scoped API like every
|
||||
// other borrower list in the console.
|
||||
const { rows: enterprises } = useTenantEnterprises(block)
|
||||
const [enterpriseId, setEnterpriseId] = useState(enterprises[0]?.enterpriseId ?? 'MSME-4417')
|
||||
|
||||
const rulesQ = useQuery({ queryKey: ['rules'], queryFn: () => get<TripWireRule[]>('/admin/rules') })
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
import { get } from '../../lib/api'
|
||||
import type { Band, Enterprise, Sector } from '../../lib/api'
|
||||
import { Card, Empty, StatusPill } from '../../components/ui/bits'
|
||||
import { BANDS, ENTERPRISES, SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises'
|
||||
import { Unavailable } from '../../components/ui/bits'
|
||||
import { BANDS, SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises'
|
||||
|
||||
/**
|
||||
* The portfolio as a bank supervisor thinks about it: sectors first, then the
|
||||
@@ -60,12 +61,14 @@ export function SectorPortfolioPage() {
|
||||
const [term, setTerm] = useState('')
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
|
||||
const { data, isError } = useQuery({
|
||||
const { data, isError, refetch } = useQuery({
|
||||
queryKey: ['portfolio-enterprises'],
|
||||
queryFn: () => get<Enterprise[]>('/officer/enterprises'),
|
||||
})
|
||||
const demo = isError
|
||||
const all = data ?? ENTERPRISES
|
||||
// Same rule: ENTERPRISES is the unscoped demo book.
|
||||
// Memoised so the derivations below do not recompute on every render.
|
||||
const all = useMemo(() => data ?? [], [data])
|
||||
|
||||
const bySector = useMemo(() => {
|
||||
const map = new Map<Sector, Enterprise[]>()
|
||||
@@ -101,6 +104,8 @@ export function SectorPortfolioPage() {
|
||||
|
||||
const chosen = rows.find((e) => e.enterpriseId === selected) ?? null
|
||||
|
||||
if (isError || !data) return <Unavailable what="Sector portfolio" onRetry={() => void refetch()} />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── header ─────────────────────────────────────── */}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function SettingsPage() {
|
||||
type="checkbox"
|
||||
checked={packs[s]}
|
||||
onChange={(e) => setPacks({ ...packs, [s]: e.target.checked })}
|
||||
className="accent-[#0b5d3b]"
|
||||
className="accent-[#2a71b8]"
|
||||
/>
|
||||
<span className="text-[12px] text-slate-700">{SECTORS[s].label}</span>
|
||||
<span className="text-[11px] text-slate-400 ml-auto">{SECTORS[s].labelHi}</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { officerSession } from '../../lib/api'
|
||||
import { fetchVisits, officerSession } from '../../lib/api'
|
||||
import type { FieldVisit, VisitState } from '../../lib/api'
|
||||
import {
|
||||
ActionChip,
|
||||
@@ -33,7 +34,11 @@ export function VisitsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { block } = useScope()
|
||||
const officer = officerSession()?.fullName ?? 'Field Officer'
|
||||
const { visits } = useVisits(block, officer)
|
||||
const visitsQ = useQuery({
|
||||
queryKey: ['officer-visits', block],
|
||||
queryFn: () => fetchVisits(block),
|
||||
})
|
||||
const { visits } = useVisits(block, officer, visitsQ.data ?? [])
|
||||
const [filter, setFilter] = useState<VisitState | 'ALL'>('ALL')
|
||||
const [logging, setLogging] = useState<FieldVisit | null>(null)
|
||||
|
||||
@@ -133,7 +138,7 @@ function LogOutcomeModal({ visit, onClose }: { visit: FieldVisit; onClose: () =>
|
||||
const [note, setNote] = useState('')
|
||||
|
||||
function save() {
|
||||
logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset)
|
||||
logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset, visit)
|
||||
onClose()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchAlerts, fetchEnterprises, fetchVisits, joinAlerts } from '../../lib/api'
|
||||
import type { Enterprise, FieldVisit, RiskAlert } from '../../lib/api'
|
||||
|
||||
/**
|
||||
* The only sanctioned source of borrower rows in the officer console.
|
||||
*
|
||||
* <h2>Why these exist</h2>
|
||||
*
|
||||
* <p>Every screen here used to fall back to `lib/demo/*` when a request failed,
|
||||
* which reads well until you notice the demo portfolio has no owner on it: the
|
||||
* ids are generated from a formula, and `ops.ts` does not mention institutions
|
||||
* at all. So the fallback listed all 142 fabricated borrowers to whoever was
|
||||
* signed in, and a Madhya Pradesh Gramin officer was shown a Vindhya Small
|
||||
* Finance Bank borrower. Clicking it produced a 404 from the API, which was the
|
||||
* only correct part of the exchange.
|
||||
*
|
||||
* <p>Scoping the demo data was attempted and cannot be done honestly. The
|
||||
* session carries `blocks`, but blocks do not map to banks — Sehore holds both
|
||||
* MPGB and Sehore DCCB, Raisen holds both Sehore DCCB and VSFB, and MPGB's own
|
||||
* block list includes Bhopal, which is entirely VSFB.
|
||||
*
|
||||
* <h2>The rule</h2>
|
||||
*
|
||||
* <p><b>Borrower rows come from the tenant-scoped API or they do not render.</b>
|
||||
* Each hook returns `unavailable` instead of data when the server cannot be
|
||||
* reached, and the screen says so. A console that invents borrowers while the
|
||||
* server is down is worse than one that admits it cannot reach the server —
|
||||
* particularly this console, whose central claim is that a bank's book is
|
||||
* unreachable from another bank's screen.
|
||||
*
|
||||
* <p>Feeds, data sources, connectors and the rule catalogue keep their demo
|
||||
* fallbacks. Nothing in them is a borrower.
|
||||
*/
|
||||
|
||||
export interface Scoped<T> {
|
||||
rows: T
|
||||
/** The server could not be reached. Render a refusal, never substitute data. */
|
||||
unavailable: boolean
|
||||
loading: boolean
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
export function useTenantEnterprises(block: string): Scoped<Enterprise[]> {
|
||||
const q = useQuery({
|
||||
queryKey: ['officer-enterprises', block],
|
||||
queryFn: () => fetchEnterprises(block),
|
||||
})
|
||||
return { rows: q.data ?? [], unavailable: q.isError, loading: q.isLoading, retry: () => void q.refetch() }
|
||||
}
|
||||
|
||||
export function useTenantVisits(block: string): Scoped<FieldVisit[]> {
|
||||
const q = useQuery({
|
||||
queryKey: ['officer-visits', block],
|
||||
queryFn: () => fetchVisits(block),
|
||||
})
|
||||
return { rows: q.data ?? [], unavailable: q.isError, loading: q.isLoading, retry: () => void q.refetch() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Alerts, with the borrower fields the console reads.
|
||||
*
|
||||
* <p>`risk_alert` stores an enterprise id and nothing else about the borrower, so
|
||||
* the name, sector and block are joined from the enterprise list. Both lists are
|
||||
* server-scoped to the caller's institution, so intersecting them stays scoped —
|
||||
* and an alert whose borrower is not on this book resolves to nothing and is
|
||||
* dropped rather than rendered with a blank name.
|
||||
*/
|
||||
export function useTenantAlerts(block: string): Scoped<RiskAlert[]> {
|
||||
const alerts = useQuery({
|
||||
queryKey: ['officer-alerts', block],
|
||||
queryFn: () => fetchAlerts(block),
|
||||
})
|
||||
const ents = useQuery({
|
||||
queryKey: ['officer-enterprises', block],
|
||||
queryFn: () => fetchEnterprises(block),
|
||||
})
|
||||
return {
|
||||
rows: joinAlerts(alerts.data ?? [], ents.data ?? []),
|
||||
unavailable: alerts.isError || ents.isError,
|
||||
loading: alerts.isLoading || ents.isLoading,
|
||||
retry: () => {
|
||||
void alerts.refetch()
|
||||
void ents.refetch()
|
||||
},
|
||||
}
|
||||
}
|
||||
+252
-36
@@ -1,58 +1,115 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* UdyamLinc design tokens
|
||||
* Layout language: BharatLinc / CBSNexora (LincPay console family)
|
||||
* Brand palette: UdyamLinc deck — forest #0B5D3B, green #1E7A46, amber #E8A020
|
||||
* UdyamLinc design tokens — LincPay product design system
|
||||
* Source of truth: udyamlincdesign.html (brand system view).
|
||||
*
|
||||
* The palette is sampled from the LincPay logo: blue #2a71b8 carries interaction
|
||||
* (active nav, primary action, focus), green #28ae64 carries health and success,
|
||||
* and brand-deep #0b4a37 carries chrome. Risk keeps its own three-colour scale,
|
||||
* separate from brand, because a band must never be read as a brand accent.
|
||||
*
|
||||
* Existing screens address these through semantic Tailwind names — `text-forest`,
|
||||
* `bg-green-soft`, `band-watch` — so the whole console re-skins from this block
|
||||
* rather than from twenty files.
|
||||
*/
|
||||
@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;
|
||||
/* brand chrome */
|
||||
--color-forest-deep: #072e22;
|
||||
--color-forest: #0b4a37;
|
||||
--color-forest-mid: #0e5c44;
|
||||
|
||||
--font-sans: "Segoe UI", Roboto, -apple-system, "Helvetica Neue", Arial, sans-serif;
|
||||
/* interaction — LincPay blue */
|
||||
--color-lp-blue: #2a71b8;
|
||||
--color-lp-blue-d: #215a94;
|
||||
--color-lp-blue-l: #4a8dd0;
|
||||
--color-lp-blue-soft: #e9f1fa;
|
||||
|
||||
/* health — LincPay green */
|
||||
--color-green: #28ae64;
|
||||
--color-green-d: #1f8b50;
|
||||
--color-green-hi: #4cc582;
|
||||
--color-green-soft: #e7f6ee;
|
||||
|
||||
/* watch */
|
||||
--color-amber: #e08019;
|
||||
--color-amber-hi: #f0ae5c;
|
||||
--color-amber-soft: #fdf3e6;
|
||||
|
||||
/* risk */
|
||||
--color-risk-red: #c0392b;
|
||||
--color-risk-soft: #fbecea;
|
||||
|
||||
/* ink and line */
|
||||
--color-ink: #0d1418;
|
||||
--color-ink-2: #4d5a63;
|
||||
--color-ink-3: #87939c;
|
||||
--color-line: #e2e7ec;
|
||||
--color-line-s: #c8d1d9;
|
||||
--color-page: #f4f6f8;
|
||||
--color-surface-2: #fafbfc;
|
||||
--color-surface-3: #eff2f5;
|
||||
--color-tagline: #87939c;
|
||||
|
||||
--radius-card: 12px;
|
||||
|
||||
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: "Cascadia Mono", ui-monospace, "Roboto Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-[#f4f7f5] text-slate-900 font-sans text-[13px] antialiased;
|
||||
:root {
|
||||
/* Shadows are three fixed steps in the design; keep them as raw vars so a
|
||||
component can reach for one without inventing its own blur. */
|
||||
--sh: 0 1px 2px rgba(13, 20, 24, 0.06), 0 1px 3px rgba(13, 20, 24, 0.04);
|
||||
--sh2: 0 4px 14px rgba(13, 20, 24, 0.09);
|
||||
--sh3: 0 14px 36px rgba(13, 20, 24, 0.16);
|
||||
}
|
||||
|
||||
/* Modern data table — same rhythm as BharatLinc's .bl-table */
|
||||
body {
|
||||
@apply bg-page text-ink font-sans text-[13px] antialiased;
|
||||
}
|
||||
|
||||
/* Tabular figures wherever a number is compared down a column. */
|
||||
.num,
|
||||
.mn {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Data 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;
|
||||
@apply bg-surface-2 text-[10px] uppercase tracking-[0.07em] text-ink-3 text-left px-4 py-2 border-b border-line whitespace-nowrap font-bold;
|
||||
}
|
||||
.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]; }
|
||||
.ul-table tbody td { @apply px-4 py-2.5 border-b border-line whitespace-nowrap align-middle; }
|
||||
.ul-table tbody tr:last-child td { @apply border-b-0; }
|
||||
.ul-table tbody tr:hover { @apply bg-surface-2; }
|
||||
.ul-table tbody tr.sel { @apply bg-lp-blue-soft; }
|
||||
|
||||
/* 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; }
|
||||
.pill { @apply inline-flex items-center gap-1.5 text-[11px] font-bold px-2 py-0.5 rounded-[20px] leading-[1.6]; }
|
||||
.pill::before { content: ""; @apply w-[5px] h-[5px] rounded-full bg-current shrink-0; }
|
||||
.pill-ok { @apply bg-green-soft text-[#186b3d]; }
|
||||
.pill-pend { @apply bg-amber-soft text-[#8a4e05]; }
|
||||
.pill-info { @apply bg-lp-blue-soft text-lp-blue; }
|
||||
.pill-bad { @apply bg-risk-soft text-[#9c2b1f]; }
|
||||
.pill-mut { @apply bg-surface-3 text-ink-2; }
|
||||
|
||||
/* 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; }
|
||||
.band-red { @apply bg-risk-soft text-[#9c2b1f]; }
|
||||
.band-watch { @apply bg-amber-soft text-[#8a4e05]; }
|
||||
.band-standard { @apply bg-green-soft text-[#186b3d]; }
|
||||
|
||||
/* Card chrome shared by console + PWA */
|
||||
.ul-card { @apply bg-white border border-slate-200 rounded-lg; }
|
||||
.ul-card {
|
||||
@apply bg-white border border-line rounded-card;
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
/* Header dropdown panel — the officer console's grouped nav */
|
||||
.ul-menu {
|
||||
@apply bg-white border border-line rounded-card overflow-hidden;
|
||||
box-shadow: var(--sh3);
|
||||
}
|
||||
|
||||
/* Enterprise PWA: bottom tab bar sits above the home indicator */
|
||||
.ul-safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
|
||||
@@ -61,6 +118,165 @@ body {
|
||||
.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; }
|
||||
@keyframes ul-fade { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; } }
|
||||
.ul-pane { animation: ul-fade 0.18s ease; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ul-tape, .ul-blink { animation: none; }
|
||||
.ul-tape, .ul-blink, .ul-pane { animation: none; }
|
||||
}
|
||||
|
||||
|
||||
/* ==================================================================
|
||||
* Officer dashboard
|
||||
*
|
||||
* Scoped under `.gf`. The layout is the change here, not the palette:
|
||||
* the screen is organised by *when* a borrower's downside stops covering
|
||||
* the instalment, because lead time is the thing this platform can show
|
||||
* that a bureau score cannot.
|
||||
*
|
||||
* It stays light. Two dark treatments were tried and both read as a
|
||||
* different application dropped inside twenty light screens — a dashboard
|
||||
* is not a place to spend that inconsistency.
|
||||
*
|
||||
* Severity keeps the three meanings it carries everywhere else. The
|
||||
* accent stays LincPay blue.
|
||||
* ================================================================== */
|
||||
|
||||
.gf { display: flex; flex-direction: column; gap: 14px; }
|
||||
.gf .num { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── triage funnel ─────────────────────────────────────────────
|
||||
One strip, four segments, urgency decreasing left to right. A row of
|
||||
separate tiles counting bands reads as a census; this reads as a queue. */
|
||||
.gf-triage {
|
||||
display: grid; gap: 1px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
background: var(--color-line);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-card); overflow: hidden;
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
.gf-seg {
|
||||
background: #fff; padding: 15px 17px 14px; text-align: left; position: relative;
|
||||
transition: background .18s; cursor: pointer; border: none; color: inherit; font: inherit;
|
||||
}
|
||||
.gf-seg::after {
|
||||
content: ""; position: absolute; inset: auto 0 0 0; height: 3px;
|
||||
background: var(--seg, var(--color-ink-3));
|
||||
}
|
||||
.gf-seg:hover { background: var(--color-surface-2); }
|
||||
.gf-seg.is-on { background: var(--seg-soft, var(--color-surface-3)); }
|
||||
.gf-seg.is-on::after { height: 5px; }
|
||||
.gf-now { --seg: var(--color-risk-red); --seg-soft: var(--color-risk-soft); }
|
||||
.gf-window { --seg: var(--color-amber); --seg-soft: var(--color-amber-soft); }
|
||||
.gf-watch { --seg: var(--color-lp-blue); --seg-soft: var(--color-lp-blue-soft); }
|
||||
.gf-clear { --seg: var(--color-green); --seg-soft: var(--color-green-soft); }
|
||||
.gf-seg-label {
|
||||
display: block; font-size: 10px; letter-spacing: .09em; text-transform: uppercase;
|
||||
color: var(--color-ink-3); font-weight: 700;
|
||||
}
|
||||
.gf-seg-val {
|
||||
display: block; font-size: 32px; font-weight: 700; line-height: 1.05;
|
||||
letter-spacing: -.03em; margin: 5px 0 2px; color: var(--seg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.gf-seg-cap { display: block; font-size: 11.5px; color: var(--color-ink-3); }
|
||||
|
||||
/* ── cards ─────────────────────────────────────────────────────── */
|
||||
.gf-card {
|
||||
background: #fff; border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-card); padding: 16px 18px; box-shadow: var(--sh);
|
||||
}
|
||||
.gf-row2 { display: grid; gap: 14px; grid-template-columns: 1.7fr 1fr; align-items: stretch; }
|
||||
/* The chart card fills the row rather than leaving a well beneath it: the side
|
||||
column stacks three cards and is always the taller of the two. */
|
||||
.gf-row2 > .gf-card { display: flex; flex-direction: column; }
|
||||
.gf-side { display: grid; gap: 14px; align-content: start; }
|
||||
.gf-mini { padding: 14px 16px; }
|
||||
@media (max-width: 1000px) { .gf-row2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.gf-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 13px; }
|
||||
.gf-head h2 {
|
||||
font-size: 13.5px; font-weight: 600; letter-spacing: -.012em; margin: 0;
|
||||
display: flex; align-items: center; gap: 7px; color: var(--color-ink);
|
||||
}
|
||||
.gf-head span { font-size: 11.5px; color: var(--color-ink-3); }
|
||||
.gf-link {
|
||||
margin-left: auto; font-size: 11.5px; font-weight: 600; color: var(--color-lp-blue);
|
||||
background: none; border: none; cursor: pointer; padding: 0;
|
||||
}
|
||||
.gf-link:hover { text-decoration: underline; }
|
||||
|
||||
/* ── lead-time bars ────────────────────────────────────────────── */
|
||||
.gf-bars { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; flex: 1; min-height: 160px; }
|
||||
.gf-bar { display: flex; flex-direction: column; align-items: center; gap: 5px; min-height: 0; }
|
||||
.gf-bar-n { font-size: 12.5px; font-weight: 700; color: var(--color-ink-2); height: 15px; }
|
||||
.gf-bar-track {
|
||||
flex: 1; width: 100%; display: flex; align-items: flex-end;
|
||||
background: var(--color-surface-3); border-radius: 6px; overflow: hidden;
|
||||
}
|
||||
.gf-bar-fill { width: 100%; border-radius: 6px; background: var(--color-line-s); transition: height 1s cubic-bezier(.2,.8,.2,1); }
|
||||
.gf-bar-fill.is-now { background: var(--color-risk-red); }
|
||||
.gf-bar-fill.is-window { background: var(--color-amber); }
|
||||
.gf-bar-x { font-size: 10.5px; color: var(--color-ink-3); }
|
||||
|
||||
.gf-note {
|
||||
display: flex; gap: 7px; align-items: flex-start; margin: 13px 0 0;
|
||||
font-size: 11.5px; color: var(--color-ink-3); line-height: 1.5; max-width: 64ch;
|
||||
}
|
||||
.gf-note-sm { margin-top: 10px; font-size: 10.5px; }
|
||||
|
||||
/* ── key/value mini cards ──────────────────────────────────────── */
|
||||
.gf-kv { display: grid; gap: 9px; margin: 0; }
|
||||
.gf-kv > div { display: flex; align-items: baseline; gap: 10px; }
|
||||
.gf-kv dt { font-size: 11.5px; color: var(--color-ink-3); }
|
||||
.gf-kv dd {
|
||||
margin: 0 0 0 auto; font-size: 13px; font-weight: 700; color: var(--color-ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.gf-kv dd small { font-weight: 500; color: var(--color-ink-3); }
|
||||
.gf-kv dd.is-stale { color: var(--color-amber); }
|
||||
.gf-kv dd.is-live { color: var(--color-green); font-family: var(--font-mono); font-size: 11.5px; }
|
||||
|
||||
/* ── work list ─────────────────────────────────────────────────── */
|
||||
.gf-list {
|
||||
list-style: none; margin: 0; padding: 0; display: grid; gap: 1px;
|
||||
background: var(--color-line); border: 1px solid var(--color-line);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
}
|
||||
.gf-item { display: flex; align-items: stretch; gap: 12px; padding: 11px 13px; background: #fff; transition: background .15s; }
|
||||
.gf-item:hover { background: var(--color-surface-2); }
|
||||
.gf-item-main {
|
||||
flex: 1; display: flex; gap: 12px; align-items: center; text-align: left;
|
||||
background: none; border: none; color: inherit; font: inherit; cursor: pointer;
|
||||
padding: 0; min-width: 0;
|
||||
}
|
||||
.gf-score {
|
||||
width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center;
|
||||
flex: none; font-size: 14.5px; border: 1px solid;
|
||||
}
|
||||
.gf-score.is-red { background: var(--color-risk-soft); color: #9c2b1f; border-color: rgba(192,57,43,.22); }
|
||||
.gf-score.is-watch { background: var(--color-amber-soft); color: #8a4e05; border-color: rgba(224,128,25,.24); }
|
||||
.gf-score.is-ok { background: var(--color-green-soft); color: #186b3d; border-color: rgba(40,174,100,.22); }
|
||||
.gf-item-body { min-width: 0; }
|
||||
.gf-item-top { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; }
|
||||
.gf-item-top b { font-size: 13px; font-weight: 700; color: var(--color-ink); }
|
||||
.gf-meta { font-size: 11px; color: var(--color-ink-3); }
|
||||
.gf-when {
|
||||
display: inline-flex; align-items: center; gap: 4px; font-size: 10.5px; font-weight: 700;
|
||||
padding: 2px 8px; border-radius: 20px;
|
||||
background: var(--color-amber-soft); color: #8a4e05;
|
||||
}
|
||||
.gf-when.is-now { background: var(--color-risk-soft); color: #9c2b1f; }
|
||||
.gf-when.is-clear { background: var(--color-green-soft); color: #186b3d; }
|
||||
.gf-why { display: block; font-size: 11.5px; color: var(--color-ink-2); margin-top: 2px; }
|
||||
.gf-act {
|
||||
align-self: center; flex: none; font-size: 11.5px; font-weight: 600;
|
||||
padding: 7px 12px; border-radius: 8px; cursor: pointer;
|
||||
background: #fff; color: var(--color-lp-blue);
|
||||
border: 1px solid var(--color-line-s); transition: background .15s, border-color .15s;
|
||||
}
|
||||
.gf-act:hover { background: var(--color-lp-blue); border-color: var(--color-lp-blue); color: #fff; }
|
||||
.gf-empty { font-size: 12.5px; color: var(--color-ink-3); padding: 22px 0; text-align: center; }
|
||||
|
||||
.gf button:focus-visible { outline: 2px solid var(--color-lp-blue); outline-offset: 2px; }
|
||||
@media (prefers-reduced-motion: reduce) { .gf-bar-fill { transition: none; } }
|
||||
|
||||
+15
-3
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { AlertState, RiskAlert } from './api'
|
||||
import { alertsFor } from './demo/ops'
|
||||
|
||||
/*
|
||||
* Officer actions on the alert queue.
|
||||
@@ -51,7 +50,19 @@ export function setAlertState(alert: RiskAlert, to: AlertState, actor: string, n
|
||||
}
|
||||
|
||||
/** The alert queue for a block, with any officer actions applied on top. */
|
||||
export function useAlerts(block: string) {
|
||||
/**
|
||||
* Live alerts with local dispositions overlaid.
|
||||
*
|
||||
* <p>The rows are supplied by the caller, never fetched here, and there is no
|
||||
* demo fallback: `/officer/alerts` filters through the caller.s own institution
|
||||
* before returning anything, while the in-file demo portfolio has no owner on it
|
||||
* at all. This hook used to call `alertsFor(block)` directly, which listed
|
||||
* borrowers from every bank on the platform to whoever was signed in.
|
||||
*
|
||||
* <p>Acknowledge / escalate / snooze still live in local storage — they are demo
|
||||
* gestures, not writes — so they are merged over whatever the server returned.
|
||||
*/
|
||||
export function useAlerts(block: string, source: RiskAlert[]) {
|
||||
const [version, setVersion] = useState(0)
|
||||
|
||||
const bump = useCallback(() => setVersion((v) => v + 1), [])
|
||||
@@ -64,7 +75,8 @@ export function useAlerts(block: string) {
|
||||
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 }))
|
||||
void block
|
||||
const alerts = source.map((a) => ({ ...a, state: states[a.alertId] ?? a.state }))
|
||||
|
||||
return { alerts, audit: readAudit() }
|
||||
}
|
||||
|
||||
+417
-3
@@ -1,8 +1,43 @@
|
||||
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'
|
||||
declare global {
|
||||
interface Window {
|
||||
__UDYAM_CONFIG__?: { apiBaseUrl?: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the API lives — resolved at runtime, not frozen into the bundle.
|
||||
*
|
||||
* <p>Precedence, highest first:
|
||||
*
|
||||
* 1. `window.__UDYAM_CONFIG__.apiBaseUrl` from `public/config.js`, which is served
|
||||
* unbundled and can be edited on the server. This is what lets the backend be
|
||||
* deployed first and the UI pointed at it afterwards, without a rebuild.
|
||||
* 2. `VITE_UDYAM_API_BASE_URL`, inlined at build time — still useful for `npm run
|
||||
* dev` against a local backend.
|
||||
* 3. `/api/v1/udyam` — relative, so the bundle carries no hostname, works from any
|
||||
* domain, and is same-origin with the API that nginx or httpd proxies. Being
|
||||
* same-origin also means CORS never applies, which removes the largest single
|
||||
* source of works-locally-fails-deployed.
|
||||
*
|
||||
* <p>An empty or whitespace-only value counts as unset. A deployment that writes
|
||||
* `apiBaseUrl: ""` into config.js means "use the default", and treating that as a
|
||||
* real base would send every request to the page's own URL.
|
||||
*/
|
||||
function resolveApiBase(): string {
|
||||
const runtime = window.__UDYAM_CONFIG__?.apiBaseUrl
|
||||
if (typeof runtime === 'string' && runtime.trim() !== '') {
|
||||
return runtime.trim().replace(/\/+$/, '')
|
||||
}
|
||||
const compiled = import.meta.env.VITE_UDYAM_API_BASE_URL
|
||||
if (typeof compiled === 'string' && compiled.trim() !== '') {
|
||||
return compiled.trim().replace(/\/+$/, '')
|
||||
}
|
||||
return '/api/v1/udyam'
|
||||
}
|
||||
|
||||
export const API_BASE = resolveApiBase()
|
||||
|
||||
/** 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 })
|
||||
@@ -169,6 +204,11 @@ export interface Enterprise {
|
||||
band: Band
|
||||
trend: number[] // 6-month score history, oldest first
|
||||
lastUpdated: string
|
||||
/** Months until the downside case first misses the instalment; null if never. */
|
||||
firstBreachHorizon?: number | null
|
||||
firstBreachMonth?: string | null
|
||||
topDriver?: string | null
|
||||
topDriverHi?: string | null
|
||||
}
|
||||
|
||||
/** One month of the quantile forecast. Amounts are net cash position in ₹. */
|
||||
@@ -780,3 +820,377 @@ export interface BranchCell {
|
||||
stressRatePct?: number
|
||||
avgScore?: number
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Enterprise detail — the live shape of GET /officer/enterprises/{id} */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The stress score as the backend stores it: five flat sub-score columns rather
|
||||
* than a nested object, plus the two fields that say where the number came from.
|
||||
*
|
||||
* <p>`engine` is the one to read carefully. `MODEL` means the gradient-boosted
|
||||
* head produced it; `RULE` means the rule engine did; `RULE_FALLBACK` means the
|
||||
* run was configured for the model and the inference service could not be
|
||||
* reached, so this particular score is rule-derived. A screen that showed all
|
||||
* three as "score" would be hiding exactly the distinction an auditor needs.
|
||||
*/
|
||||
export interface StressScoreRow {
|
||||
enterpriseId: string
|
||||
score: number
|
||||
band: Band
|
||||
engine: string
|
||||
confidence: number
|
||||
liquidity: number | null
|
||||
behavioural: number | null
|
||||
trendVolatility: number | null
|
||||
obligationLoad: number | null
|
||||
externalShock: number | null
|
||||
computedAt: string | null
|
||||
runId: number | null
|
||||
}
|
||||
|
||||
/** One SHAP driver as the backend stores it, sentence already rendered. */
|
||||
export interface ReasonCodeRow {
|
||||
id: number
|
||||
runId: number
|
||||
enterpriseId: string
|
||||
driver: string
|
||||
driverHi: string
|
||||
weight: number
|
||||
rank: number
|
||||
sentenceEn: string
|
||||
sentenceHi: string
|
||||
}
|
||||
|
||||
export interface ForecastPointRow {
|
||||
horizon: number
|
||||
month: string
|
||||
p10: number
|
||||
p50: number
|
||||
p90: number
|
||||
obligation: number
|
||||
breach: boolean
|
||||
engine: string
|
||||
modelVersion: string
|
||||
missingInputs: number
|
||||
}
|
||||
|
||||
export interface EnterpriseDetail {
|
||||
enterprise: Enterprise
|
||||
sector: string
|
||||
district: string
|
||||
score: StressScoreRow | null
|
||||
reasons: ReasonCodeRow[]
|
||||
forecast: ForecastPointRow[]
|
||||
alerts: RiskAlert[]
|
||||
visits: FieldVisit[]
|
||||
ledger: LedgerEntry[]
|
||||
}
|
||||
|
||||
export async function enterpriseDetail(enterpriseId: string): Promise<EnterpriseDetail> {
|
||||
const wire = await get<EnterpriseDetail>(`/officer/enterprises/${enterpriseId}`)
|
||||
// The server stores `syncedAt` (a timestamp) and has no `syncState` column, so
|
||||
// these rows never satisfied the type they were declared as. Anything on the
|
||||
// server is by definition synced; mapping here keeps that in one place rather
|
||||
// than leaving each screen to discover the mismatch by crashing.
|
||||
return {
|
||||
...wire,
|
||||
ledger: (wire.ledger ?? []).map((row) => toLedgerEntry(row as unknown as Record<string, unknown>)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Did the backend answer, and answer "no"?
|
||||
*
|
||||
* <p>The console's house pattern is to fall back to in-file demo data when a
|
||||
* request fails, so a screen still reads with the backend down. That is right
|
||||
* for a connection error and **wrong for a 404**: cross-tenant reads return 404
|
||||
* deliberately, indistinguishable from a fabricated id, and falling back to demo
|
||||
* data there would render a borrower the caller is forbidden to see. A refusal
|
||||
* has to be honoured, not papered over.
|
||||
*/
|
||||
export function isNotFound(e: unknown): boolean {
|
||||
return (e as { response?: { status?: number } })?.response?.status === 404
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tenant-scoped reads */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Borrower-bearing endpoints.
|
||||
*
|
||||
* <p>Every one of these is filtered server-side through the caller's own
|
||||
* institution before a row is returned. **There is no demo fallback for any of
|
||||
* them, deliberately.** The in-file demo portfolio has no owner on it — the ids
|
||||
* are generated from a formula — so a screen falling back to it renders
|
||||
* borrowers from every bank at once. That happened, and it is the one failure
|
||||
* this platform cannot afford: the whole apex tier rests on a bank's book being
|
||||
* unreachable from another bank's console.
|
||||
*
|
||||
* <p>When these fail the screen says the portfolio is unavailable. A console
|
||||
* that invents borrowers when the server is down is worse than one that admits
|
||||
* it cannot reach the server.
|
||||
*/
|
||||
export function fetchAlerts(block: string, state?: string) {
|
||||
return get<RiskAlertRow[]>("/officer/alerts", { block, state })
|
||||
}
|
||||
|
||||
export function fetchVisits(block: string) {
|
||||
return get<FieldVisit[]>('/officer/visits', { block })
|
||||
}
|
||||
|
||||
/** Ids on this officer's book that have a phone-app login. */
|
||||
export function fetchAppEnrolled(block: string) {
|
||||
return get<string[]>('/officer/app-enrolled', { block })
|
||||
}
|
||||
|
||||
export function fetchEnterprises(block: string) {
|
||||
return get<Enterprise[]>('/officer/enterprises', { block })
|
||||
}
|
||||
|
||||
/** The alert exactly as the backend stores it — flat reason, no borrower join. */
|
||||
export interface RiskAlertRow {
|
||||
id: number
|
||||
alertId: string
|
||||
enterpriseId: string
|
||||
runId: number | null
|
||||
band: Band
|
||||
score: number
|
||||
origin: AlertOrigin
|
||||
ruleId: number | null
|
||||
title: string
|
||||
titleHi: string | null
|
||||
reasonEn: string | null
|
||||
reasonHi: string | null
|
||||
shortfall: number | null
|
||||
breachMonth: string | null
|
||||
state: AlertState
|
||||
raisedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Give an alert the borrower fields the console reads.
|
||||
*
|
||||
* <p>`risk_alert` stores an enterprise id and nothing else about the borrower —
|
||||
* correct, because a row should not duplicate a join — and it renders the reason
|
||||
* as two flat strings rather than a nested object. The alert screen wants a name,
|
||||
* a sector and `reason.sentenceEn`. Reading the wire record directly gave
|
||||
* `undefined.sentenceEn` and `SECTORS[undefined]`, which threw.
|
||||
*
|
||||
* <p>The join source is `/officer/enterprises`, which is scoped to the caller's
|
||||
* own institution — so an alert whose borrower is not on this book resolves to
|
||||
* nothing and is dropped rather than rendered with a blank name. Two scoped
|
||||
* lists intersected stay scoped.
|
||||
*/
|
||||
export function joinAlerts(rows: RiskAlertRow[], enterprises: Enterprise[]): RiskAlert[] {
|
||||
const byId = new Map(enterprises.map((e) => [e.enterpriseId, e]))
|
||||
return rows
|
||||
.map((a) => {
|
||||
const e = byId.get(a.enterpriseId)
|
||||
if (!e) return null
|
||||
return {
|
||||
id: a.id,
|
||||
alertId: a.alertId,
|
||||
enterpriseId: a.enterpriseId,
|
||||
enterpriseName: e.name,
|
||||
sector: e.sector,
|
||||
block: e.block,
|
||||
band: a.band,
|
||||
score: a.score,
|
||||
origin: a.origin,
|
||||
title: a.title,
|
||||
titleHi: a.titleHi ?? a.title,
|
||||
reason: {
|
||||
driver: '',
|
||||
driverHi: '',
|
||||
weight: 0,
|
||||
sentenceEn: a.reasonEn ?? '',
|
||||
sentenceHi: a.reasonHi ?? '',
|
||||
},
|
||||
shortfall: a.shortfall ?? 0,
|
||||
breachMonth: a.breachMonth ?? '',
|
||||
state: a.state,
|
||||
raisedAt: a.raisedAt,
|
||||
} satisfies RiskAlert
|
||||
})
|
||||
.filter((a): a is RiskAlert => a !== null)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Model metrics — what the backtest actually measured */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** One arm of the comparison: a trained model, or a single-feature baseline. */
|
||||
export interface MetricArm {
|
||||
baseline: boolean
|
||||
auc_roc?: number
|
||||
pr_auc?: number
|
||||
precision_red?: number
|
||||
recall_red?: number
|
||||
brier?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest backtest, as `model_metric` stored it.
|
||||
*
|
||||
* <p>`available: false` when no backtest has been run — the console says so in
|
||||
* words rather than falling back to a number, which is how a hard-coded 0.87
|
||||
* survived on this page for as long as it did.
|
||||
*
|
||||
* <p>`syntheticLabelShare` is not a footnote. Every DPD label on this book is
|
||||
* generated, so a metric measured against it measures the generator as much as
|
||||
* the borrowers. It arrives on the same payload as the values so the page cannot
|
||||
* render one without the other.
|
||||
*/
|
||||
export interface ModelMetrics {
|
||||
available: boolean
|
||||
message?: string
|
||||
modelVersion?: string
|
||||
computedAt?: string
|
||||
validation?: string
|
||||
datasetRows?: number
|
||||
datasetPositives?: number
|
||||
syntheticLabelShare?: number
|
||||
arms?: Record<string, MetricArm>
|
||||
}
|
||||
|
||||
export function fetchModelMetrics() {
|
||||
return get<ModelMetrics>('/admin/model/metrics')
|
||||
}
|
||||
|
||||
/** Human labels for the arms the backtest writes. */
|
||||
export const ARM_LABEL: Record<string, string> = {
|
||||
lightgbm: 'UdyamLinc — LightGBM, full signal set',
|
||||
xgboost: 'UdyamLinc — XGBoost, full signal set',
|
||||
obligation_ratio_only: 'Baseline — instalment ÷ inflow alone',
|
||||
repayment_history_only: 'Baseline — current DPD alone',
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Enterprise app — the borrower's own surface */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* `/app/**` is scoped to one enterprise by the token itself: the backend reads
|
||||
* the enterprise id from the JWT subject, not from a parameter, so a borrower
|
||||
* cannot ask for anyone else's record even by editing a request.
|
||||
*
|
||||
* <p>The wire shape differs from the console's in the same way it does
|
||||
* everywhere: `enterprise` carries foreign keys rather than resolved names, and
|
||||
* `advice` arrives as two flat strings.
|
||||
*/
|
||||
export interface AppHomeWire {
|
||||
enterprise: Record<string, unknown>
|
||||
alert: RiskAlertRow | null
|
||||
forecast: ForecastPointRow[]
|
||||
todayIn: number
|
||||
todayOut: number
|
||||
adviceEn: string | null
|
||||
adviceHi: string | null
|
||||
lastSyncedAt: string | null
|
||||
}
|
||||
|
||||
export function fetchAppHome() {
|
||||
return get<AppHomeWire>('/app/home')
|
||||
}
|
||||
|
||||
export function fetchAppLedger() {
|
||||
return get<Record<string, unknown>[]>('/app/ledger')
|
||||
}
|
||||
|
||||
/** The owner's own reason codes, newest run only. Scoped by the token. */
|
||||
export function fetchAppReasons() {
|
||||
return get<ReasonCodeRow[]>('/app/reasons')
|
||||
}
|
||||
|
||||
export function fetchAppAlerts() {
|
||||
return get<RiskAlertRow[]>('/app/alerts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Push locally queued entries.
|
||||
*
|
||||
* <p>The offline queue is the point of this app: an owner records a sale with no
|
||||
* signal and it reaches the server when one returns. `clientId` is minted on the
|
||||
* phone so an entry keeps its identity across the sync and cannot be duplicated
|
||||
* by a retry.
|
||||
*/
|
||||
export interface SyncResult { accepted: number; duplicates: number; syncedAt: string }
|
||||
|
||||
export function syncAppLedger(entries: LedgerEntry[]) {
|
||||
return post<SyncResult>(
|
||||
'/app/ledger/sync',
|
||||
entries.map((e) => ({
|
||||
clientId: e.id,
|
||||
kind: e.kind,
|
||||
amount: e.amount,
|
||||
category: e.category,
|
||||
note: e.note,
|
||||
entryDate: e.entryDate,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant or withdraw consent for transaction aggregates.
|
||||
*
|
||||
* <p>A real write, not a UI toggle. The consent artefact is the thing that makes
|
||||
* reading a borrower's inflows lawful, so it has to live on the server and be
|
||||
* revocable from the borrower's own device.
|
||||
*/
|
||||
export function setAppConsent(active: boolean) {
|
||||
return put<Record<string, unknown>>('/app/consent', { active })
|
||||
}
|
||||
|
||||
/**
|
||||
* Server ledger row → the shape the app is written against.
|
||||
*
|
||||
* <p>The two differ where it matters: the server's `id` is a database key and
|
||||
* its `clientId` is the identity the phone minted, which is the one the app
|
||||
* dedupes on. Mapping `clientId` onto `id` is what lets a synced entry replace
|
||||
* its local copy instead of appearing twice.
|
||||
*/
|
||||
export function toLedgerEntry(wire: Record<string, unknown>): LedgerEntry {
|
||||
return {
|
||||
id: String(wire.clientId ?? wire.id),
|
||||
enterpriseId: String(wire.enterpriseId ?? ''),
|
||||
kind: (wire.kind ?? 'IN') as LedgerKind,
|
||||
amount: Number(wire.amount ?? 0),
|
||||
category: String(wire.category ?? ''),
|
||||
note: (wire.note as string | null) ?? null,
|
||||
entryDate: String(wire.entryDate ?? ''),
|
||||
syncState: 'SYNCED',
|
||||
createdAt: String(wire.createdAt ?? wire.syncedAt ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Borrower calls with an explicit token ────────────────────────────
|
||||
//
|
||||
// The shared `api` instance picks its JWT from `window.location.pathname`, which
|
||||
// is right for the two portals and wrong for the demo page: it lives at /demo and
|
||||
// would send the officer's token to /app/*. Its 401 handler would then clear both
|
||||
// sessions and bounce to the sign-in page — during a live demo.
|
||||
//
|
||||
// So borrower calls made from outside /app go through their own instance: no
|
||||
// interceptors, token passed in, failures handled by the caller.
|
||||
|
||||
const rawApi = axios.create({ baseURL: API_BASE, timeout: 6_000 })
|
||||
|
||||
async function asBorrower<T>(token: string, path: string): Promise<T> {
|
||||
const res = await rawApi.get(path, { headers: { Authorization: `Bearer ${token}` } })
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
/** A real borrower sign-in. The demo page holds the token in memory only. */
|
||||
export async function borrowerLogin(enterpriseId: string, pin: string): Promise<EnterpriseSession> {
|
||||
const res = await rawApi.post('/auth/app-login', { enterpriseId, pin })
|
||||
return res.data.data as EnterpriseSession
|
||||
}
|
||||
|
||||
export const borrower = {
|
||||
home: (t: string) => asBorrower<AppHomeWire>(t, '/app/home'),
|
||||
reasons: (t: string) => asBorrower<ReasonCodeRow[]>(t, '/app/reasons'),
|
||||
ledger: (t: string) => asBorrower<Record<string, unknown>[]>(t, '/app/ledger'),
|
||||
}
|
||||
|
||||
+31
-78
@@ -41,22 +41,13 @@ export const CANONICAL: CanonicalColumns = {
|
||||
],
|
||||
}
|
||||
|
||||
const FINACLE: Record<string, string> = {
|
||||
enterprise_ref: 'ENTITY_REF',
|
||||
loan_account_ref: 'CUST_ACCT_ID',
|
||||
product: 'SCHM_CODE',
|
||||
sanctioned_amount: 'SANCT_AMT',
|
||||
outstanding: 'PRIN_OS',
|
||||
emi_amount: 'INSTL_AMT',
|
||||
due_date: 'NEXT_DUE_DT',
|
||||
last_paid_date: 'LAST_PAID_DT',
|
||||
dpd: 'DPD_DAYS',
|
||||
bucket: 'ASSET_CLASS',
|
||||
closed: 'ACCT_CLS_FLG',
|
||||
as_of_date: 'EXTRACT_DT',
|
||||
}
|
||||
|
||||
const CUSTOM: Record<string, string> = {
|
||||
/**
|
||||
* CBSNexora's field names. LincPay's own core banking platform, and the only
|
||||
* core named anywhere in this system — where a lender runs someone else's, the
|
||||
* per-profile mapping absorbs their column names without the platform ever
|
||||
* needing the product's name.
|
||||
*/
|
||||
const CBSNEXORA: Record<string, string> = {
|
||||
enterprise_ref: 'borrowerRef',
|
||||
loan_account_ref: 'loanId',
|
||||
product: 'productCode',
|
||||
@@ -86,52 +77,14 @@ export function demoCatalog(): ConnectorCatalogEntry[] {
|
||||
suggestedMapping: IDENTITY,
|
||||
},
|
||||
{
|
||||
vendor: 'FINACLE',
|
||||
displayName: 'Infosys Finacle (simulated)',
|
||||
transport: 'FILE',
|
||||
simulated: true,
|
||||
nativeColumns: [...Object.values(FINACLE), 'CUST_NAME', 'MOBILE_NO', 'BRANCH_CODE', 'CCY'],
|
||||
activationNote:
|
||||
'Finacle exports through a scheduled CRM/Reports extract. A bank enables it, agrees the column list, and the file lands on the drop path.',
|
||||
suggestedMapping: FINACLE,
|
||||
},
|
||||
{
|
||||
vendor: 'FLEXCUBE',
|
||||
displayName: 'Oracle FLEXCUBE (simulated)',
|
||||
transport: 'JDBC',
|
||||
simulated: true,
|
||||
nativeColumns: [
|
||||
'CUSTOMER_REF', 'ACCOUNT_NUMBER', 'PRODUCT_CODE', 'AMOUNT_FINANCED', 'PRINCIPAL_OUTSTANDING',
|
||||
'INSTALMENT_AMOUNT', 'NEXT_DUE_DATE', 'LAST_PAYMENT_DATE', 'OVERDUE_DAYS', 'NPA_STAGE',
|
||||
'ACCOUNT_STATUS', 'VALUE_DATE', 'CUSTOMER_NAME', 'CONTACT_MOBILE', 'BRANCH_CODE',
|
||||
],
|
||||
activationNote:
|
||||
'Read through a reporting schema or extract view rather than the transaction tables. Needs a read-only account scoped to the loan module.',
|
||||
suggestedMapping: {},
|
||||
},
|
||||
{
|
||||
vendor: 'BANCS',
|
||||
displayName: 'TCS BaNCS (simulated)',
|
||||
transport: 'FILE',
|
||||
simulated: true,
|
||||
nativeColumns: [
|
||||
'PARTY_REF', 'ARRANGEMENT_ID', 'PRODUCT_GROUP', 'LIMIT_AMT', 'OUTSTANDING_BAL', 'EMI',
|
||||
'DUE_DATE', 'PAID_DATE', 'DAYS_OVERDUE', 'CLASSIFICATION', 'STATUS', 'POSITION_DATE',
|
||||
'PARTY_NAME', 'MOBILE', 'BRANCH',
|
||||
],
|
||||
activationNote:
|
||||
'Cooperative-bank hosted here. A nightly position file is the realistic ask; a live API rarely is in year one.',
|
||||
suggestedMapping: {},
|
||||
},
|
||||
{
|
||||
vendor: 'CUSTOM_API',
|
||||
displayName: 'In-house LMS over REST (simulated)',
|
||||
vendor: 'CBSNEXORA',
|
||||
displayName: 'CBSNexora (simulated)',
|
||||
transport: 'REST',
|
||||
simulated: true,
|
||||
nativeColumns: [...Object.values(CUSTOM), 'borrowerName', 'borrowerMobile', 'branchCode'],
|
||||
nativeColumns: [...Object.values(CBSNEXORA), 'borrowerName', 'borrowerMobile', 'branchCode'],
|
||||
activationNote:
|
||||
'For a lender whose loan management system is their own. One paged read-only endpoint; the mapping absorbs whatever they call the fields.',
|
||||
suggestedMapping: CUSTOM,
|
||||
'CBSNexora exposes one paged, read-only loan endpoint. A lender enables it for the platform’s service account and agrees the field list; the mapping absorbs whatever their deployment calls them.',
|
||||
suggestedMapping: CBSNEXORA,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -139,21 +92,21 @@ export function demoCatalog(): ConnectorCatalogEntry[] {
|
||||
export function demoProfiles(): ConnectorProfileRow[] {
|
||||
return [
|
||||
{
|
||||
code: 'MPGB-FINACLE',
|
||||
displayName: 'MP Gramin Bank — Finacle nightly extract',
|
||||
vendor: 'FINACLE',
|
||||
vendorName: 'Infosys Finacle (simulated)',
|
||||
transport: 'FILE',
|
||||
code: 'MPGB-CBSNEXORA',
|
||||
displayName: 'MP Gramin Bank — CBSNexora loan API',
|
||||
vendor: 'CBSNEXORA',
|
||||
vendorName: 'CBSNexora (simulated)',
|
||||
transport: 'REST',
|
||||
simulated: true,
|
||||
activationNote: demoCatalog()[1].activationNote,
|
||||
institutionCode: 'MPGB',
|
||||
endpoint: 'loanbook-*.csv',
|
||||
endpoint: 'https://cbsnexora.mpgb.example/api/loans',
|
||||
scheduleCron: '0 30 1 * * *',
|
||||
autoCommit: false,
|
||||
active: true,
|
||||
lastSyncAt: '2026-08-07T01:30:00',
|
||||
lastStatus: 'PARTIAL',
|
||||
mapping: FINACLE,
|
||||
mapping: CBSNEXORA,
|
||||
sourceColumns: demoCatalog()[1].nativeColumns,
|
||||
},
|
||||
{
|
||||
@@ -175,22 +128,22 @@ export function demoProfiles(): ConnectorProfileRow[] {
|
||||
sourceColumns: [],
|
||||
},
|
||||
{
|
||||
code: 'VSFB-LMS',
|
||||
displayName: 'Vindhya SFB — in-house LMS over REST',
|
||||
vendor: 'CUSTOM_API',
|
||||
vendorName: 'In-house LMS over REST (simulated)',
|
||||
code: 'VSFB-CBSNEXORA',
|
||||
displayName: 'Vindhya SFB — CBSNexora loan API',
|
||||
vendor: 'CBSNEXORA',
|
||||
vendorName: 'CBSNexora (simulated)',
|
||||
transport: 'REST',
|
||||
simulated: true,
|
||||
activationNote: demoCatalog()[4].activationNote,
|
||||
activationNote: demoCatalog()[1].activationNote,
|
||||
institutionCode: 'VSFB',
|
||||
endpoint: 'https://lms.vindhyasfb.example/api/loans',
|
||||
endpoint: 'https://cbsnexora.vindhyasfb.example/api/loans',
|
||||
scheduleCron: '0 15 1 * * *',
|
||||
autoCommit: false,
|
||||
active: true,
|
||||
lastSyncAt: null,
|
||||
lastStatus: 'NEVER_RUN',
|
||||
mapping: CUSTOM,
|
||||
sourceColumns: demoCatalog()[4].nativeColumns,
|
||||
mapping: CBSNEXORA,
|
||||
sourceColumns: demoCatalog()[1].nativeColumns,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -244,8 +197,8 @@ export function demoRuns(): ConnectorSyncRun[] {
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
profileCode: 'MPGB-FINACLE',
|
||||
vendor: 'FINACLE',
|
||||
profileCode: 'MPGB-CBSNEXORA',
|
||||
vendor: 'CBSNEXORA',
|
||||
institutionCode: 'MPGB',
|
||||
trigger: 'SCHEDULED',
|
||||
triggeredBy: 'scheduler',
|
||||
@@ -266,8 +219,8 @@ export function demoRuns(): ConnectorSyncRun[] {
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
profileCode: 'MPGB-FINACLE',
|
||||
vendor: 'FINACLE',
|
||||
profileCode: 'MPGB-CBSNEXORA',
|
||||
vendor: 'CBSNEXORA',
|
||||
institutionCode: 'MPGB',
|
||||
trigger: 'MANUAL',
|
||||
triggeredBy: 'supervisor',
|
||||
|
||||
@@ -43,16 +43,22 @@ export const SECTORS: Record<Sector, { label: string; labelHi: string; short: st
|
||||
|
||||
export const SECTOR_LIST = Object.keys(SECTORS) as Sector[]
|
||||
|
||||
export const BLOCKS = ['Sehore', 'Vidisha', 'Raisen', 'Bhopal'] as const
|
||||
export const BLOCKS = ['Sehore', 'Vidisha', 'Raisen', 'Bhopal', 'Kamrup', 'Nagaon'] as const
|
||||
export const BLOCK_DISTRICT: Record<string, string> = {
|
||||
Sehore: 'Sehore',
|
||||
Vidisha: 'Vidisha',
|
||||
Raisen: 'Raisen',
|
||||
Bhopal: 'Bhopal',
|
||||
Kamrup: 'Kamrup',
|
||||
Nagaon: 'Nagaon',
|
||||
}
|
||||
|
||||
/** The six enterprises printed on the wireframe slide — reproduced exactly. */
|
||||
const PINNED: { enterpriseId: string; name: string; nameHi: string; sector: Sector; block: string; score: number }[] = [
|
||||
// Kaziranga is the borrower the officer console opens on: score 3, nothing
|
||||
// visibly wrong, and a forecast breach in November. Pinned so the enterprise
|
||||
// app can show the same business from the owner's side.
|
||||
{ enterpriseId: 'MSME-6151', name: 'Kaziranga Fruit Farm', nameHi: 'काज़ीरंगा फ्रूट फ़ार्म', sector: 'HORTICULTURE', block: 'Kamrup', score: 3 },
|
||||
{ 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 },
|
||||
|
||||
+49
-37
@@ -11,6 +11,7 @@ import type {
|
||||
import type { RiskAlert } from '../api'
|
||||
import {
|
||||
ENTERPRISES,
|
||||
|
||||
SECTORS,
|
||||
breachPoint,
|
||||
forecastFor,
|
||||
@@ -27,8 +28,22 @@ import { currentMonthKey, dayOffsetIso, monthRef, pickInt, pickOne, rng, seedOf
|
||||
|
||||
export const ALL_BLOCKS = 'ALL'
|
||||
|
||||
export function scopedEnterprises(block: string, sector?: Sector | 'ALL'): Enterprise[] {
|
||||
return ENTERPRISES.filter(
|
||||
/**
|
||||
* Filter a book by block and sector.
|
||||
*
|
||||
* <p>`book` is required and has no default on purpose. It used to close over
|
||||
* `ENTERPRISES` — the fabricated 142-borrower demo portfolio, which carries no
|
||||
* owner — so every aggregate built on it mixed all banks together and showed the
|
||||
* result to whoever was signed in. Callers now pass the tenant-scoped list from
|
||||
* `useTenantEnterprises`, and a caller that has no list cannot accidentally get
|
||||
* the demo one.
|
||||
*/
|
||||
export function scopedEnterprises(
|
||||
block: string,
|
||||
sector: Sector | "ALL" | undefined,
|
||||
book: Enterprise[],
|
||||
): Enterprise[] {
|
||||
return book.filter(
|
||||
(e) => (block === ALL_BLOCKS || e.block === block) && (!sector || sector === 'ALL' || e.sector === sector),
|
||||
)
|
||||
}
|
||||
@@ -38,8 +53,8 @@ export function scopedEnterprises(block: string, sector?: Sector | 'ALL'): Enter
|
||||
* 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)
|
||||
export function portfolioSummary(block: string, month: string, book: Enterprise[]): PortfolioSummary {
|
||||
const rows = scopedEnterprises(block, undefined, book)
|
||||
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
|
||||
@@ -66,8 +81,8 @@ export function portfolioSummary(block: string, month: string): PortfolioSummary
|
||||
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)
|
||||
export function alertsFor(block: string, book: Enterprise[]): RiskAlert[] {
|
||||
return scopedEnterprises(block, undefined, book)
|
||||
.filter((e) => e.band !== 'STANDARD')
|
||||
.map((e, i) => {
|
||||
const r = rng(seedOf(`${e.enterpriseId}:alert`))
|
||||
@@ -103,9 +118,16 @@ export function alertsFor(block: string): RiskAlert[] {
|
||||
.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
/** The single alert the enterprise app shows on its Home tab. */
|
||||
/**
|
||||
* The single alert the enterprise app shows on its Home tab.
|
||||
*
|
||||
* <p>The one sanctioned use of the unscoped demo book, and it is safe because of
|
||||
* the shape of the question: the borrower PWA is signed in as one enterprise and
|
||||
* this returns that enterprise's own alert or nothing. There is no list, so there
|
||||
* is nothing to leak across banks.
|
||||
*/
|
||||
export function alertForEnterprise(enterpriseId: string): RiskAlert | null {
|
||||
return alertsFor(ALL_BLOCKS).find((a) => a.enterpriseId === enterpriseId) ?? null
|
||||
return alertsFor(ALL_BLOCKS, ENTERPRISES).find((a) => a.enterpriseId === enterpriseId) ?? null
|
||||
}
|
||||
|
||||
// ── Field visits / playbook ─────────────────────────────────────
|
||||
@@ -130,8 +152,8 @@ const OUTCOMES = [
|
||||
'Buyer payment confirmed as delayed, not defaulted.',
|
||||
]
|
||||
|
||||
export function visitsFor(block: string, officer = 'Aditya Vyas'): FieldVisit[] {
|
||||
return scopedEnterprises(block)
|
||||
export function visitsFor(block: string, book: Enterprise[], officer = 'Aditya Vyas'): FieldVisit[] {
|
||||
return scopedEnterprises(block, undefined, book)
|
||||
.filter((e) => e.band !== 'STANDARD')
|
||||
.slice(0, 24)
|
||||
.map((e, i) => {
|
||||
@@ -156,31 +178,21 @@ export function visitsFor(block: string, officer = 'Aditya Vyas'): FieldVisit[]
|
||||
})
|
||||
}
|
||||
|
||||
/** "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,
|
||||
}
|
||||
// ── Benchmarks ─────────────────────────────────────────────────
|
||||
//
|
||||
// Removed 2026-08-24. This block held nine constants the Reports page rendered as
|
||||
// measurements: AUC 0.87 against an invented "bureau baseline", a 2,400-enterprise
|
||||
// dataset that did not exist, and an on-device inference latency for inference
|
||||
// that does not run on a device. Nothing computed any of them.
|
||||
//
|
||||
// The replacement is the `model_metric` table, written by
|
||||
// `UdyamLinc-ML/training/train_stress.py` at the end of every run and served by
|
||||
// GET /admin/model/metrics. The measured figures are lower than the ones that
|
||||
// were here, which is the point.
|
||||
|
||||
/** 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)
|
||||
export function bandMigration(block: string, book: Enterprise[]): { month: string; red: number; watch: number; standard: number }[] {
|
||||
const rows = scopedEnterprises(block, undefined, book)
|
||||
const r = rng(seedOf(`${block}:migration`))
|
||||
const base = new Date()
|
||||
return Array.from({ length: 6 }, (_, i) => {
|
||||
@@ -197,9 +209,9 @@ export function bandMigration(block: string): { month: string; red: number; watc
|
||||
}
|
||||
|
||||
/** Per-sector-pack rollup for the Forecasts screen. */
|
||||
export function sectorRollup(block: string) {
|
||||
export function sectorRollup(block: string, book: Enterprise[]) {
|
||||
return (Object.keys(SECTORS) as Sector[]).map((s) => {
|
||||
const rows = scopedEnterprises(block, s)
|
||||
const rows = scopedEnterprises(block, s, book)
|
||||
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
|
||||
@@ -216,8 +228,8 @@ export function sectorRollup(block: string) {
|
||||
}
|
||||
|
||||
/** Portfolio-level P10/P50/P90 aggregate over the 6-month horizon. */
|
||||
export function portfolioForecast(block: string) {
|
||||
const rows = scopedEnterprises(block)
|
||||
export function portfolioForecast(block: string, book: Enterprise[]) {
|
||||
const rows = scopedEnterprises(block, undefined, book)
|
||||
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) => ({
|
||||
|
||||
+73
-21
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { LedgerEntry, LedgerKind } from './api'
|
||||
import { syncAppLedger } from './api'
|
||||
import { dayOffsetIso, pickInt, pickOne, rng, seedOf, todayIso } from './demo/seed'
|
||||
|
||||
/*
|
||||
@@ -110,24 +111,38 @@ export function pendingCount(enterpriseId: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Drain the offline queue to the server.
|
||||
*
|
||||
* <p>This was the missing half of the offline promise. Entries were written to
|
||||
* localStorage and marked PENDING, and this function flipped them to SYNCED
|
||||
* without anything leaving the phone — so an owner could record a sale with no
|
||||
* signal, which was the point, but it never reached the lender and the officer's
|
||||
* screen never saw it.
|
||||
*
|
||||
* <p>Safe to call whenever. The server dedupes on `clientId`, which the phone
|
||||
* minted, so a retry after a half-failed sync writes nothing twice. Entries that
|
||||
* fail to send stay PENDING and go again on the next pass rather than being
|
||||
* dropped — losing an owner's tap is the one failure this app cannot have, so a
|
||||
* network error is deliberately silent from their side.
|
||||
*/
|
||||
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())
|
||||
export async function syncPending(enterpriseId: string): Promise<number> {
|
||||
const pending = read().filter(
|
||||
(e) => e.enterpriseId === enterpriseId && e.syncState === 'PENDING',
|
||||
)
|
||||
if (pending.length === 0) return 0
|
||||
|
||||
try {
|
||||
await syncAppLedger(pending)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
|
||||
// Re-read rather than reusing the snapshot: the owner may have tapped save
|
||||
// again while the request was in flight, and that entry must stay PENDING.
|
||||
const sent = new Set(pending.map((e) => e.id))
|
||||
write(read().map((e) => (sent.has(e.id) ? { ...e, syncState: 'SYNCED' as const } : e)))
|
||||
localStorage.setItem(SYNC_KEY, new Date().toISOString())
|
||||
return pending.length
|
||||
}
|
||||
|
||||
export function lastSyncedAt(): string | null {
|
||||
@@ -144,16 +159,53 @@ export function dayTotals(entries: LedgerEntry[], date = todayIso()) {
|
||||
}
|
||||
|
||||
/** 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))
|
||||
/**
|
||||
* The borrower.s ledger: what the server holds, plus anything typed on this
|
||||
* device that has not synced yet.
|
||||
*
|
||||
* <p>Local entries come first and win on `clientId`, which is minted on the
|
||||
* phone. That is what makes the offline path honest — an entry recorded with no
|
||||
* signal is visible immediately, and when it syncs the server copy replaces it
|
||||
* without ever appearing twice.
|
||||
*/
|
||||
export function useLedger(enterpriseId: string, server: LedgerEntry[] = []) {
|
||||
const [local, setLocal] = useState<LedgerEntry[]>(() => loadLedger(enterpriseId))
|
||||
|
||||
const refresh = useCallback(() => setEntries(loadLedger(enterpriseId)), [enterpriseId])
|
||||
const refresh = useCallback(() => setLocal(loadLedger(enterpriseId)), [enterpriseId])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
window.addEventListener('ul-ledger-changed', refresh)
|
||||
return () => window.removeEventListener('ul-ledger-changed', refresh)
|
||||
window.addEventListener("ul-ledger-changed", refresh)
|
||||
return () => window.removeEventListener("ul-ledger-changed", refresh)
|
||||
}, [refresh])
|
||||
|
||||
const seen = new Set(local.map((e) => e.id))
|
||||
const entries = [...local, ...server.filter((e) => !seen.has(e.id))]
|
||||
.sort((a, b) => b.entryDate.localeCompare(a.entryDate))
|
||||
|
||||
return { entries, refresh }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the queue whenever the network comes back.
|
||||
*
|
||||
* <p>Without this the offline promise was only half kept: an entry recorded with
|
||||
* no signal reached the lender on the owner's <em>next</em> save or manual tap,
|
||||
* and an owner who recorded a sale and put the phone away never synced at all.
|
||||
* The browser tells us when the radio returns; that is the moment to try.
|
||||
*
|
||||
* <p>Also runs once on mount, because the app is commonly opened <em>after</em>
|
||||
* connectivity is restored rather than while it is being restored — in that
|
||||
* case no `online` event ever fires.
|
||||
*/
|
||||
export function useAutoSync(enterpriseId: string | undefined) {
|
||||
useEffect(() => {
|
||||
if (!enterpriseId) return
|
||||
const drain = () => {
|
||||
void syncPending(enterpriseId)
|
||||
}
|
||||
drain()
|
||||
window.addEventListener('online', drain)
|
||||
return () => window.removeEventListener('online', drain)
|
||||
}, [enterpriseId])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import {
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Egg,
|
||||
FileSpreadsheet,
|
||||
FileUp,
|
||||
FolderInput,
|
||||
Globe,
|
||||
IndianRupee,
|
||||
Landmark,
|
||||
MapPin,
|
||||
Milk,
|
||||
NotebookPen,
|
||||
PencilLine,
|
||||
ShieldCheck,
|
||||
Store,
|
||||
Upload,
|
||||
Wheat,
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
ConnectorProfileRow,
|
||||
ConnectorSyncRun,
|
||||
Freshness,
|
||||
IngestionRun,
|
||||
ManualEntryBatch,
|
||||
} from './api'
|
||||
import type { SourceGroupSpec, SourceSpec } from './sourceStatus'
|
||||
|
||||
/**
|
||||
* The single description of every route by which data enters UdyamLinc.
|
||||
*
|
||||
* <p>Both consoles render this. The officer console adds drill-through links to
|
||||
* the operational screens; the apex console does not, because NABARD supervises
|
||||
* whether a source is connected and never operates it. That is the only
|
||||
* difference, and it is one boolean — anything more and the two tiers would
|
||||
* eventually be shown different facts about the same feed.
|
||||
*
|
||||
* <p>Nothing here is hard-coded that can be read: run status, row counts and
|
||||
* staleness all come from the same endpoints the detail screens use. What is
|
||||
* hard-coded is what cannot be discovered at runtime — the endpoint a feed is
|
||||
* fetched from, the table it lands in, and why an unconnected one is not
|
||||
* connected yet.
|
||||
*/
|
||||
|
||||
export interface InventoryInput {
|
||||
freshness: Freshness[]
|
||||
runs: IngestionRun[]
|
||||
profiles: ConnectorProfileRow[]
|
||||
connectorRuns: ConnectorSyncRun[]
|
||||
batches: ManualEntryBatch[]
|
||||
/** Officer console only. The apex tier gets the inventory, not the controls. */
|
||||
withLinks: boolean
|
||||
}
|
||||
|
||||
export interface Inventory {
|
||||
groups: SourceGroupSpec[]
|
||||
all: SourceSpec[]
|
||||
districtsCovered: number
|
||||
/** Oldest market observation across districts, in days. Null when nothing ingested. */
|
||||
worstMarketAge: number | null
|
||||
worstClimateAge: number | null
|
||||
}
|
||||
|
||||
export function buildInventory(input: InventoryInput): Inventory {
|
||||
const { freshness, runs, profiles, connectorRuns, batches, withLinks } = input
|
||||
|
||||
const link = (to: string, label: string) => (withLinks ? { to, label } : undefined)
|
||||
|
||||
const districtsCovered = freshness.length
|
||||
const marketAges = freshness.map((f) => f.marketStalenessDays).filter((d): d is number => d != null)
|
||||
const climateAges = freshness.map((f) => f.climateStalenessDays).filter((d): d is number => d != null)
|
||||
const worstMarketAge = marketAges.length ? Math.max(...marketAges) : null
|
||||
const worstClimateAge = climateAges.length ? Math.max(...climateAges) : null
|
||||
|
||||
const lastRun = (source: string): string => {
|
||||
const run = runs.find((r) => r.source === source)
|
||||
if (!run) return 'No run recorded yet'
|
||||
const rows = run.rowsWritten ?? run.rowsFetched ?? 0
|
||||
const when = run.runDate ?? run.startedAt?.slice(0, 10) ?? '—'
|
||||
return `${run.status} · ${rows.toLocaleString('en-IN')} rows · ${when}`
|
||||
}
|
||||
|
||||
const lastConnectorRun = (code: string): string => {
|
||||
const run = connectorRuns.find((r) => r.profileCode === code)
|
||||
if (!run) return 'Never run'
|
||||
const rows = run.rowsAccepted ?? run.rowsFetched ?? 0
|
||||
return `${run.status} · ${rows.toLocaleString('en-IN')} rows · ${run.startedAt.slice(0, 10)}`
|
||||
}
|
||||
|
||||
const batchActivity = (type: ManualEntryBatch['entryType']): string => {
|
||||
const b = batches.find((x) => x.entryType === type)
|
||||
if (!b) return 'No upload yet'
|
||||
const accepted = (b.rowsAccepted ?? 0).toLocaleString('en-IN')
|
||||
const rejected = (b.rowsRejected ?? 0).toLocaleString('en-IN')
|
||||
return `${b.status} · ${accepted} accepted, ${rejected} rejected · ${b.uploadedAt.slice(0, 10)}`
|
||||
}
|
||||
|
||||
/* ── A · public data APIs ─────────────────────────────────────── */
|
||||
|
||||
const apiSources: SourceSpec[] = [
|
||||
{
|
||||
icon: Wheat,
|
||||
id: 'agmarknet',
|
||||
name: 'Agmarknet mandi prices',
|
||||
kind: 'Public API · data.gov.in',
|
||||
status: 'LIVE',
|
||||
mechanism: 'GET api.data.gov.in/resource/9ef84268-…',
|
||||
auth: 'API key — DATAGOV_API_KEY',
|
||||
writes: 'market_signal',
|
||||
cadence: 'Nightly 02:00, or on demand',
|
||||
feeds: ['feed_cost_change', 'output_price_change', 'margin_squeeze', 'price_z_score'],
|
||||
note: `39 commodities across ${districtsCovered} district baskets. Daily arrivals folded into one modal price per ISO week.`,
|
||||
activity: `${lastRun('AGMARKNET')}${worstMarketAge != null ? ` · oldest district ${worstMarketAge}d` : ''}`,
|
||||
link: link('/officer/feeds', 'Prices'),
|
||||
},
|
||||
{
|
||||
icon: CloudRain,
|
||||
id: 'open-meteo',
|
||||
name: 'Rainfall & temperature',
|
||||
kind: 'Public API · Open-Meteo archive',
|
||||
status: 'LIVE',
|
||||
mechanism: 'GET archive-api.open-meteo.com/v1/archive',
|
||||
auth: 'None required',
|
||||
writes: 'climate_signal, climate_normal',
|
||||
cadence: 'Nightly 02:00, or on demand',
|
||||
feeds: ['rainfall_anomaly', 'heat_anomaly'],
|
||||
note: 'Reanalysis rather than a live weather endpoint, because an anomaly needs a 30-year baseline to be measured against.',
|
||||
activity: `${lastRun('OPEN_METEO')}${worstClimateAge != null ? ` · oldest district ${worstClimateAge}d` : ''}`,
|
||||
link: link('/officer/feeds', 'Climate'),
|
||||
},
|
||||
{
|
||||
icon: CloudSun,
|
||||
id: 'imd',
|
||||
name: 'IMD Agromet advisories',
|
||||
kind: 'Public API · district advisory',
|
||||
status: 'NOT_CONNECTED',
|
||||
mechanism: 'District advisory bulletin — no client written',
|
||||
auth: 'Open',
|
||||
writes: '—',
|
||||
cadence: 'Tuesday / Friday',
|
||||
feeds: [],
|
||||
note: 'Adds the agronomic advisory text beside the numbers. One client class; slots into the existing scheduler.',
|
||||
activity: 'Nothing ingested',
|
||||
},
|
||||
{
|
||||
icon: Egg,
|
||||
id: 'necc',
|
||||
name: 'NECC egg realisation',
|
||||
kind: 'HTML page · no API',
|
||||
status: 'NOT_CONNECTED',
|
||||
mechanism: 'e2necc.com daily city rate table — needs a scrape',
|
||||
auth: 'Open, terms-of-use review pending',
|
||||
writes: 'market_signal (source NECC)',
|
||||
cadence: 'Daily',
|
||||
feeds: ['output_price_change'],
|
||||
note: 'The poultry pack’s output price. Until it lands, egg is filled by the simulator and by manual entry.',
|
||||
activity: 'Nothing ingested — commodity seeded, source reserved',
|
||||
},
|
||||
{
|
||||
icon: Milk,
|
||||
id: 'dairy',
|
||||
name: 'Dairy federation milk rate',
|
||||
kind: 'Institutional · no API',
|
||||
status: 'NOT_CONNECTED',
|
||||
mechanism: 'State federation procurement rate — MoU or admin entry',
|
||||
auth: 'Relationship, not an endpoint',
|
||||
writes: 'market_signal (source DAIRY_FEDERATION)',
|
||||
cadence: 'Monthly',
|
||||
feeds: ['output_price_change', 'margin_squeeze'],
|
||||
note: 'The dairy pack’s output price. No public API exists anywhere in India; manual entry exists because of this one.',
|
||||
activity: 'Nothing ingested — commodity seeded, source reserved',
|
||||
},
|
||||
]
|
||||
|
||||
/* ── B · core banking ─────────────────────────────────────────── */
|
||||
|
||||
const fileDrop = profiles.find((p) => !p.simulated)
|
||||
const simulatedProfiles = profiles.filter((p) => p.simulated)
|
||||
|
||||
const cbsSources: SourceSpec[] = [
|
||||
{
|
||||
icon: FolderInput,
|
||||
id: 'file-drop',
|
||||
name: fileDrop?.displayName ?? 'Bank file drop',
|
||||
kind: 'CBS extract · file drop',
|
||||
status: 'LIVE',
|
||||
mechanism: fileDrop?.endpoint ?? 'Newest matching file on a mounted drop path',
|
||||
auth: 'Filesystem, mounted by the bank',
|
||||
writes: 'loan_record (via staged preview)',
|
||||
cadence: fileDrop?.scheduleCron ?? 'On demand',
|
||||
feeds: ['emi_delay', 'emi_to_inflow'],
|
||||
note: 'The real adapter. Reads and never writes, and leaves the file where it found it so a failed run is repeatable. A bank on any core can deliver to it.',
|
||||
activity: fileDrop ? lastConnectorRun(fileDrop.code) : 'No profile configured',
|
||||
link: link('/officer/integration', 'Connections'),
|
||||
},
|
||||
...simulatedProfiles.map<SourceSpec>((p) => ({
|
||||
icon: Landmark,
|
||||
id: p.code,
|
||||
name: p.vendorName ?? p.displayName,
|
||||
kind: `CBS dialect · ${p.transport ?? 'FILE'}`,
|
||||
status: 'SIMULATED' as const,
|
||||
mechanism: `${p.vendor} column dialect — ${Object.keys(p.mapping ?? {}).length} columns mapped`,
|
||||
auth: 'None — emitted locally',
|
||||
writes: 'loan_record (stamped simulated per run)',
|
||||
cadence: 'On demand',
|
||||
feeds: ['emi_delay', 'emi_to_inflow'],
|
||||
note:
|
||||
p.activationNote ??
|
||||
'Emits the real portfolio in CBSNexora’s own field names, so the mapping is agreed and exercised before a live instance is connected.',
|
||||
activity: lastConnectorRun(p.code),
|
||||
link: link('/officer/integration', 'Mapping'),
|
||||
})),
|
||||
]
|
||||
|
||||
/* ── C · digital transaction proxies ──────────────────────────── */
|
||||
|
||||
const upiSources: SourceSpec[] = [
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
id: 'aa',
|
||||
name: 'Account Aggregator (Sahamati)',
|
||||
kind: 'Consent framework · RBI',
|
||||
status: 'NOT_CONNECTED',
|
||||
mechanism: 'FIU registration + consent artefact — no client written',
|
||||
auth: 'Consent artefact per borrower',
|
||||
writes: 'upi_summary',
|
||||
cadence: 'Weekly',
|
||||
feeds: ['upi_inflow_change'],
|
||||
note: 'The mandate’s "digital transaction proxies". Registration is a relationship with a long lead time, not a sprint — so the table, the query and the feature all exist and wait for it.',
|
||||
activity: 'No consented rows · TW-02 reports SKIPPED_MISSING, never fires',
|
||||
},
|
||||
{
|
||||
icon: IndianRupee,
|
||||
id: 'upi-sim',
|
||||
name: 'UPI weekly aggregates',
|
||||
kind: 'Simulator · stands in for AA',
|
||||
status: 'SIMULATED',
|
||||
mechanism: 'UpiSimulationService → the same upi_summary table',
|
||||
auth: 'Written only for enterprises with consentActive',
|
||||
writes: 'upi_summary (source SIMULATED)',
|
||||
cadence: 'On demand',
|
||||
feeds: ['upi_inflow_change'],
|
||||
note: 'Goes through the identical table and derivation an Account Aggregator would, so the seam is exercised rather than described. An enterprise that has not consented stays null with NO_CONSENT.',
|
||||
activity: 'Populated for consented demo enterprises only',
|
||||
},
|
||||
]
|
||||
|
||||
/* ── D · manual upload and entry ──────────────────────────────── */
|
||||
|
||||
const uploadSources: SourceSpec[] = [
|
||||
{
|
||||
icon: FileSpreadsheet,
|
||||
id: 'upload-market',
|
||||
name: 'Market price upload',
|
||||
kind: 'File upload · CSV / XLSX',
|
||||
status: 'LIVE',
|
||||
mechanism: 'POST /admin/manual/upload?type=MARKET_SIGNAL',
|
||||
auth: 'Data steward or above',
|
||||
writes: 'market_signal (source MANUAL)',
|
||||
cadence: 'Whenever a rate sheet arrives',
|
||||
feeds: ['output_price_change', 'margin_squeeze', 'price_z_score'],
|
||||
note: 'Exists for milk, egg, fodder and cane — the commodities no API publishes. Nothing is written until the preview has shown what will land, what was rejected and what will overwrite.',
|
||||
activity: batchActivity('MARKET_SIGNAL'),
|
||||
link: link('/officer/data-entry', 'Upload'),
|
||||
},
|
||||
{
|
||||
icon: FileUp,
|
||||
id: 'upload-loans',
|
||||
name: 'Loan book upload',
|
||||
kind: 'File upload · CSV / XLSX',
|
||||
status: 'LIVE',
|
||||
mechanism: 'POST /admin/manual/upload?type=LOAN_RECORD',
|
||||
auth: 'Data steward or above',
|
||||
writes: 'loan_record',
|
||||
cadence: 'Monthly, or ahead of a CBS connection',
|
||||
feeds: ['emi_delay', 'emi_to_inflow'],
|
||||
note: 'A file whose headers carry a name, phone or account number is refused whole — the no-PII rule is enforced at the boundary, not cleaned up afterwards.',
|
||||
activity: batchActivity('LOAN_RECORD'),
|
||||
link: link('/officer/data-entry', 'Upload'),
|
||||
},
|
||||
{
|
||||
icon: PencilLine,
|
||||
id: 'entry-single',
|
||||
name: 'Single market entry',
|
||||
kind: 'Form · one rate at a time',
|
||||
status: 'LIVE',
|
||||
mechanism: 'POST /admin/manual/entry',
|
||||
auth: 'Data steward or above',
|
||||
writes: 'market_signal (source MANUAL)',
|
||||
cadence: 'Ad hoc',
|
||||
feeds: ['output_price_change'],
|
||||
note: 'For the officer who has one number from the dairy union and no file to upload.',
|
||||
activity: 'Available to every data steward',
|
||||
link: link('/officer/data-entry', 'Entry'),
|
||||
},
|
||||
]
|
||||
|
||||
/* ── E · enterprise-generated ─────────────────────────────────── */
|
||||
|
||||
const ledgerSources: SourceSpec[] = [
|
||||
{
|
||||
icon: NotebookPen,
|
||||
id: 'ledger',
|
||||
name: 'Three-tap enterprise ledger',
|
||||
kind: 'Borrower PWA · offline-first',
|
||||
status: 'LIVE',
|
||||
mechanism: 'Enterprise app — in, out, stock. Queued offline, synced on reconnect',
|
||||
auth: 'Enterprise PIN',
|
||||
writes: 'ledger_entry',
|
||||
cadence: 'Daily, ~15 seconds of owner effort',
|
||||
feeds: ['cash_flow_change', 'days_since_last_entry', 'inventory_days', 'dscr_p10'],
|
||||
note: 'The only signal that reaches the enterprise’s own trading position. client_id is minted on the phone so an entry keeps its identity through a sync.',
|
||||
activity: 'Captured per enterprise in the borrower app',
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
id: 'visits',
|
||||
name: 'Field visit outcomes',
|
||||
kind: 'Officer console · feedback edge',
|
||||
status: 'LIVE',
|
||||
mechanism: 'Visit logged against an alert, with the intervention and its result',
|
||||
auth: 'Field officer or above',
|
||||
writes: 'field_visit',
|
||||
cadence: 'Per visit',
|
||||
feeds: [],
|
||||
note: 'Closes the loop: what the officer did and whether it worked is the label a future retraining cycle reads.',
|
||||
activity: 'Logged from the alerts and visits screens',
|
||||
link: link('/officer/visits', 'Visits'),
|
||||
},
|
||||
]
|
||||
|
||||
const groups: SourceGroupSpec[] = [
|
||||
{
|
||||
icon: Globe,
|
||||
letter: 'A',
|
||||
title: 'Public data APIs',
|
||||
blurb: 'Market intelligence and climate — pulled nightly, never in the request path',
|
||||
sources: apiSources,
|
||||
},
|
||||
{
|
||||
icon: Landmark,
|
||||
letter: 'B',
|
||||
title: 'Core banking / LMS (CBS)',
|
||||
blurb: 'The loan book — a real file drop beside the CBSNexora loan API',
|
||||
sources: cbsSources,
|
||||
},
|
||||
{
|
||||
icon: IndianRupee,
|
||||
letter: 'C',
|
||||
title: 'Digital transaction proxies (UPI)',
|
||||
blurb: 'Consent-bound inflows through the Account Aggregator framework',
|
||||
sources: upiSources,
|
||||
},
|
||||
{
|
||||
icon: Upload,
|
||||
letter: 'D',
|
||||
title: 'Manual upload & entry',
|
||||
blurb: 'For every feed no API serves — staged, previewed, then committed',
|
||||
sources: uploadSources,
|
||||
},
|
||||
{
|
||||
icon: Store,
|
||||
letter: 'E',
|
||||
title: 'Enterprise-generated',
|
||||
blurb: 'What the borrower and the officer record themselves',
|
||||
sources: ledgerSources,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
groups,
|
||||
all: groups.flatMap((g) => g.sources),
|
||||
districtsCovered,
|
||||
worstMarketAge,
|
||||
worstClimateAge,
|
||||
}
|
||||
}
|
||||
|
||||
/** The four families the problem statement names, and what is live behind each. */
|
||||
export interface MandateRow {
|
||||
family: string
|
||||
detail: string
|
||||
live: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function mandateCoverage(inv: Inventory): MandateRow[] {
|
||||
const group = (letter: string) => inv.groups.find((g) => g.letter === letter)?.sources ?? []
|
||||
const live = (s: SourceSpec[]) => s.filter((x) => x.status === 'LIVE').length
|
||||
|
||||
const api = group('A')
|
||||
const market = api.filter((s) => s.id !== 'open-meteo' && s.id !== 'imd')
|
||||
const climate = api.filter((s) => s.id === 'open-meteo' || s.id === 'imd')
|
||||
const cbs = group('B')
|
||||
const upi = group('C')
|
||||
const upload = group('D')
|
||||
|
||||
return [
|
||||
{
|
||||
family: 'Financial records',
|
||||
detail: 'Core banking extract, loan-book upload, borrower ledger',
|
||||
live: live(cbs) + live(upload) + 1,
|
||||
total: cbs.length + upload.length + 1,
|
||||
},
|
||||
{
|
||||
family: 'Digital transaction proxies',
|
||||
detail: 'Consented UPI and bank inflows',
|
||||
live: live(upi),
|
||||
total: upi.length,
|
||||
},
|
||||
{
|
||||
family: 'Market intelligence',
|
||||
detail: 'Mandi prices, egg and milk realisation',
|
||||
live: live(market),
|
||||
total: market.length,
|
||||
},
|
||||
{
|
||||
family: 'Climate data',
|
||||
detail: 'Rainfall, heat and district advisories',
|
||||
live: live(climate),
|
||||
total: climate.length,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* The vocabulary the data-source inventory is written in.
|
||||
*
|
||||
* <p>Split out from the card that renders it so both consoles and the inventory
|
||||
* builder can share the words without importing a component, and so the card
|
||||
* file exports components only.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Three states, and deliberately no fourth. {@code SIMULATED} exists because the
|
||||
* alternative — leaving generated rows looking real — is the single failure this
|
||||
* console is built to prevent.
|
||||
*/
|
||||
export type SourceStatus = 'LIVE' | 'SIMULATED' | 'NOT_CONNECTED'
|
||||
|
||||
export const STATUS_LABEL: Record<SourceStatus, string> = {
|
||||
LIVE: 'Live',
|
||||
SIMULATED: 'Simulated',
|
||||
NOT_CONNECTED: 'Not connected',
|
||||
}
|
||||
|
||||
export const STATUS_PILL: Record<SourceStatus, string> = {
|
||||
LIVE: 'pill-ok',
|
||||
SIMULATED: 'pill-pend',
|
||||
NOT_CONNECTED: 'pill-mut',
|
||||
}
|
||||
|
||||
export const STATUS_HELP: Record<SourceStatus, string> = {
|
||||
LIVE: 'Real data, fetched or uploaded. Every row carries its origin.',
|
||||
SIMULATED: 'Generated because no feed exists. Stamped SIMULATED in the database, permanently.',
|
||||
NOT_CONNECTED: 'The seam is built; the source needs an agreement, a key or a scrape.',
|
||||
}
|
||||
|
||||
export interface SourceSpec {
|
||||
/** Lucide glyph for the card's tile — what the source *is*, at a glance. */
|
||||
icon?: LucideIcon
|
||||
id: string
|
||||
/** What a person would call it. */
|
||||
name: string
|
||||
/** The mechanism class — Public API, CBS extract, File upload… */
|
||||
kind: string
|
||||
status: SourceStatus
|
||||
/** Endpoint, path or template — rendered monospace, because it is literal. */
|
||||
mechanism: string
|
||||
auth: string
|
||||
/** The table it lands in. */
|
||||
writes: string
|
||||
cadence: string
|
||||
/** Feature codes this source ultimately feeds. */
|
||||
feeds: string[]
|
||||
/** Last run, row count, or why there is nothing to report. */
|
||||
activity: string
|
||||
/** One sentence a reader can take without asking a follow-up. */
|
||||
note?: string
|
||||
/** Officer console only — the apex tier gets no operational drill-through. */
|
||||
link?: { to: string; label: string }
|
||||
}
|
||||
|
||||
export interface SourceGroupSpec {
|
||||
icon?: LucideIcon
|
||||
letter: string
|
||||
title: string
|
||||
blurb: string
|
||||
sources: SourceSpec[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tint for a card's icon tile. The pill states the status in words; the tile
|
||||
* repeats it in colour, so a reader scanning a grid of twelve cards sees which
|
||||
* are live before reading any of them.
|
||||
*/
|
||||
export const STATUS_TILE: Record<SourceStatus, string> = {
|
||||
LIVE: 'bg-green-soft text-forest',
|
||||
SIMULATED: 'bg-amber-soft text-amber',
|
||||
NOT_CONNECTED: 'bg-slate-100 text-slate-400',
|
||||
}
|
||||
+18
-8
@@ -1,6 +1,5 @@
|
||||
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.
|
||||
@@ -51,22 +50,32 @@ export function scheduleVisit(input: {
|
||||
}
|
||||
|
||||
/** Record the outcome of a visit — this is what feeds the next retraining cycle. */
|
||||
export function logOutcome(visitId: string, outcome: string) {
|
||||
export function logOutcome(visitId: string, outcome: string, source?: FieldVisit) {
|
||||
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])
|
||||
// A server visit: shadow it locally rather than reaching for the demo
|
||||
// generator, which is not scoped to any bank. The caller supplies the row it
|
||||
// is closing, because it already has it on screen.
|
||||
if (source) {
|
||||
write([{ ...source, 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) {
|
||||
/**
|
||||
* Live visits with locally scheduled ones merged in.
|
||||
*
|
||||
* <p>Server rows are supplied by the caller and there is no demo fallback, for
|
||||
* the same reason as `useAlerts`: `/officer/visits` filters through the
|
||||
* caller.s own borrowers, and the in-file portfolio has no owner on it. Visits
|
||||
* scheduled in the browser are demo gestures held in local storage and are
|
||||
* merged over the server list.
|
||||
*/
|
||||
export function useVisits(block: string, officer: string, source: FieldVisit[]) {
|
||||
const [local, setLocal] = useState<FieldVisit[]>(read)
|
||||
|
||||
const refresh = useCallback(() => setLocal(read()), [])
|
||||
@@ -76,7 +85,8 @@ export function useVisits(block: string, officer: string) {
|
||||
return () => window.removeEventListener('ul-visits-changed', refresh)
|
||||
}, [refresh])
|
||||
|
||||
const seeded = visitsFor(block, officer)
|
||||
void officer
|
||||
const seeded = source
|
||||
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))]
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
borrower,
|
||||
borrowerLogin,
|
||||
fetchAppEnrolled,
|
||||
fetchModelMetrics,
|
||||
officerSession,
|
||||
} from '../lib/api'
|
||||
import type { AppHomeWire, Enterprise, ReasonCodeRow } from '../lib/api'
|
||||
import { useTenantEnterprises } from '../features/officer/useTenantData'
|
||||
import { ALL_BLOCKS } from '../lib/demo/ops'
|
||||
import { inr, monthRef } from '../lib/demo/seed'
|
||||
import { SECTORS } from '../lib/demo/enterprises'
|
||||
import '../styles/demo.css'
|
||||
|
||||
/**
|
||||
* One borrower, both sides, one screen.
|
||||
*
|
||||
* <h2>Why this page exists</h2>
|
||||
*
|
||||
* <p>The jury has thirty minutes and two surfaces to understand: a lender's
|
||||
* console and a borrower's phone. Shown in sequence they read as two products.
|
||||
* Shown together, with the same borrower selected on both, they read as what
|
||||
* they are — one record, two audiences, and a shortfall the lender can see
|
||||
* months before it arrives.
|
||||
*
|
||||
* <h2>Everything here is live</h2>
|
||||
*
|
||||
* <p>The left column is the officer's own tenant-scoped API. The right column is
|
||||
* a <em>real</em> borrower sign-in: picking a row calls {@code /auth/app-login}
|
||||
* and every panel beside it is fetched with that borrower's token. Nothing is
|
||||
* mirrored from the left column, which is the point — if the two sides agree it
|
||||
* is because they read one record, not because one was copied into the other.
|
||||
*
|
||||
* <p>The borrower token is held in React state and never written to storage, so
|
||||
* this page cannot disturb a real app session open in another tab.
|
||||
*/
|
||||
|
||||
const DEMO_PIN = '1234'
|
||||
|
||||
type Bucket = 'NOW' | 'WINDOW' | 'LATER' | 'CLEAR'
|
||||
|
||||
/** Same thresholds as the officer dashboard's triage funnel. */
|
||||
function bucketOf(e: Enterprise): Bucket {
|
||||
const h = e.firstBreachHorizon
|
||||
if (h == null) return 'CLEAR'
|
||||
if (h <= 1) return 'NOW'
|
||||
if (h <= 4) return 'WINDOW'
|
||||
return 'LATER'
|
||||
}
|
||||
|
||||
const BUCKET_META: Record<Bucket, { label: string; cls: string; caption: string }> = {
|
||||
NOW: { label: 'Act now', cls: 'is-now', caption: 'breaches this month' },
|
||||
WINDOW: { label: 'In window', cls: 'is-window', caption: 'breaches in 2-4 months' },
|
||||
LATER: { label: 'Watch', cls: 'is-watch', caption: 'breaches beyond 4 months' },
|
||||
CLEAR: { label: 'Covered', cls: 'is-clear', caption: 'downside still covers' },
|
||||
}
|
||||
|
||||
function monthLabel(month: string | null | undefined): string {
|
||||
if (!month) return '—'
|
||||
return monthRef(new Date(`${month}-01T00:00:00`)).fullEn
|
||||
}
|
||||
|
||||
export function DemoPage() {
|
||||
const session = officerSession()
|
||||
const book = useTenantEnterprises(ALL_BLOCKS)
|
||||
const metricsQ = useQuery({ queryKey: ['model-metrics'], queryFn: fetchModelMetrics })
|
||||
// Not every borrower has the app. Knowing which do lets the page open on one
|
||||
// that can actually be shown, instead of failing a sign-in in front of a jury.
|
||||
const enrolledQ = useQuery({
|
||||
queryKey: ['app-enrolled', ALL_BLOCKS],
|
||||
queryFn: () => fetchAppEnrolled(ALL_BLOCKS),
|
||||
})
|
||||
const enrolled = useMemo(() => new Set(enrolledQ.data ?? []), [enrolledQ.data])
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [signInError, setSignInError] = useState<string | null>(null)
|
||||
|
||||
// Worst first — the borrower with the least lead time is the one worth opening.
|
||||
const rows = useMemo(() => {
|
||||
const order: Record<Bucket, number> = { NOW: 0, WINDOW: 1, LATER: 2, CLEAR: 3 }
|
||||
return [...book.rows].sort((a, b) => {
|
||||
const d = order[bucketOf(a)] - order[bucketOf(b)]
|
||||
return d !== 0 ? d : b.score - a.score
|
||||
})
|
||||
}, [book.rows])
|
||||
|
||||
// Open on the most urgent borrower who is actually on the app, so the page is
|
||||
// already telling its whole story when it appears. Falling back to the most
|
||||
// urgent row overall keeps the lender half working when nobody is enrolled.
|
||||
useEffect(() => {
|
||||
if (selected || !rows.length || enrolledQ.isLoading) return
|
||||
const first = rows.find((e) => enrolled.has(e.enterpriseId)) ?? rows[0]
|
||||
setSelected(first.enterpriseId)
|
||||
}, [rows, selected, enrolled, enrolledQ.isLoading])
|
||||
|
||||
// A real sign-in as the selected borrower, repeated whenever the selection
|
||||
// changes. The previous token is dropped rather than reused.
|
||||
useEffect(() => {
|
||||
if (!selected) return
|
||||
let cancelled = false
|
||||
setToken(null)
|
||||
setSignInError(null)
|
||||
if (enrolledQ.isSuccess && !enrolled.has(selected)) {
|
||||
setSignInError('NOT_ENROLLED')
|
||||
return
|
||||
}
|
||||
borrowerLogin(selected, DEMO_PIN)
|
||||
.then((s) => {
|
||||
if (!cancelled) setToken(s.token)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSignInError('NOT_ENROLLED')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selected, enrolled, enrolledQ.isSuccess])
|
||||
|
||||
const homeQ = useQuery({
|
||||
queryKey: ['demo-app-home', selected, token],
|
||||
queryFn: () => borrower.home(token as string),
|
||||
enabled: Boolean(token),
|
||||
})
|
||||
const reasonsQ = useQuery({
|
||||
queryKey: ['demo-app-reasons', selected, token],
|
||||
queryFn: () => borrower.reasons(token as string),
|
||||
enabled: Boolean(token),
|
||||
})
|
||||
|
||||
const lender = rows.find((e) => e.enterpriseId === selected) ?? null
|
||||
const home = homeQ.data ?? null
|
||||
const reasons = reasonsQ.data ?? []
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<Bucket, number> = { NOW: 0, WINDOW: 0, LATER: 0, CLEAR: 0 }
|
||||
for (const e of book.rows) c[bucketOf(e)]++
|
||||
return c
|
||||
}, [book.rows])
|
||||
|
||||
const arm = metricsQ.data?.arms?.lightgbm
|
||||
const engine = home?.forecast?.[0]?.engine ?? null
|
||||
|
||||
return (
|
||||
<div className="dm">
|
||||
<header className="dm-top">
|
||||
<div className="dm-top-lede">
|
||||
<h1>One borrower, both sides</h1>
|
||||
<p>
|
||||
The lender’s console and the borrower’s phone, reading the same
|
||||
record. Pick a row on the left — the phone signs in as that borrower.
|
||||
</p>
|
||||
</div>
|
||||
<dl className="dm-facts">
|
||||
<div>
|
||||
<dt>Signed in as</dt>
|
||||
<dd>{session?.fullName ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Branch</dt>
|
||||
<dd>{session?.branch ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Borrowers</dt>
|
||||
<dd className="num">{book.rows.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Forecast engine</dt>
|
||||
<dd>{engine ?? '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
{book.unavailable && (
|
||||
<div className="dm-warn">
|
||||
The server could not be reached, so no borrowers are listed. This page never
|
||||
substitutes demo rows — a book that cannot be read is shown as unread.{' '}
|
||||
<button onClick={book.retry}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dm-split">
|
||||
{/* ── lender ─────────────────────────────────────────── */}
|
||||
<section className="dm-col">
|
||||
<div className="dm-col-head">
|
||||
<span className="dm-tag">The lender sees</span>
|
||||
<h2>Officer console</h2>
|
||||
</div>
|
||||
|
||||
<div className="dm-triage">
|
||||
{(['NOW', 'WINDOW', 'LATER', 'CLEAR'] as Bucket[]).map((b) => (
|
||||
<div key={b} className={`dm-seg ${BUCKET_META[b].cls}`}>
|
||||
<span className="dm-seg-label">{BUCKET_META[b].label}</span>
|
||||
<span className="dm-seg-val num">{counts[b]}</span>
|
||||
<span className="dm-seg-cap">{BUCKET_META[b].caption}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ul className="dm-list">
|
||||
{rows.slice(0, 7).map((e) => {
|
||||
const b = bucketOf(e)
|
||||
return (
|
||||
<li key={e.enterpriseId}>
|
||||
<button
|
||||
className={`dm-row ${selected === e.enterpriseId ? 'is-on' : ''}`}
|
||||
onClick={() => setSelected(e.enterpriseId)}
|
||||
>
|
||||
<span className={`dm-score ${BUCKET_META[b].cls}`}>{e.score}</span>
|
||||
<span className="dm-row-body">
|
||||
<span className="dm-row-top">
|
||||
<b>{e.name}</b>
|
||||
<span className={`dm-when ${BUCKET_META[b].cls}`}>
|
||||
{e.firstBreachMonth ? monthLabel(e.firstBreachMonth) : 'covered'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="dm-meta">
|
||||
{SECTORS[e.sector]?.short ?? e.sector} · {e.block} ·{' '}
|
||||
{e.enterpriseId}
|
||||
{enrolled.has(e.enterpriseId) && (
|
||||
<span className="dm-hasapp" title="Has the phone app">
|
||||
on the app
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{e.topDriver && <span className="dm-why">{e.topDriver}</span>}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{rows.length === 0 && !book.unavailable && (
|
||||
<li className="dm-empty">
|
||||
{book.loading ? 'Loading the book…' : 'No borrowers on this book.'}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* ── borrower ───────────────────────────────────────── */}
|
||||
<section className="dm-col">
|
||||
<div className="dm-col-head">
|
||||
<span className="dm-tag">The borrower sees</span>
|
||||
<h2>Their own phone</h2>
|
||||
</div>
|
||||
|
||||
<div className="dm-phone">
|
||||
<div className="dm-phone-bar">
|
||||
<span>{lender?.name ?? '—'}</span>
|
||||
<span className="dm-live">
|
||||
{signInError === 'NOT_ENROLLED'
|
||||
? 'no app'
|
||||
: token
|
||||
? 'signed in'
|
||||
: 'signing in…'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="dm-phone-body">
|
||||
{signInError === 'NOT_ENROLLED' ? (
|
||||
<div className="dm-none">
|
||||
<b>This borrower is not on the app.</b>
|
||||
<span>
|
||||
They are still scored and still forecast — from core banking and
|
||||
UPI aggregates, which need nothing installed on a phone. The app
|
||||
adds a daily ledger and gives the owner the reasons in their own
|
||||
language; it is not what makes the borrower visible.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{signInError !== 'NOT_ENROLLED' && home?.alert ? (
|
||||
<div className="dm-alert">
|
||||
<b>{home.alert.title}</b>
|
||||
<span>
|
||||
Estimated {inr(home.alert.shortfall ?? 0)} short
|
||||
{home.alert.breachMonth ? ` · ${monthLabel(home.alert.breachMonth)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
) : signInError !== 'NOT_ENROLLED' && home ? (
|
||||
<div className="dm-ok">No shortfall expected in the next six months.</div>
|
||||
) : signInError !== 'NOT_ENROLLED' ? (
|
||||
<div className="dm-skel" />
|
||||
) : null}
|
||||
|
||||
{/* Six-month strip. The bar is the downside case, the line is what the
|
||||
instalment needs. A bar below the line is the whole product. */}
|
||||
{signInError !== 'NOT_ENROLLED' && home?.forecast?.length ? (
|
||||
<div className="dm-fc">
|
||||
<div className="dm-fc-head">
|
||||
<span>Next six months</span>
|
||||
<span>worst case vs instalment</span>
|
||||
</div>
|
||||
<div className="dm-bars">
|
||||
{(() => {
|
||||
const strip = home.forecast.slice(0, 6)
|
||||
const top = Math.max(
|
||||
...strip.map((q) => Math.max(q.p10, q.p50, q.obligation)),
|
||||
1,
|
||||
)
|
||||
return strip.map((p) => (
|
||||
<div key={p.month} className="dm-bar">
|
||||
<div className="dm-bar-track">
|
||||
<div
|
||||
className={`dm-bar-fill ${p.breach ? 'is-breach' : ''}`}
|
||||
style={{ height: `${Math.max(2, (Math.max(p.p10, 0) / top) * 100)}%` }}
|
||||
/>
|
||||
<div
|
||||
className="dm-bar-need"
|
||||
style={{ bottom: `${Math.min(100, (p.obligation / top) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="dm-bar-x">
|
||||
{monthRef(new Date(`${p.month}-01T00:00:00`)).label}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
<p className="dm-legend">
|
||||
<i className="dm-key-bar" /> worst case <i className="dm-key-line" /> instalment
|
||||
due
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{signInError !== 'NOT_ENROLLED' && reasons.length > 0 && (
|
||||
<div className="dm-reasons">
|
||||
<div className="dm-fc-head">
|
||||
<span>Why</span>
|
||||
</div>
|
||||
<ul>
|
||||
{reasons.slice(0, 3).map((r: ReasonCodeRow) => (
|
||||
<li key={r.id}>{r.sentenceEn}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signInError !== 'NOT_ENROLLED' && home && (
|
||||
<div className="dm-today">
|
||||
<span>
|
||||
Today in <b className="num">{inr(home.todayIn ?? 0)}</b>
|
||||
</span>
|
||||
<span>
|
||||
out <b className="num">{inr(home.todayOut ?? 0)}</b>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Agreement lender={lender} home={home} reasons={reasons} />
|
||||
|
||||
<footer className="dm-foot">
|
||||
<span>
|
||||
{arm
|
||||
? `Scoring model: LightGBM, AUC ${arm.auc_roc?.toFixed(3) ?? '—'} on ${
|
||||
metricsQ.data?.datasetRows ?? '—'
|
||||
} rows`
|
||||
: 'Scoring model metrics unavailable'}
|
||||
{metricsQ.data?.syntheticLabelShare === 1 && ' · labels are synthetic'}
|
||||
</span>
|
||||
<span>
|
||||
<Link to="/officer">Officer console</Link> · <Link to="/app">Borrower app</Link>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The claim this page is making, checked rather than asserted.
|
||||
*
|
||||
* <p>Each row puts the officer's value beside the borrower's own, fetched under
|
||||
* two different tokens from two different endpoints. They agree because they are
|
||||
* one record. Where they disagree this says so — a demo that cannot fail is not
|
||||
* demonstrating anything.
|
||||
*/
|
||||
function Agreement({
|
||||
lender,
|
||||
home,
|
||||
reasons,
|
||||
}: {
|
||||
lender: Enterprise | null
|
||||
home: AppHomeWire | null
|
||||
reasons: ReasonCodeRow[]
|
||||
}) {
|
||||
if (!lender || !home) return null
|
||||
|
||||
const appScore = Number((home.enterprise as Record<string, unknown>)?.score ?? NaN)
|
||||
const appBreach = home.forecast?.find((p) => p.breach)?.month ?? null
|
||||
|
||||
const checks = [
|
||||
{
|
||||
k: 'Stress score',
|
||||
lender: String(lender.score),
|
||||
app: Number.isNaN(appScore) ? '—' : String(appScore),
|
||||
},
|
||||
{
|
||||
k: 'First month the instalment is missed',
|
||||
lender: lender.firstBreachMonth ? monthLabel(lender.firstBreachMonth) : 'none',
|
||||
app: appBreach ? monthLabel(appBreach) : 'none',
|
||||
},
|
||||
{
|
||||
// The officer row carries the rendered sentence, not the driver code, so
|
||||
// this compares sentence to sentence. Comparing `driver` here would read
|
||||
// 'obligationLoad' against 'The instalment takes 88% of monthly inflow'
|
||||
// and report a disagreement between two halves of the same reason.
|
||||
k: 'Leading reason',
|
||||
lender: lender.topDriver ?? '—',
|
||||
app: reasons[0]?.sentenceEn ?? '—',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="dm-agree">
|
||||
<div className="dm-col-head">
|
||||
<span className="dm-tag">The point</span>
|
||||
<h2>Both sides are reading one record</h2>
|
||||
</div>
|
||||
<div className="dm-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Officer console</th>
|
||||
<th>Borrower’s phone</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{checks.map((c) => {
|
||||
const same = c.lender.toLowerCase() === c.app.toLowerCase()
|
||||
return (
|
||||
<tr key={c.k}>
|
||||
<th scope="row">{c.k}</th>
|
||||
<td>{c.lender}</td>
|
||||
<td>{c.app}</td>
|
||||
<td className={same ? 'dm-yes' : 'dm-no'}>{same ? 'agree' : 'differ'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="dm-note">
|
||||
Fetched under two separate tokens — the officer’s and this
|
||||
borrower’s own. Neither column is copied from the other.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+51
-8
@@ -42,7 +42,7 @@ const STATS = [
|
||||
/** Demo credentials — mirrored by UdyamDataSeeder on the backend. */
|
||||
const DEMO = {
|
||||
officer: { username: 'aditya.vyas', password: 'Udyam@123' },
|
||||
enterprise: { id: 'MSME-4417', pin: '1234' },
|
||||
enterprise: { id: 'MSME-6151', pin: '1234' },
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,6 +82,10 @@ const PERSONAS: {
|
||||
tier: 'Partner bank',
|
||||
note: 'its own book in full, borrower records included',
|
||||
accounts: [
|
||||
// Assam Gramin first: its book carries the widest spread of lead times, which
|
||||
// is what the early-warning view is built to show. MP Gramin is 8 breaching
|
||||
// now against 2 in the window — a single spike rather than a distribution.
|
||||
{ label: 'Assam Gramin · head office', username: 'agvb.admin', secret: 'Udyam@123', mode: 'OFFICER' },
|
||||
{ label: 'MP Gramin · head office', username: 'mpgb.admin', secret: 'Udyam@123', mode: 'OFFICER' },
|
||||
{ label: 'Sehore DCCB · head office', username: 'dccb.admin', secret: 'Udyam@123', mode: 'OFFICER' },
|
||||
{ label: 'Field officer', username: 'aditya.vyas', secret: 'Udyam@123', mode: 'OFFICER' },
|
||||
@@ -90,7 +94,11 @@ const PERSONAS: {
|
||||
{
|
||||
tier: 'Enterprise',
|
||||
note: 'one borrower, itself',
|
||||
accounts: [{ label: 'MSME-4417', username: 'MSME-4417', secret: '1234', mode: 'ENTERPRISE' }],
|
||||
accounts: [
|
||||
// The same borrower the officer console opens on, seen from the owner's side.
|
||||
{ label: 'MSME-6151 · Kaziranga Fruit Farm', username: 'MSME-6151', secret: '1234', mode: 'ENTERPRISE' },
|
||||
{ label: 'MSME-4417 · Sehore Poultry Farm', username: 'MSME-4417', secret: '1234', mode: 'ENTERPRISE' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -187,7 +195,7 @@ export function LoginPage() {
|
||||
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%)',
|
||||
'radial-gradient(rgba(76,197,130,0.06) 1px, transparent 1px), linear-gradient(150deg, #072e22 0%, #0b4a37 58%, #0e5c44 100%)',
|
||||
backgroundSize: '26px 26px, cover',
|
||||
}}
|
||||
>
|
||||
@@ -199,6 +207,14 @@ export function LoginPage() {
|
||||
<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>
|
||||
<img
|
||||
src="/nabard-logo.png"
|
||||
alt="NABARD"
|
||||
className="h-10 w-auto object-contain bg-white rounded-md px-2 py-1 ml-auto shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-center max-w-[560px]">
|
||||
@@ -235,11 +251,21 @@ export function LoginPage() {
|
||||
{/* ── 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 className="flex items-center gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
<UdyamMark size={24} />
|
||||
<span className="text-[16px] font-bold text-forest">
|
||||
Udyam<span className="text-amber">Linc</span>
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
src="/nabard-logo.png"
|
||||
alt="NABARD"
|
||||
className="h-10 w-auto object-contain ml-auto shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-[19px] font-bold text-forest">{hi ? 'अपने उद्यम में जाएँ' : 'Sign in to the console'}</h2>
|
||||
@@ -398,6 +424,23 @@ export function LoginPage() {
|
||||
? 'आपकी निजी जानकारी कहीं नहीं रखी जाती।'
|
||||
: 'Consent-first, explainable and auditable by design — no personally identifiable data.'}
|
||||
</p>
|
||||
|
||||
{/* Event attribution. Lives on the sign-in panel rather than the
|
||||
branding panel because that one is `hidden lg:flex` and vanishes
|
||||
below 1024px. No white chip needed here — the panel is white. */}
|
||||
<div className="flex items-center justify-center gap-2.5 mt-5 pt-5 border-t border-slate-200">
|
||||
<img
|
||||
src="/nabard-logo.png"
|
||||
alt="NABARD"
|
||||
className="h-9 w-auto object-contain shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-slate-400 font-mono leading-relaxed">
|
||||
Built for the NABARD Hackathon 2025
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/* ==================================================================
|
||||
* Demo page — one borrower, both sides
|
||||
*
|
||||
* Scoped under `.dm`. Its whole job is a comparison, so the two columns
|
||||
* carry equal weight and the phone is drawn as a phone: the jury has to
|
||||
* recognise which side is which without being told twice.
|
||||
*
|
||||
* Severity keeps the meaning it carries everywhere else in the console —
|
||||
* red is act-now, amber is in-window, blue is watch, green is covered.
|
||||
* Nothing here invents a new colour language for one screen.
|
||||
*
|
||||
* Tokens come from index.css (`@theme`), so this file defines no colours
|
||||
* of its own beyond the two phone-chrome shades.
|
||||
* ================================================================== */
|
||||
|
||||
.dm {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.dm .num { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── header ────────────────────────────────────────────────────── */
|
||||
.dm-top {
|
||||
display: flex; gap: 26px; align-items: flex-end; flex-wrap: wrap;
|
||||
padding-bottom: 16px; border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
.dm-top-lede { flex: 1 1 340px; min-width: 0; }
|
||||
.dm-top h1 {
|
||||
font-size: 25px; font-weight: 700; letter-spacing: -0.028em; margin: 0;
|
||||
text-wrap: balance; color: var(--color-ink);
|
||||
}
|
||||
.dm-top p {
|
||||
margin: 6px 0 0; font-size: 13px; line-height: 1.55;
|
||||
color: var(--color-ink-2); max-width: 62ch;
|
||||
}
|
||||
.dm-facts { display: flex; gap: 26px; margin: 0; flex-wrap: wrap; }
|
||||
.dm-facts dt {
|
||||
font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--color-ink-3); font-weight: 700;
|
||||
}
|
||||
.dm-facts dd {
|
||||
margin: 3px 0 0; font-size: 14px; font-weight: 700; color: var(--color-ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dm-warn {
|
||||
background: var(--color-amber-soft); border: 1px solid rgba(224, 128, 25, 0.28);
|
||||
border-radius: var(--radius-card); padding: 12px 15px;
|
||||
font-size: 12.5px; color: #7a4505; line-height: 1.55;
|
||||
}
|
||||
.dm-warn button {
|
||||
font: inherit; font-weight: 700; color: var(--color-lp-blue);
|
||||
background: none; border: none; cursor: pointer; padding: 0; text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── two columns of equal weight ───────────────────────────────── */
|
||||
.dm-split { display: grid; gap: 20px; grid-template-columns: 1.35fr 1fr; align-items: start; }
|
||||
@media (max-width: 960px) { .dm-split { grid-template-columns: 1fr; } }
|
||||
|
||||
.dm-col-head { margin-bottom: 11px; }
|
||||
.dm-tag {
|
||||
display: inline-block; font-size: 9.5px; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; font-weight: 700; color: var(--color-lp-blue);
|
||||
background: var(--color-lp-blue-soft); border-radius: 20px; padding: 3px 9px;
|
||||
}
|
||||
.dm-col-head h2 {
|
||||
font-size: 16px; font-weight: 700; letter-spacing: -0.018em;
|
||||
margin: 7px 0 0; color: var(--color-ink);
|
||||
}
|
||||
|
||||
/* ── triage ────────────────────────────────────────────────────── */
|
||||
.dm-triage {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px;
|
||||
background: var(--color-line); border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-card); overflow: hidden; box-shadow: var(--sh);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dm-seg { background: #fff; padding: 12px 13px 11px; position: relative; }
|
||||
.dm-seg::after {
|
||||
content: ""; position: absolute; inset: auto 0 0 0; height: 3px;
|
||||
background: var(--seg, var(--color-ink-3));
|
||||
}
|
||||
.dm-seg.is-now { --seg: var(--color-risk-red); }
|
||||
.dm-seg.is-window { --seg: var(--color-amber); }
|
||||
.dm-seg.is-watch { --seg: var(--color-lp-blue); }
|
||||
.dm-seg.is-clear { --seg: var(--color-green); }
|
||||
.dm-seg-label {
|
||||
display: block; font-size: 9.5px; letter-spacing: 0.09em; text-transform: uppercase;
|
||||
color: var(--color-ink-3); font-weight: 700;
|
||||
}
|
||||
.dm-seg-val {
|
||||
display: block; font-size: 27px; font-weight: 700; line-height: 1.05;
|
||||
letter-spacing: -0.03em; margin: 4px 0 2px; color: var(--seg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dm-seg-cap { display: block; font-size: 10.5px; color: var(--color-ink-3); line-height: 1.35; }
|
||||
|
||||
/* ── work list ─────────────────────────────────────────────────── */
|
||||
.dm-list {
|
||||
list-style: none; margin: 0; padding: 0; display: grid; gap: 1px;
|
||||
background: var(--color-line); border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-card); overflow: hidden; box-shadow: var(--sh);
|
||||
}
|
||||
.dm-row {
|
||||
width: 100%; display: flex; gap: 12px; align-items: center; text-align: left;
|
||||
background: #fff; border: none; color: inherit; font: inherit; cursor: pointer;
|
||||
padding: 11px 13px; transition: background 0.15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.dm-row:hover { background: var(--color-surface-2); }
|
||||
.dm-row.is-on { background: var(--color-lp-blue-soft); border-left-color: var(--color-lp-blue); }
|
||||
.dm-score {
|
||||
width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center;
|
||||
flex: none; font-size: 14px; font-weight: 700; border: 1px solid;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dm-score.is-now { background: var(--color-risk-soft); color: #9c2b1f; border-color: rgba(192, 57, 43, 0.22); }
|
||||
.dm-score.is-window { background: var(--color-amber-soft); color: #8a4e05; border-color: rgba(224, 128, 25, 0.24); }
|
||||
.dm-score.is-watch { background: var(--color-lp-blue-soft); color: #1d5490; border-color: rgba(42, 113, 184, 0.22); }
|
||||
.dm-score.is-clear { background: var(--color-green-soft); color: #186b3d; border-color: rgba(40, 174, 100, 0.22); }
|
||||
.dm-row-body { min-width: 0; flex: 1; }
|
||||
.dm-row-top { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.dm-row-top b { font-size: 13px; font-weight: 700; }
|
||||
.dm-when {
|
||||
font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 20px;
|
||||
background: var(--color-amber-soft); color: #8a4e05; white-space: nowrap;
|
||||
}
|
||||
.dm-when.is-now { background: var(--color-risk-soft); color: #9c2b1f; }
|
||||
.dm-when.is-clear { background: var(--color-green-soft); color: #186b3d; }
|
||||
.dm-meta { display: block; font-size: 10.5px; color: var(--color-ink-3); margin-top: 1px; }
|
||||
.dm-why { display: block; font-size: 11.5px; color: var(--color-ink-2); margin-top: 3px; }
|
||||
.dm-empty {
|
||||
background: #fff; padding: 22px; text-align: center;
|
||||
font-size: 12.5px; color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
/* ── the phone ─────────────────────────────────────────────────── */
|
||||
.dm-phone {
|
||||
max-width: 340px; margin: 0 auto; background: #fff;
|
||||
border: 8px solid #1c2429; border-radius: 26px; overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(13, 20, 24, 0.16);
|
||||
}
|
||||
.dm-phone-bar {
|
||||
background: #1c2429; color: #fff; padding: 6px 14px 10px;
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
|
||||
font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.dm-live { font-size: 9.5px; font-weight: 600; color: #7fe0ad; letter-spacing: 0.04em; }
|
||||
.dm-phone-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.dm-alert {
|
||||
background: var(--color-risk-red); color: #fff; border-radius: 10px; padding: 11px 13px;
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
}
|
||||
.dm-alert b { font-size: 13px; line-height: 1.35; }
|
||||
.dm-alert span { font-size: 11.5px; color: rgba(255, 255, 255, 0.9); }
|
||||
.dm-ok {
|
||||
background: var(--color-green-soft); border: 1px solid rgba(40, 174, 100, 0.22);
|
||||
color: #186b3d; border-radius: 10px; padding: 11px 13px; font-size: 12px;
|
||||
}
|
||||
.dm-skel { height: 62px; border-radius: 10px; background: var(--color-surface-3); }
|
||||
|
||||
.dm-fc-head {
|
||||
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
|
||||
font-size: 9.5px; letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--color-ink-3); font-weight: 700; margin-bottom: 7px;
|
||||
}
|
||||
.dm-bars { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; height: 92px; }
|
||||
.dm-bar { display: flex; flex-direction: column; gap: 4px; min-height: 0; }
|
||||
.dm-bar-track {
|
||||
flex: 1; position: relative; display: flex; align-items: flex-end;
|
||||
background: var(--color-surface-3); border-radius: 4px;
|
||||
}
|
||||
.dm-bar-fill { width: 100%; border-radius: 4px; background: var(--color-lp-blue-l); }
|
||||
.dm-bar-fill.is-breach { background: var(--color-risk-red); }
|
||||
/* What the instalment needs. A bar ending below this line is the shortfall. */
|
||||
.dm-bar-need {
|
||||
position: absolute; left: -1px; right: -1px; height: 0;
|
||||
border-top: 2px dashed var(--color-ink-2);
|
||||
}
|
||||
.dm-bar-x { font-size: 9.5px; color: var(--color-ink-3); text-align: center; }
|
||||
.dm-legend {
|
||||
margin: 8px 0 0; font-size: 10px; color: var(--color-ink-3);
|
||||
display: flex; align-items: center; gap: 5px; flex-wrap: wrap;
|
||||
}
|
||||
.dm-key-bar {
|
||||
width: 10px; height: 10px; border-radius: 2px;
|
||||
background: var(--color-lp-blue-l); display: inline-block;
|
||||
}
|
||||
.dm-key-line {
|
||||
width: 12px; border-top: 2px dashed var(--color-ink-2);
|
||||
display: inline-block; margin-left: 6px;
|
||||
}
|
||||
|
||||
.dm-reasons ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||
.dm-reasons li {
|
||||
font-size: 11.5px; line-height: 1.45; color: var(--color-ink-2);
|
||||
padding-left: 12px; position: relative;
|
||||
}
|
||||
.dm-reasons li::before {
|
||||
content: ""; position: absolute; left: 0; top: 6px;
|
||||
width: 5px; height: 5px; border-radius: 50%; background: var(--color-amber);
|
||||
}
|
||||
.dm-today {
|
||||
display: flex; gap: 14px; font-size: 11.5px; color: var(--color-ink-3);
|
||||
border-top: 1px solid var(--color-line); padding-top: 9px;
|
||||
}
|
||||
.dm-today b { color: var(--color-ink); }
|
||||
|
||||
/* ── agreement table ───────────────────────────────────────────── */
|
||||
.dm-agree {
|
||||
background: #fff; border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-card); padding: 18px 20px; box-shadow: var(--sh);
|
||||
}
|
||||
.dm-table-wrap { overflow-x: auto; }
|
||||
.dm-agree table { width: 100%; border-collapse: collapse; min-width: 520px; }
|
||||
.dm-agree thead th {
|
||||
font-size: 9.5px; letter-spacing: 0.09em; text-transform: uppercase;
|
||||
color: var(--color-ink-3); font-weight: 700; text-align: left;
|
||||
padding: 0 12px 7px 0; border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
.dm-agree tbody th {
|
||||
font-size: 12px; font-weight: 600; color: var(--color-ink-2);
|
||||
text-align: left; padding: 10px 12px 10px 0;
|
||||
}
|
||||
.dm-agree tbody td {
|
||||
font-size: 13px; font-weight: 700; color: var(--color-ink);
|
||||
padding: 10px 12px 10px 0; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dm-agree tbody tr + tr th,
|
||||
.dm-agree tbody tr + tr td { border-top: 1px solid var(--color-line); }
|
||||
.dm-yes { color: var(--color-green); font-size: 11px; font-weight: 700; }
|
||||
.dm-no { color: var(--color-risk-red); font-size: 11px; font-weight: 700; }
|
||||
.dm-note {
|
||||
margin: 12px 0 0; font-size: 11.5px; color: var(--color-ink-3);
|
||||
line-height: 1.5; max-width: 70ch;
|
||||
}
|
||||
|
||||
.dm-foot {
|
||||
display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
||||
font-size: 11.5px; color: var(--color-ink-3);
|
||||
border-top: 1px solid var(--color-line); padding-top: 14px;
|
||||
}
|
||||
.dm-foot a { color: var(--color-lp-blue); font-weight: 600; }
|
||||
.dm button:focus-visible { outline: 2px solid var(--color-lp-blue); outline-offset: 2px; }
|
||||
|
||||
/* A borrower with no phone app. Stated rather than shown as an error: the
|
||||
platform not needing the app is a claim worth making on this screen. */
|
||||
.dm-none {
|
||||
background: var(--color-surface-3); border: 1px solid var(--color-line);
|
||||
border-radius: 10px; padding: 12px 13px;
|
||||
display: flex; flex-direction: column; gap: 5px;
|
||||
}
|
||||
.dm-none b { font-size: 12.5px; color: var(--color-ink); }
|
||||
.dm-none span { font-size: 11.5px; line-height: 1.5; color: var(--color-ink-2); }
|
||||
|
||||
.dm-hasapp {
|
||||
margin-left: 7px; font-size: 9px; font-weight: 700; letter-spacing: 0.04em;
|
||||
text-transform: uppercase; color: var(--color-lp-blue);
|
||||
background: var(--color-lp-blue-soft); border-radius: 20px; padding: 1px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -3,10 +3,34 @@ import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
/**
|
||||
* The backend, for the two dev-time servers. Production does not use either of
|
||||
* these — nginx or httpd proxies /api/ there — but `npm run preview` serves the
|
||||
* real production bundle, which is built with a *relative* API base and so has
|
||||
* no host to call without a proxy in front of it. Without this block the only
|
||||
* way to exercise the shipped artifact is to deploy it, which is how a bundle
|
||||
* reaches a demo server untested.
|
||||
*/
|
||||
const API_TARGET = 'http://127.0.0.1:8082'
|
||||
|
||||
const proxy = {
|
||||
'/api': {
|
||||
target: API_TARGET,
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
// 5180 belongs to BharatLinc-UI — UdyamLinc runs alongside it
|
||||
port: 5182,
|
||||
proxy,
|
||||
},
|
||||
// Serves dist/ exactly as it will be served in production, with the same
|
||||
// relative API base, so a preview run is a real rehearsal of the deployment.
|
||||
preview: {
|
||||
port: 4173,
|
||||
proxy,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user