Author SHA1 Message Date
LincPayandClaude Opus 5 b6eea1360f feat(sources): show every data source, and fix the navigation
Adds the data-source inventory the problem statement implies but the console
never showed: seventeen sources across core banking, UPI, mandi prices,
climate and manual upload, each badged Live, Simulated or Not connected from
sourceStatus.ts rather than from a static list. A jury — or a bank — can see
at a glance which feeds are real, and the ones that are simulated say so on
their own card instead of in a footnote.

Navigation moves to a fixed header with grouped dropdown submenus. The left
rail had grown past what a field officer could scan, and related screens were
scattered across it. MenuSelect replaces the native <select> in the identity
bar: a native control inherits the OS widget and looked foreign inside the
console, and putting overflow-x on the nav to fit it clipped the dropdowns and
added a scrollbar on Windows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:46:14 +05:30
LincPayandClaude Opus 5 9d307c2d20 fix(reports): show the measured metrics, including the one that beats us
The Reports page carried a BENCHMARKS block of invented figures. It now reads
GET /admin/model/metrics and the block is deleted, with a comment recording
what it held and why it is gone. No fabricated number remains anywhere in the
product.

The comparison chart sorts by value rather than by kind, which puts the
obligation_ratio_only baseline at 0.768 above the LightGBM model at 0.760.
Grouping models above baselines would have hidden that, which is the original
problem wearing better clothes. Baselines are drawn grey, models blue.

The synthetic-label share travels with every figure, so accuracy cannot be
read here without the caveat. fmt() prints an em dash for an absent metric,
never 0.00 — a missing measurement and a measured zero are different claims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:46:02 +05:30
LincPayandClaude Opus 5 1ffc1cbd4c feat(demo): one borrower on both surfaces, and a lead-time dashboard
/demo puts the officer console and the borrower's phone side by side on one
screen. The right column is a real borrower sign-in — selecting a row calls
/auth/app-login and every panel beside it is fetched with that borrower's own
token. Nothing is mirrored across, which is the point: if the two sides agree
it is because they read one record, and the page checks that rather than
asserting it.

It uses its own axios instance. The shared one picks its JWT from
window.location.pathname, which is right for the two portals and wrong here —
it would send the officer's token to /app/*, and its 401 handler would then
clear both sessions and bounce to sign-in, during a live demo.

The officer dashboard is reorganised around when a borrower's downside stops
covering the instalment, rather than by composite score. Lead time is the
thing this platform can show that a bureau score cannot, so the triage funnel
counts act-now / in-window / covered and the work list ranks by it. The
previous score-ranked view is preserved as a tab; they answer different
questions and sort by different keys, and merging them would mean losing one
ordering.

StatusPill no longer white-screens on a missing status. It renders wire data
on fifteen call sites; a row that arrives without one is a data gap, not
grounds to take the page down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:45:48 +05:30
LincPayandClaude Opus 5 13c628fa4b feat(pwa): actually send the offline queue, and sync when the radio returns
syncPending() flipped entries from PENDING to SYNCED without sending
anything. The offline half of the promise worked — the owner could record a
sale with no signal and never saw a spinner — but the entry stopped at
localStorage and the lender never saw it.

It now posts to /app/ledger/sync and marks only the entries the server
accepted. It re-reads storage before writing rather than reusing the snapshot,
because the owner may have tapped save again while the request was in flight
and that entry must stay PENDING. A failure is deliberately silent: the
entries stay queued and go again next time, because from the owner's side
nothing has gone wrong, and losing a tap is the one failure this app cannot
have.

useAutoSync() drains the queue on the browser's `online` event and once on
mount — the app is commonly opened after connectivity returns rather than
while it is returning, in which case no online event ever fires.

The consent toggle now writes to the server instead of holding local state,
and the alerts screen renders the borrower's real reason codes from
/app/reasons. Where the request succeeds with nothing on record it says so;
inventing three sentences on the borrower's own screen would be a lie about
the one thing they are entitled to see.

Also fixes a month label that rendered blank on every live forecast point: a
${p.month} interpolation had been eaten by an earlier edit, so the strip only
displayed correctly when the API was unreachable and the demo generator took
over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:45:34 +05:30
LincPayandClaude Opus 5 41c49113c2 fix(tenancy): borrower rows come from the scoped API or they do not render
The console fell back to lib/demo/* whenever 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 — 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. Risk alerts and Field visits never called the API at
all.

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.

So the rule is now the one the central claim requires: borrower rows come
from the tenant-scoped API or the screen says it cannot reach the server. 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
whole argument is that a bank's book is unreachable from another bank's
screen.

useTenantData.ts is the only sanctioned source. Each hook returns
`unavailable` rather than substituting data. scopedEnterprises() now takes the
book as a required argument with no default, so reintroducing the leak is a
compile error rather than an oversight.

Feeds, data sources, connectors and the rule catalogue keep their demo
fallbacks. Nothing in them is a borrower.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:45:19 +05:30
LincPay 39ca34be68 feat(config): resolve the API base at runtime from config.js 2026-08-15 17:01:45 +05:30
LincPay aa1484c285 build(dev): proxy /api in dev and preview so the production bundle is testable 2026-08-15 16:46:33 +05:30
LincPay 318b83625a build(deploy): relative API base and a same-origin /api proxy 2026-08-15 15:33:21 +05:30
51 changed files with 5376 additions and 760 deletions
+8 -2
View File
@@ -4,8 +4,14 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
# VITE_UDYAM_API_BASE_URL is unset here, so the compiled-in default # Vite inlines this at build time, so it is a build arg and not a runtime env
# (http://udyamlinc-backend.lincpay/api/v1/udyam) is baked into the bundle. # 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 RUN npm run build
# ── serve stage: nginx serving the static SPA on 5173 ── # ── serve stage: nginx serving the static SPA on 5173 ──
+7
View File
@@ -11,6 +11,13 @@
</head> </head>
<body> <body>
<div id="root"></div> <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> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+31
View File
@@ -19,6 +19,37 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate"; 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 # SPA fallback — React Router owns the paths
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
+30
View File
@@ -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
View File
@@ -6,7 +6,7 @@
* queued in localStorage by the app itself (see lib/ledgerStore.ts); this worker * queued in localStorage by the app itself (see lib/ledgerStore.ts); this worker
* only guarantees the shell and its assets are reachable. * 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'] const SHELL = ['/', '/app', '/index.html', '/manifest.webmanifest', '/udyamlinc-tile.svg']
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
+19 -1
View File
@@ -1,6 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom' import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { DemoPage } from './pages/DemoPage'
import { enterpriseSession, officerSession, homeFor, isApexRole } from './lib/api' import { enterpriseSession, officerSession, homeFor, isApexRole } from './lib/api'
// Field officer console // Field officer console
@@ -11,7 +12,9 @@ import { RegionalOfficesPage } from './features/nabard/RegionalOfficesPage'
import { BankScorecardPage } from './features/nabard/BankScorecardPage' import { BankScorecardPage } from './features/nabard/BankScorecardPage'
import { DigestPage } from './features/nabard/DigestPage' import { DigestPage } from './features/nabard/DigestPage'
import { InstitutionsPage } from './features/nabard/InstitutionsPage' import { InstitutionsPage } from './features/nabard/InstitutionsPage'
import { SourceCoveragePage } from './features/nabard/SourceCoveragePage'
import { OfficerDashboard } from './features/officer/OfficerDashboard' import { OfficerDashboard } from './features/officer/OfficerDashboard'
import { OfficerDashboardClassic } from './features/officer/OfficerDashboardClassic'
import { EnterprisesPage } from './features/officer/EnterprisesPage' import { EnterprisesPage } from './features/officer/EnterprisesPage'
import { SectorPortfolioPage } from './features/officer/SectorPortfolioPage' import { SectorPortfolioPage } from './features/officer/SectorPortfolioPage'
import { EnterpriseDetailPage } from './features/officer/EnterpriseDetailPage' import { EnterpriseDetailPage } from './features/officer/EnterpriseDetailPage'
@@ -19,6 +22,7 @@ import { AlertsPage } from './features/officer/AlertsPage'
import { ForecastsPage } from './features/officer/ForecastsPage' import { ForecastsPage } from './features/officer/ForecastsPage'
import { VisitsPage } from './features/officer/VisitsPage' import { VisitsPage } from './features/officer/VisitsPage'
import { RulesPage } from './features/officer/RulesPage' import { RulesPage } from './features/officer/RulesPage'
import { DataSourcesPage } from './features/officer/DataSourcesPage'
import { DataFeedsPage } from './features/officer/DataFeedsPage' import { DataFeedsPage } from './features/officer/DataFeedsPage'
import { DataEntryPage } from './features/officer/DataEntryPage' import { DataEntryPage } from './features/officer/DataEntryPage'
import { IntegrationPage } from './features/officer/IntegrationPage' import { IntegrationPage } from './features/officer/IntegrationPage'
@@ -81,6 +85,16 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<RootRedirect />} /> <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 />} /> <Route path="/login" element={<LoginPage />} />
{/* Field officer console — portfolio, risk detail and recommended action */} {/* Field officer console — portfolio, risk detail and recommended action */}
@@ -92,7 +106,9 @@ export default function App() {
</RequireOfficer> </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="portfolio" element={<SectorPortfolioPage />} />
<Route path="enterprises" element={<EnterprisesPage />} /> <Route path="enterprises" element={<EnterprisesPage />} />
<Route path="enterprises/:enterpriseId" element={<EnterpriseDetailPage />} /> <Route path="enterprises/:enterpriseId" element={<EnterpriseDetailPage />} />
@@ -100,6 +116,7 @@ export default function App() {
<Route path="forecasts" element={<ForecastsPage />} /> <Route path="forecasts" element={<ForecastsPage />} />
<Route path="visits" element={<VisitsPage />} /> <Route path="visits" element={<VisitsPage />} />
<Route path="rules" element={<RulesPage />} /> <Route path="rules" element={<RulesPage />} />
<Route path="sources" element={<DataSourcesPage />} />
<Route path="feeds" element={<DataFeedsPage />} /> <Route path="feeds" element={<DataFeedsPage />} />
<Route path="data-entry" element={<DataEntryPage />} /> <Route path="data-entry" element={<DataEntryPage />} />
<Route path="integration" element={<IntegrationPage />} /> <Route path="integration" element={<IntegrationPage />} />
@@ -121,6 +138,7 @@ export default function App() {
<Route path="banks" element={<BankScorecardPage />} /> <Route path="banks" element={<BankScorecardPage />} />
<Route path="digest" element={<DigestPage />} /> <Route path="digest" element={<DigestPage />} />
<Route path="institutions" element={<InstitutionsPage />} /> <Route path="institutions" element={<InstitutionsPage />} />
<Route path="sources" element={<SourceCoveragePage />} />
</Route> </Route>
{/* Enterprise PWA — offline, Hindi-first */} {/* Enterprise PWA — offline, Hindi-first */}
+50 -10
View File
@@ -1,31 +1,71 @@
import { Bar } from 'react-chartjs-2' import { Bar } from 'react-chartjs-2'
import type { ChartOptions } from 'chart.js' 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 { CHART_COLOURS } from './chartSetup'
import './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 = { const data = {
labels: ['UdyamLinc — full signal set', 'Repayment history only', 'Bureau-score baseline'], labels: entries.map((e) => ARM_LABEL[e.key] ?? e.key),
datasets: [ datasets: [
{ {
label: 'AUC-ROC', label: metric === 'auc_roc' ? 'AUC-ROC' : 'PR-AUC',
data: [BENCHMARKS.aucRoc, BENCHMARKS.aucRepaymentOnly, BENCHMARKS.aucBureau], data: entries.map((e) => e.value),
backgroundColor: [CHART_COLOURS.forest, CHART_COLOURS.greenHi, '#94a3b8'], backgroundColor: entries.map((e) => (e.arm.baseline ? '#9aa8b4' : CHART_COLOURS.forest)),
borderRadius: 4, 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'> = { const options: ChartOptions<'bar'> = {
indexAxis: 'y', indexAxis: 'y',
responsive: true, responsive: true,
maintainAspectRatio: false, 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: { 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 } } }, y: { grid: { display: false }, ticks: { font: { size: 10.5 } } },
}, },
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import './chartSetup'
/** The five risk families as a radar — shows the *shape* of the stress, not just its size. */ /** 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 }) { 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 = { const data = {
labels: DRIVER_META.map((d) => d.label), labels: DRIVER_META.map((d) => d.label),
+7 -7
View File
@@ -27,15 +27,15 @@ ChartJS.register(
Legend, 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.font.size = 11
ChartJS.defaults.color = '#64748b' ChartJS.defaults.color = '#87939c'
export const CHART_COLOURS = { export const CHART_COLOURS = {
forest: '#0b5d3b', forest: '#2a71b8', // s1 — the design's primary series
green: '#1e7a46', green: '#28ae64', // s3
greenHi: '#3fb871', greenHi: '#4cc582',
amber: '#e8a020', amber: '#e08019', // s2
red: '#c0392b', red: '#c0392b',
grid: '#e8eeea', grid: '#edf0f3',
} }
+124
View File
@@ -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>
)
}
+164
View File
@@ -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
View File
@@ -8,7 +8,10 @@ import { BANDS } from '../../lib/demo/enterprises'
/* the bottom (RiskBadge / ScoreDial / Sparkline / DriverBar) are new. */ /* 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 s = status.toUpperCase()
const cls = const cls =
s.includes('DONE') || s.includes('CLOSED') || s.includes('SYNCED') || s.includes('ACTIVE') s.includes('DONE') || s.includes('CLOSED') || s.includes('SYNCED') || s.includes('ACTIVE')
@@ -37,11 +40,11 @@ export function Card({
className?: string className?: string
}) { }) {
return ( 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 && ( {title && (
<div className="px-3.5 py-2.5 border-b border-slate-200 flex items-center gap-2"> <div className="px-4 py-3 border-b border-line flex items-center gap-2.5">
<h3 className="text-[12.5px] font-bold text-forest">{title}</h3> <h3 className="text-[13.5px] font-semibold text-ink tracking-[-0.012em]">{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>} {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>} {actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
</div> </div>
)} )}
@@ -83,7 +86,7 @@ export function Modal({
export function SubNav({ items }: { items: { label: string; to: string; end?: boolean }[] }) { export function SubNav({ items }: { items: { label: string; to: string; end?: boolean }[] }) {
return ( 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) => ( {items.map((i) => (
<NavLink <NavLink
key={i.to} key={i.to}
@@ -92,7 +95,7 @@ export function SubNav({ items }: { items: { label: string; to: string; end?: bo
className={({ isActive }) => className={({ isActive }) =>
[ [
'px-3.5 py-2 text-[11.5px] font-semibold whitespace-nowrap border-b-2 -mb-px', '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(' ') ].join(' ')
} }
> >
@@ -124,7 +127,7 @@ export function Field({
} }
export const inputCls = 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({ export function PrimaryBtn({
children, children,
@@ -134,7 +137,7 @@ export function PrimaryBtn({
return ( return (
<button <button
{...rest} {...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} {children}
</button> </button>
@@ -149,7 +152,7 @@ export function GhostBtn({
return ( return (
<button <button
{...rest} {...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} {children}
</button> </button>
@@ -166,7 +169,7 @@ export function ActionChip({
{...rest} {...rest}
className={[ className={[
'text-[11px] font-semibold px-2.5 py-1 rounded-full border transition-colors', '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(' ')} ].join(' ')}
> >
{children} {children}
@@ -176,7 +179,7 @@ export function ActionChip({
/** Empty/loading state used wherever a list can come back with nothing. */ /** Empty/loading state used wherever a list can come back with nothing. */
export function Empty({ text }: { text: string }) { 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 ─────────────────────────────────── // ── UdyamLinc risk primitives ───────────────────────────────────
@@ -194,7 +197,7 @@ export function ScoreDial({ score, band, size = 96 }: { score: number; band: Ban
const stroke = size * 0.11 const stroke = size * 0.11
const r = (size - stroke) / 2 const r = (size - stroke) / 2
const circ = 2 * Math.PI * r 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 ( return (
<div className="relative grid place-items-center" style={{ width: size, height: size }}> <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`}> <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 min = Math.min(...values)
const max = Math.max(...values) const max = Math.max(...values)
const span = Math.max(1, max - min) 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 const pts = values
.map((v, i) => { .map((v, i) => {
const x = (i / (values.length - 1)) * (width - 4) + 2 const x = (i / (values.length - 1)) * (width - 4) + 2
@@ -285,16 +288,16 @@ export function KpiTile({
tone?: 'neutral' | 'red' | 'watch' | 'good' tone?: 'neutral' | 'red' | 'watch' | 'good'
}) { }) {
const dot = const dot =
tone === 'red' ? 'bg-risk-red' : tone === 'watch' ? 'bg-amber' : tone === 'good' ? 'bg-green' : 'bg-forest' 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-slate-400' const deltaCls = tone === 'red' ? 'text-risk-red' : tone === 'watch' ? 'text-amber' : 'text-ink-3'
return ( return (
<div className="ul-card px-3.5 py-3 flex-1 min-w-[150px]"> <div className="ul-card px-3.5 py-3 flex-1 min-w-[150px]">
<div className="flex items-start justify-between"> <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}`} /> <span className={`w-2.5 h-2.5 rounded-[3px] ${dot}`} />
</div> </div>
<div className="text-[26px] font-bold text-forest leading-tight mt-1">{value}</div> <div className="text-[28px] font-bold text-ink leading-none mt-1.5 tracking-[-0.03em] num">{value}</div>
{delta && <div className={`text-[10.5px] ${deltaCls}`}>{delta}</div>} {delta && <div className={`text-[11.5px] mt-1.5 ${deltaCls}`}>{delta}</div>}
</div> </div>
) )
} }
@@ -303,8 +306,76 @@ export function KpiTile({
export function UdyamMark({ size = 26, onDark = false }: { size?: number; onDark?: boolean }) { export function UdyamMark({ size = 26, onDark = false }: { size?: number; onDark?: boolean }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 64 64" aria-label="UdyamLinc" role="img"> <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'} /> <path d="M34.5 15 20 36h10l-2.5 13L44 27H33.5z" fill={onDark ? '#f2b544' : '#e8a020'} />
</svg> </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>
)
}
+26 -3
View File
@@ -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 { Empty } from '../../components/ui/bits'
import { findEnterprise, reasonCodesFor, tripWiresFor } from '../../lib/demo/enterprises' import { findEnterprise, reasonCodesFor, tripWiresFor } from '../../lib/demo/enterprises'
import { alertForEnterprise } from '../../lib/demo/ops' import { alertForEnterprise } from '../../lib/demo/ops'
@@ -15,11 +16,28 @@ export function AppAlertsPage() {
const { lang, t } = useLang('hi') const { lang, t } = useLang('hi')
const hi = lang === '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 const e = session ? findEnterprise(session.enterpriseId) : undefined
if (!session || !e) return <Empty text="—" /> if (!session || !e) return <Empty text="—" />
const alert = alertForEnterprise(e.enterpriseId) const live = alertsQ.data?.[0]
const reasons = reasonCodesFor(e) 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) const fired = tripWiresFor(e).filter((w) => w.fired)
if (!alert) { if (!alert) {
@@ -52,6 +70,11 @@ export function AppAlertsPage() {
{hi ? 'ऐसा क्यों लग रहा है' : 'Why this is expected'} {hi ? 'ऐसा क्यों लग रहा है' : 'Why this is expected'}
</div> </div>
<ul className="space-y-2.5"> <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) => ( {reasons.map((r) => (
<li key={r.driver} className="flex gap-2.5"> <li key={r.driver} className="flex gap-2.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber mt-[7px] shrink-0" /> <span className="w-1.5 h-1.5 rounded-full bg-amber mt-[7px] shrink-0" />
+25 -6
View File
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' 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 type { ForecastPoint } from '../../lib/api'
import { Empty } from '../../components/ui/bits' import { Empty } from '../../components/ui/bits'
import { breachPoint, findEnterprise, forecastFor } from '../../lib/demo/enterprises' import { breachPoint, findEnterprise, forecastFor } from '../../lib/demo/enterprises'
@@ -23,13 +24,31 @@ export function AppHomePage() {
const [sheet, setSheet] = useState<LedgerKind | null>(null) const [sheet, setSheet] = useState<LedgerKind | null>(null)
const e = session ? findEnterprise(session.enterpriseId) : undefined 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="—" /> if (!session || !e) return <Empty text="—" />
const points = forecastFor(e) // Server forecast where we have one; the in-file generator only if the API is
const alert = alertForEnterprise(e.enterpriseId) // unreachable, so a borrower with no signal still sees their own screen.
const breach = breachPoint(points) 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 breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
const totals = dayTotals(entries) const totals = dayTotals(entries)
const hi = lang === 'hi' const hi = lang === 'hi'
@@ -51,7 +70,7 @@ export function AppHomePage() {
{hi ? alert.titleHi : alert.title} {hi ? alert.titleHi : alert.title}
</span> </span>
<span className="block text-[12.5px] font-bold mt-1"> <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> </span>
<span className="text-[16px] opacity-70">›</span> <span className="text-[16px] opacity-70">›</span>
+5 -2
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react' 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 type { LedgerKind } from '../../lib/api'
import { Empty } from '../../components/ui/bits' import { Empty } from '../../components/ui/bits'
import { CATEGORIES, useLedger } from '../../lib/ledgerStore' import { CATEGORIES, useLedger } from '../../lib/ledgerStore'
@@ -15,7 +16,9 @@ export function AppLedgerPage() {
const { lang, t } = useLang('hi') const { lang, t } = useLang('hi')
const [range, setRange] = useState<Range>('WEEK') const [range, setRange] = useState<Range>('WEEK')
const [sheet, setSheet] = useState<LedgerKind | null>(null) 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 hi = lang === 'hi'
const cutoff = useMemo(() => { const cutoff = useMemo(() => {
+36 -9
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' 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 type { Lang } from '../../lib/api'
import { Empty } from '../../components/ui/bits' import { Empty } from '../../components/ui/bits'
import { SECTORS, findEnterprise } from '../../lib/demo/enterprises' import { SECTORS, findEnterprise } from '../../lib/demo/enterprises'
@@ -16,20 +17,41 @@ import { useOffline } from '../../lib/useOffline'
export function AppProfilePage() { export function AppProfilePage() {
const navigate = useNavigate() const navigate = useNavigate()
const session = enterpriseSession() const session = enterpriseSession()
const queryClient = useQueryClient()
const { lang, setLang, t } = useLang('hi') const { lang, setLang, t } = useLang('hi')
const offline = useOffline() const offline = useOffline()
const [consent, setConsent] = useState(true)
const [justSynced, setJustSynced] = useState<number | null>(null) const [justSynced, setJustSynced] = useState<number | null>(null)
const hi = lang === 'hi' 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="—" /> if (!session || !e) return <Empty text="—" />
const pending = pendingCount(e.enterpriseId) const pending = pendingCount(e.enterpriseId)
const synced = lastSyncedAt() const synced = lastSyncedAt()
function doSync() { async function doSync() {
const n = syncPending(e!.enterpriseId) const n = await syncPending(e!.enterpriseId)
setJustSynced(n) setJustSynced(n)
setTimeout(() => setJustSynced(null), 2200) setTimeout(() => setJustSynced(null), 2200)
} }
@@ -76,10 +98,15 @@ export function AppProfilePage() {
: 'Only transaction aggregates are read — never a name, number or account.'} : 'Only transaction aggregates are read — never a name, number or account.'}
</p> </p>
<button <button
onClick={() => setConsent((c) => !c)} onClick={() => consentM.mutate(!consent)}
className="mt-2.5 text-[12px] font-semibold text-risk-red border border-risk-red/30 rounded-lg px-3 py-1.5" 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> </button>
</section> </section>
@@ -96,7 +123,7 @@ export function AppProfilePage() {
{t('profile.lastSynced')}: {synced ? new Date(synced).toLocaleString() : t('profile.never')} {t('profile.lastSynced')}: {synced ? new Date(synced).toLocaleString() : t('profile.never')}
</div> </div>
<button <button
onClick={doSync} onClick={() => void doSync()}
disabled={offline || pending === 0} 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" className="mt-2.5 w-full bg-forest disabled:opacity-40 text-white text-[13px] font-semibold py-2.5 rounded-lg"
> >
+5 -2
View File
@@ -3,7 +3,7 @@ import { enterpriseSession } from '../../lib/api'
import { UdyamMark } from '../../components/ui/bits' import { UdyamMark } from '../../components/ui/bits'
import { useLang } from '../../lib/i18n' import { useLang } from '../../lib/i18n'
import { useOffline } from '../../lib/useOffline' 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 * 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 session = enterpriseSession()
const { lang, t } = useLang('hi') const { lang, t } = useLang('hi')
const offline = useOffline() 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 const pending = session ? pendingCount(session.enterpriseId) : 0
return ( return (
<div className="min-h-screen bg-[#eef3f0] flex justify-center"> <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 ────────────────────────────────────────── */}
<header className="bg-forest text-white px-4 py-3 flex items-center gap-2.5 sticky top-0 z-30"> <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 /> <UdyamMark size={26} onDark />
+11 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import type { LedgerKind } from '../../lib/api' 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 { useLang } from '../../lib/i18n'
import { inr } from '../../lib/demo/seed' import { inr } from '../../lib/demo/seed'
@@ -32,14 +33,23 @@ export function EntrySheet({
const [amount, setAmount] = useState('') const [amount, setAmount] = useState('')
const [category, setCategory] = useState(CATEGORIES[kind][0].code) const [category, setCategory] = useState(CATEGORIES[kind][0].code)
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
const queryClient = useQueryClient()
const hi = lang === 'hi' const hi = lang === 'hi'
const meta = KIND_META[kind] const meta = KIND_META[kind]
const value = Number(amount) || 0 const value = Number(amount) || 0
function save() { function save() {
if (value <= 0) return 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 }) addEntry({ enterpriseId, kind, amount: value, category })
setDone(true) 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) setTimeout(onClose, 900)
} }
+73 -51
View File
@@ -1,5 +1,5 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom' 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' import { clearSessions, isRegionalOfficeRole, officerSession } from '../../lib/api'
const NAV = [ const NAV = [
@@ -8,6 +8,7 @@ const NAV = [
{ label: 'Partner banks', to: '/nabard/banks', icon: Landmark }, { label: 'Partner banks', to: '/nabard/banks', icon: Landmark },
{ label: 'Early warning', to: '/nabard/digest', icon: Siren }, { label: 'Early warning', to: '/nabard/digest', icon: Siren },
{ label: 'Institutions', to: '/nabard/institutions', icon: Building2 }, { 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 * 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 * 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. * 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() { export function NabardShell() {
const navigate = useNavigate() const navigate = useNavigate()
const session = officerSession() const session = officerSession()
const tier = isRegionalOfficeRole(session?.role) ? 'NABARD Regional Office' : 'NABARD Head Office'
return ( return (
<div className="min-h-screen flex bg-slate-50"> <div className="min-h-screen flex flex-col bg-page">
<aside className="w-[210px] shrink-0 bg-[#123c2b] text-white flex flex-col px-3 py-5"> <header className="sticky top-0 z-40">
<div className="flex items-center gap-2.5 px-2 pb-5"> <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)]">
<span className="w-9 h-9 rounded-[9px] bg-[#0f6b4c] grid place-items-center shrink-0"> <NavLink to="/nabard" className="flex items-center gap-2.5 shrink-0">
<ShieldCheck size={19} /> <span className="w-[30px] h-[30px] rounded-[9px] bg-forest-mid grid place-items-center shrink-0">
</span> <ShieldCheck size={17} />
<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'}
</span> </span>
</span> <span className="leading-tight hidden sm:block">
</div> <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"> <span className="w-px h-6 bg-white/20 hidden md:block" />
{NAV.map((n) => (
<NavLink <nav className="flex items-center gap-0.5 min-w-0">
key={n.to} {NAV.map((n) => (
to={n.to} <NavLink
end={n.end} key={n.to}
className={({ isActive }) => to={n.to}
`flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[12.5px] transition-colors ${ end={n.end}
isActive ? 'bg-white/15 text-white font-semibold' : 'text-[#cfe2da] hover:bg-white/10' 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} /> <LogOut size={13} />
{n.label} Sign out
</NavLink> </button>
))} </div>
</nav> </div>
{/* {/*
Stated in the chrome, on every screen, rather than buried in a policy 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 page: this tier cannot reach a borrower. A supervisor should be able to
tell a partner bank what this portal can see without checking. 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"> <div className="bg-green-soft border-b border-line px-5 py-2 flex items-center gap-2 text-[11px] text-[#186b3d]">
<b className="text-[#dcebe4] block mb-0.5">Aggregate view only</b> <ShieldCheck size={14} className="shrink-0" />
This portal holds no borrower identity. Figures are counts and rates <span>
across refinanced portfolios; cells below the minimum size are withheld. <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> </div>
</header>
<button <main className="flex-1 min-w-0 px-5 py-4 w-full max-w-[1400px] ul-pane">
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]">
<Outlet /> <Outlet />
</main> </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> </div>
) )
} }
+300
View File
@@ -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>
)
}
+38 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' 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 type { AlertState, RiskAlert } from '../../lib/api'
import { ActionChip, Card, Empty, GhostBtn, RiskBadge, StatusPill } from '../../components/ui/bits' import { ActionChip, Card, Empty, GhostBtn, RiskBadge, StatusPill } from '../../components/ui/bits'
import { SECTORS } from '../../lib/demo/enterprises' import { SECTORS } from '../../lib/demo/enterprises'
@@ -25,7 +26,20 @@ export function AlertsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { block } = useScope() const { block } = useScope()
const [filter, setFilter] = useState<AlertState | 'ALL'>('OPEN') 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 actor = officerSession()?.fullName ?? 'Field Officer'
const rows = alerts.filter((a) => filter === 'ALL' || a.state === filter) const rows = alerts.filter((a) => filter === 'ALL' || a.state === filter)
@@ -34,6 +48,26 @@ export function AlertsPage() {
setAlertState(alert, to, actor) 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 ( return (
<div className="grid gap-4 xl:grid-cols-[1fr_320px] items-start"> <div className="grid gap-4 xl:grid-cols-[1fr_320px] items-start">
<div> <div>
@@ -80,8 +114,8 @@ export function AlertsPage() {
</div> </div>
<div className="text-[11px] text-slate-500 mt-0.5"> <div className="text-[11px] text-slate-500 mt-0.5">
{a.enterpriseId} · {SECTORS[a.sector].short} · {a.block} · score{' '} {a.enterpriseId} · {SECTORS[a.sector]?.short ?? a.sector} · {a.block} · score{' '}
<b className="text-slate-700">{a.score}</b> · raised {a.raisedAt} <b className="text-slate-700">{a.score}</b> · raised {a.raisedAt?.slice(0, 16).replace('T', ' ')}
</div> </div>
<p className="text-[12.5px] font-semibold text-slate-700 mt-2">{a.title}</p> <p className="text-[12.5px] font-semibold text-slate-700 mt-2">{a.title}</p>
+43
View File
@@ -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>
)
}
+116
View File
@@ -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>
)
}
+317 -45
View File
@@ -1,7 +1,16 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { officerSession } from '../../lib/api' import { enterpriseDetail, isNotFound, officerSession } from '../../lib/api'
import type { Enterprise } from '../../lib/api' import type {
Enterprise,
FieldVisit,
LedgerEntry,
EnterpriseDetail,
ForecastPointRow,
ReasonCodeRow,
StressScoreRow,
} from '../../lib/api'
import { import {
ActionChip, ActionChip,
Card, Card,
@@ -22,8 +31,6 @@ import {
breachPoint, breachPoint,
findEnterprise, findEnterprise,
forecastFor, forecastFor,
reasonCodesFor,
stressScoreFor,
subScoresFor, subScoresFor,
tripWiresFor, tripWiresFor,
} from '../../lib/demo/enterprises' } from '../../lib/demo/enterprises'
@@ -43,18 +50,110 @@ const TABS: { key: Tab; label: string }[] = [
{ key: 'VISITS', label: 'Visits' }, { 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() { export function EnterpriseDetailPage() {
const { enterpriseId = '' } = useParams() const { enterpriseId = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('OVERVIEW') const [tab, setTab] = useState<Tab>('OVERVIEW')
const [scheduling, setScheduling] = useState(false) 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}.`} /> if (!e) return <Empty text={`No enterprise found for ${enterpriseId}.`} />
const points = forecastFor(e) // Live forecast rows where we have them; the in-file generator only where the
const breach = breachPoint(points) // 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 const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
return ( return (
@@ -69,12 +168,13 @@ export function EnterpriseDetailPage() {
<h1 className="text-[19px] font-bold text-forest">{e.name}</h1> <h1 className="text-[19px] font-bold text-forest">{e.name}</h1>
<RiskBadge band={e.band} /> <RiskBadge band={e.band} />
</div> </div>
<div className="text-[11.5px] text-slate-500"> <div className="text-[11.5px] text-ink-3">
{e.enterpriseId} · {SECTORS[e.sector].label} · {e.block} block, {e.district} district ·{' '} {e.enterpriseId} · {sectorLabel(e.sector)} · {e.block} block, {e.district} district ·{' '}
{e.vintageMonths} months on book {e.vintageMonths} months on book
</div> </div>
</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> <PrimaryBtn onClick={() => setScheduling(true)}>Schedule visit</PrimaryBtn>
<GhostBtn onClick={() => setTab('FORECAST')}>Full forecast</GhostBtn> <GhostBtn onClick={() => setTab('FORECAST')}>Full forecast</GhostBtn>
</div> </div>
@@ -100,28 +200,77 @@ export function EnterpriseDetailPage() {
))} ))}
</div> </div>
{tab === 'OVERVIEW' && <OverviewTab e={e} />} {tab === 'OVERVIEW' && <OverviewTab e={e} score={data?.score ?? null} reasons={data?.reasons ?? []} />}
{tab === 'FORECAST' && <ForecastTab e={e} />} {tab === 'FORECAST' && <ForecastTab e={e} live={data?.forecast ?? []} />}
{tab === 'DRIVERS' && <DriversTab e={e} />} {tab === 'DRIVERS' && <DriversTab e={e} score={data?.score ?? null} reasons={data?.reasons ?? []} />}
{tab === 'LEDGER' && <LedgerTab e={e} />} {tab === 'LEDGER' && <LedgerTab e={e} live={data?.ledger ?? []} />}
{tab === 'VISITS' && <VisitsTab e={e} />} {tab === 'VISITS' && <VisitsTab e={e} live={data?.visits ?? []} />}
{scheduling && <ScheduleVisitModal enterprise={e} onClose={() => setScheduling(false)} />} {scheduling && <ScheduleVisitModal enterprise={e} onClose={() => setScheduling(false)} />}
</div> </div>
) )
} }
function OverviewTab({ e }: { e: Enterprise }) { /** How the engine that produced a score should be described, and coloured. */
const score = stressScoreFor(e) const ENGINE_LABEL: Record<string, { text: string; pill: string; note: string }> = {
const reasons = reasonCodesFor(e) 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 ( return (
<div className="grid gap-4 lg:grid-cols-3"> <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"> <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"> <div className="mt-3 w-full">
<Sparkline values={e.trend} band={e.band} width={240} height={44} /> <Sparkline values={e.trend} band={(score?.band ?? e.band) as never} width={240} height={44} />
<div className="text-[10px] text-slate-400 text-center mt-1">6-month score history</div> <div className="text-[10px] text-ink-3 text-center mt-1">6-month score history</div>
</div> </div>
</div> </div>
</Card> </Card>
@@ -131,28 +280,64 @@ function OverviewTab({ e }: { e: Enterprise }) {
{[ {[
['Loan outstanding', inr(e.loanOutstanding)], ['Loan outstanding', inr(e.loanOutstanding)],
['Monthly instalment', inr(e.monthlyInstalment)], ['Monthly instalment', inr(e.monthlyInstalment)],
['Sector pack', SECTORS[e.sector].label], ['Sector pack', sectorLabel(e.sector)],
['Block / district', `${e.block} / ${e.district}`], ['Block / district', `${e.block} / ${e.district}`],
['Owners on record', String(e.ownerCount)], ['Owners on record', String(e.ownerCount)],
['Vintage', `${e.vintageMonths} months`], ['Vintage', `${e.vintageMonths} months`],
['Last scored', score?.computedAt ? score.computedAt.slice(0, 16).replace('T', ' ') : '—'],
].map(([k, v]) => ( ].map(([k, v]) => (
<div key={k} className="flex justify-between gap-3"> <div key={k} className="flex justify-between gap-3">
<dt className="text-slate-500">{k}</dt> <dt className="text-ink-3">{k}</dt>
<dd className="font-semibold text-slate-700">{v}</dd> <dd className="font-semibold text-ink num">{v}</dd>
</div> </div>
))} ))}
</dl> </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>
<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"> <div className="p-4 space-y-3">
{reasons.map((r) => ( {reasons.length === 0 && <Empty text="No reason codes stored for this enterprise." />}
<div key={r.driver} className="border-l-[3px] border-amber pl-2.5"> {reasons.slice(0, 5).map((r) => (
<div className="text-[11px] font-semibold text-slate-700"> <div key={r.id} className="flex gap-2.5">
{r.driver} <span className="font-mono text-slate-400">· {r.weight}</span> <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> </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>
))} ))}
</div> </div>
@@ -161,11 +346,29 @@ function OverviewTab({ e }: { e: Enterprise }) {
) )
} }
function ForecastTab({ e }: { e: Enterprise }) { function ForecastTab({ e, live }: { e: Enterprise; live: ForecastPointRow[] }) {
const points = forecastFor(e) // 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 ( return (
<div className="grid gap-4 lg:grid-cols-3"> <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"> <div className="p-4">
<ForecastFan points={points} height={280} /> <ForecastFan points={points} height={280} />
</div> </div>
@@ -207,14 +410,47 @@ function ForecastTab({ e }: { e: Enterprise }) {
) )
} }
function DriversTab({ e }: { e: Enterprise }) { function DriversTab({
const sub = subScoresFor(e) 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 wires = tripWiresFor(e)
const isModel = score?.engine === 'MODEL'
return ( return (
<div className="grid gap-4 lg:grid-cols-2"> <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"> <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"> <div className="space-y-2 mt-4">
{DRIVER_META.map((d) => ( {DRIVER_META.map((d) => (
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} /> <DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
@@ -223,7 +459,38 @@ function DriversTab({ e }: { e: Enterprise }) {
</div> </div>
</Card> </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"> <div className="overflow-x-auto">
<table className="ul-table"> <table className="ul-table">
<thead> <thead>
@@ -259,8 +526,11 @@ function DriversTab({ e }: { e: Enterprise }) {
) )
} }
function LedgerTab({ e }: { e: Enterprise }) { function LedgerTab({ e, live }: { e: Enterprise; live: LedgerEntry[] }) {
const entries = loadLedger(e.enterpriseId).slice(0, 40) // 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) => const label = (kind: string, code: string) =>
CATEGORIES[kind as keyof typeof CATEGORIES]?.find((c) => c.code === code)?.en ?? code 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 }) { function VisitsTab({ e, live }: { e: Enterprise; live: FieldVisit[] }) {
const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer') // 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) const mine = visits.filter((v) => v.enterpriseId === e.enterpriseId)
return ( return (
+9 -3
View File
@@ -4,8 +4,9 @@ import { useNavigate } from 'react-router-dom'
import { get } from '../../lib/api' import { get } from '../../lib/api'
import type { Band, Enterprise, Sector } from '../../lib/api' import type { Band, Enterprise, Sector } from '../../lib/api'
import { ActionChip, Empty, GhostBtn, RiskBadge, Sparkline, inputCls } from '../../components/ui/bits' 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 { SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises'
import { scopedEnterprises } from '../../lib/demo/ops'
import { inrCompact } from '../../lib/demo/seed' import { inrCompact } from '../../lib/demo/seed'
import { exportCsv } from '../../lib/csv' import { exportCsv } from '../../lib/csv'
import { useScope } from './officerScope' import { useScope } from './officerScope'
@@ -25,12 +26,15 @@ export function EnterprisesPage() {
const [band, setBand] = useState<Band | 'ALL'>('ALL') const [band, setBand] = useState<Band | 'ALL'>('ALL')
const [sector, setSector] = useState<Sector | 'ALL'>('ALL') const [sector, setSector] = useState<Sector | 'ALL'>('ALL')
const { data } = useQuery({ const { data, isError, refetch } = useQuery({
queryKey: ['enterprises', block], queryKey: ['enterprises', block],
queryFn: () => get<Enterprise[]>('/officer/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 rows = useMemo(() => {
const needle = q.trim().toLowerCase() const needle = q.trim().toLowerCase()
@@ -61,6 +65,8 @@ export function EnterprisesPage() {
) )
} }
if (isError || !data) return <Unavailable what="Enterprise register" onRetry={() => void refetch()} />
return ( return (
<div> <div>
<div className="flex flex-wrap items-center gap-2 mb-3"> <div className="flex flex-wrap items-center gap-2 mb-3">
+11 -4
View File
@@ -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 { ForecastFan } from '../../components/charts/ForecastFan'
import { BandMigrationBar } from '../../components/charts/BandMigrationBar' import { BandMigrationBar } from '../../components/charts/BandMigrationBar'
import { bandMigration, portfolioForecast, sectorRollup } from '../../lib/demo/ops' import { bandMigration, portfolioForecast, sectorRollup } from '../../lib/demo/ops'
@@ -12,9 +13,15 @@ import { useScope } from './officerScope'
*/ */
export function ForecastsPage() { export function ForecastsPage() {
const { block } = useScope() const { block } = useScope()
const aggregate = portfolioForecast(block) // Aggregates are computed over this bank's own borrowers. They used to be
const sectors = sectorRollup(block) // derived from the unscoped demo book, so a portfolio chart mixed every bank.
const migration = bandMigration(block) 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 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 const covered = aggregate.filter((p) => p.p10 >= p.obligation).length
+6 -4
View File
@@ -210,10 +210,12 @@ export function IntegrationPage() {
<p className="text-[11px] text-slate-700 leading-relaxed"> <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 <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. 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 The CBSNexora adapter is <b>simulated</b>: no CBSNexora instance feeds this deployment, so it
extract in each core's own column dialect so the mapping layer can be exercised end to end. generates an extract in that platform's own field names to exercise the mapping layer end to
A live link to any of them is agreed per bank during onboarding — the schema conversation is end. Where a lender runs a different core, no product is named here — the per-profile mapping
the work, and this screen is what that conversation configures. 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> </p>
</div> </div>
+346 -203
View File
@@ -1,248 +1,391 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { CalendarClock, CloudRain, Database, Siren, TrendingDown } from 'lucide-react'
import { get } from '../../lib/api' import { get } from '../../lib/api'
import type { Enterprise, PortfolioSummary } from '../../lib/api' import type { ClimateSignal, Enterprise, MarketSignal, PortfolioSummary } from '../../lib/api'
import { import { Unavailable } from '../../components/ui/bits'
DriverBar, import { SECTORS } from '../../lib/demo/enterprises'
GhostBtn, import { demoClimateSignals, demoMarketSignals } from '../../lib/demo/feeds'
KpiTile, import { monthRef } from '../../lib/demo/seed'
PrimaryBtn,
RiskBadge,
ScoreDial,
Sparkline,
} from '../../components/ui/bits'
import {
DRIVER_META,
SECTORS,
breachPoint,
forecastFor,
reasonCodesFor,
subScoresFor,
} from '../../lib/demo/enterprises'
import { portfolioSummary } from '../../lib/demo/ops'
import { inr, monthRef } from '../../lib/demo/seed'
import { exportCsv } from '../../lib/csv' import { exportCsv } from '../../lib/csv'
import { useScope } from './officerScope' import { useScope } from './officerScope'
import { ScheduleVisitModal } from './ScheduleVisitModal' import { ScheduleVisitModal } from './ScheduleVisitModal'
import { DashboardTabs } from './DashboardTabs'
/** /**
* The console's home screen and the deck's headline wireframe: KPI tiles, the * The officer's morning screen, organised by **when trouble arrives**.
* "Enterprises by risk" register, and a risk detail panel that explains the *
* selected enterprise in the officer's own language. * <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() { export function OfficerDashboard() {
const navigate = useNavigate() const navigate = useNavigate()
const { block, month } = useScope() 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 [scheduling, setScheduling] = useState<Enterprise | null>(null)
const { data } = useQuery({ const { data, isError, refetch } = useQuery({
queryKey: ['portfolio', block, month], queryKey: ['portfolio', block, month],
queryFn: () => get<PortfolioSummary>('/officer/portfolio', { block, month }), queryFn: () => get<PortfolioSummary>('/officer/portfolio', { block, month }),
}) })
// Demo fallback: with the backend down, the screen still shows the full book. // No demo fallback: the demo portfolio carries no owner, so substituting it
const summary = data ?? portfolioSummary(block, month) // would show this bank every other bank's borrowers.
const rows = summary.rows const rows = useMemo(() => data?.rows ?? [], [data])
const selected = useMemo( const counts = useMemo(() => {
() => rows.find((e) => e.enterpriseId === selectedId) ?? rows[0], const c: Record<Bucket, number> = { NOW: 0, WINDOW: 0, LATER: 0, CLEAR: 0 }
[rows, selectedId], rows.forEach((e) => { c[bucketOf(e)] += 1 })
) return c
}, [rows])
function handleExport() { // How many borrowers first breach at each horizon — the intervention window, drawn.
exportCsv( const horizon = useMemo(() => {
`udyamlinc-portfolio-${block}-${month}.csv`, const h = [0, 0, 0, 0, 0, 0]
['Enterprise ID', 'Name', 'Sector', 'Block', 'Score', 'Band', 'Loan outstanding', 'Instalment'], rows.forEach((e) => {
rows.map((e) => [ const n = e.firstBreachHorizon
e.enterpriseId, if (n != null && n >= 1 && n <= 6) h[n - 1] += 1
e.name, })
SECTORS[e.sector].label, return h
e.block, }, [rows])
e.score,
e.band, // The day's work: soonest breach first, then worst score. Capped, because an
e.loanOutstanding, // officer visits nine borrowers a week and a list of forty is a list of none.
e.monthlyInstalment, 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 ( return (
<div> <div>
{/* ── KPI tiles ─────────────────────────────────────── */} <DashboardTabs />
<div className="flex flex-wrap gap-3 mb-4"> <div className="gf">
<KpiTile {/* ── triage funnel ─────────────────────────────────────
label="Total enterprises" One strip, four segments, urgency decreasing left to right. A row of
value={summary.total} separate tiles counting bands reads as a census; this reads as a queue. */}
delta={`${summary.totalDelta >= 0 ? '+' : ''}${summary.totalDelta} this month`} <div className="gf-triage">
/> {BUCKETS.map((b) => {
<KpiTile const active = filter === b.key
label="High risk" return (
value={summary.red} <button
delta={`${summary.redDelta >= 0 ? '+' : ''}${summary.redDelta} since last week`} key={b.key}
tone="red" onClick={() => setFilter(active ? 'ALL' : b.key)}
/> className={`gf-seg ${b.tone} ${active ? 'is-on' : ''}`}
<KpiTile >
label="Watch" <span className="gf-seg-label">{b.label}</span>
value={summary.watch} <span className="gf-seg-val num">{counts[b.key]}</span>
delta={`${summary.watchDelta >= 0 ? '+' : ''}${summary.watchDelta} since last week`} <span className="gf-seg-cap">{b.caption}</span>
tone="watch" </button>
/> )
<KpiTile })}
label="Standard"
value={summary.standard}
delta={summary.standardDelta === 0 ? 'stable' : `${summary.standardDelta > 0 ? '+' : ''}${summary.standardDelta} since last week`}
tone="good"
/>
</div> </div>
<div className="flex gap-4 items-start flex-col xl:flex-row"> <div className="gf-row2">
{/* ── enterprises by risk ─────────────────────────── */} {/* ── lead-time chart ───────────────────────────────── */}
<div className="flex-1 min-w-0 bg-white border border-slate-200 rounded-[11px] overflow-hidden"> <section className="gf-card">
<div className="px-4 py-2.5 border-b border-slate-200 flex items-center"> <header className="gf-head">
<h3 className="text-[12.5px] font-bold text-forest">Enterprises by risk</h3> <h2>When the book breaches</h2>
<span className="text-[10px] text-slate-400 ml-2">updated {summary.updatedMinutesAgo} minutes ago</span> <span>Borrowers whose downside first misses the instalment, by month</span>
<GhostBtn className="ml-auto" onClick={handleExport}> </header>
Export CSV <div className="gf-bars">
</GhostBtn> {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> </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"> {/* ── context ───────────────────────────────────────── */}
<table className="ul-table"> <div className="gf-side">
<thead className="sticky top-0 z-10"> <FreshnessCard />
<tr> <MarketCard />
<th>Enterprise</th> <EngineCard scored={rows.length} />
<th>Sector</th>
<th>Block</th>
<th className="text-right">Score</th>
<th>Status</th>
<th>6-month trend</th>
<th />
</tr>
</thead>
<tbody>
{rows.slice(0, 40).map((e) => (
<tr
key={e.enterpriseId}
onClick={() => setSelectedId(e.enterpriseId)}
className={`cursor-pointer ${e.enterpriseId === selected?.enterpriseId ? '!bg-green-soft' : ''}`}
>
<td>
<div className="flex items-center gap-2.5">
<span
className={`w-6 h-6 rounded-full grid place-items-center text-[10px] font-bold ${
e.band === 'RED' ? 'band-red' : e.band === 'WATCH' ? 'band-watch' : 'band-standard'
}`}
>
{e.name[0]}
</span>
<span className="leading-tight">
<span className="block font-semibold text-slate-700">{e.name}</span>
<span className="block font-mono text-[10px] text-slate-400">{e.enterpriseId}</span>
</span>
</div>
</td>
<td className="text-slate-600">{SECTORS[e.sector].short}</td>
<td className="text-slate-600">{e.block}</td>
<td
className={`text-right font-bold ${
e.band === 'RED' ? 'text-risk-red' : e.band === 'WATCH' ? 'text-amber' : 'text-green'
}`}
>
{e.score}
</td>
<td>
<RiskBadge band={e.band} />
</td>
<td>
<Sparkline values={e.trend} band={e.band} />
</td>
<td className="text-right">
<button
onClick={(ev) => {
ev.stopPropagation()
navigate(`/officer/enterprises/${e.enterpriseId}`)
}}
className="text-slate-400 hover:text-forest px-1"
aria-label={`Open ${e.name}`}
>
⋯
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{rows.length > 40 && (
<div className="px-4 py-2 border-t border-slate-100 text-[10.5px] text-slate-400">
Showing the 40 highest-risk of {rows.length} — the full register is under Enterprises.
</div>
)}
</div> </div>
{/* ── risk detail ─────────────────────────────────── */}
{selected && <RiskDetail enterprise={selected} onSchedule={() => setScheduling(selected)} />}
</div> </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)} />} {scheduling && <ScheduleVisitModal enterprise={scheduling} onClose={() => setScheduling(null)} />}
</div>
</div> </div>
) )
} }
/** Right-hand panel: score dial, top drivers, the reason sentence and two actions. */ /* ------------------------------------------------------------------ */
function RiskDetail({ enterprise, onSchedule }: { enterprise: Enterprise; onSchedule: () => void }) { /* Context cards — small, and each answers one question */
const navigate = useNavigate() /* ------------------------------------------------------------------ */
const sub = subScoresFor(enterprise)
const reason = reasonCodesFor(enterprise)[0] function FreshnessCard() {
const points = forecastFor(enterprise) const { data } = useQuery({
const breach = breachPoint(points) queryKey: ['freshness'],
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null 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 ( return (
<div className="w-full xl:w-[320px] shrink-0 bg-white border border-slate-200 rounded-[11px] overflow-hidden"> <section className="gf-card gf-mini">
<div className="px-4 py-2.5 border-b border-slate-200"> <header className="gf-head">
<h3 className="text-[12.5px] font-bold text-forest">Risk detail</h3> <h2><Database size={13} /> Data freshness</h2>
<div className="text-[10.5px] text-slate-500"> </header>
{enterprise.name} · {enterprise.enterpriseId} <dl className="gf-kv">
<div>
<dt>Mandi prices</dt>
<dd className={mandi != null && mandi > 10 ? 'is-stale' : ''}>{mandi ?? '—'}d old</dd>
</div> </div>
</div> <div>
<dt>Climate</dt>
<div className="p-4"> <dd className={climate != null && climate > 10 ? 'is-stale' : ''}>{climate ?? '—'}d old</dd>
<div className="grid place-items-center mb-4">
<ScoreDial score={enterprise.score} band={enterprise.band} size={116} />
</div> </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> function MarketCard() {
<div className="space-y-2 mb-4"> const marketQ = useQuery({
{DRIVER_META.map((d) => ( queryKey: ['market-signals'],
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} /> queryFn: () => get<MarketSignal[]>('/admin/ingestion/signals/market', { weeks: 8 }),
))} })
</div> 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"> const latest = new Map<string, MarketSignal>()
<p className="text-[11.5px] text-slate-700 leading-relaxed"> for (const r of market) {
{reason.sentenceEn} const held = latest.get(r.commodityName)
{breachRef && ` Shortfall expected by ${breachRef.fullEn}.`} if (!held || r.weekStart > held.weekStart) latest.set(r.commodityName, r)
</p> }
{breach && ( const movers = Array.from(latest.values())
<p className="text-[10.5px] text-risk-red font-semibold mt-1.5"> .map((m) => {
Projected gap {inr(breach.obligation - breach.p10)} at P10 const hist = market
</p> .filter((r) => r.commodityName === m.commodityName && r.weekStart < m.weekStart)
)} .map((r) => r.modalPrice)
</div> .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"> const c = climate.length ? climate.reduce((a, b) => (a.weekStart > b.weekStart ? a : b)) : null
<PrimaryBtn className="flex-1" onClick={onSchedule}>
Schedule visit return (
</PrimaryBtn> <section className="gf-card gf-mini">
<GhostBtn className="flex-1" onClick={() => navigate(`/officer/enterprises/${enterprise.enterpriseId}`)}> <header className="gf-head">
Full forecast <h2><CloudRain size={13} /> Market &amp; climate</h2>
</GhostBtn> </header>
</div> <dl className="gf-kv">
</div> {c && (
</div> <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>
)
}
+567 -135
View File
@@ -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 { 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 { clearSessions, get, officerSession } from '../../lib/api'
import type { Freshness } from '../../lib/api' import type { Freshness } from '../../lib/api'
import { demoFreshness, worstStaleness } from '../../lib/demo/feeds' import { demoFreshness, worstStaleness } from '../../lib/demo/feeds'
import { UdyamMark } from '../../components/ui/bits' import { UdyamMark } from '../../components/ui/bits'
import { MenuSelect } from '../../components/ui/MenuSelect'
import { BLOCKS } from '../../lib/demo/enterprises' 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 { monthRef, monthWindow } from '../../lib/demo/seed'
import { OfficerScopeProvider, useScope } from './officerScope' import { OfficerScopeProvider, useScope } from './officerScope'
/* /*
* Field officer console shell — dark forest sidebar on the left, identity bar * Field officer console shell — fixed header, grouped dropdown navigation.
* 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 * The console had thirteen flat items down a rail, which is more than anyone
* because the console is navigated by module, not by tab. * 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 }, * Icon-tile colours.
{ label: 'Portfolio', to: '/officer/portfolio' }, *
{ label: 'Enterprises', to: '/officer/enterprises' }, * <p>Written as literal class pairs rather than composed at runtime, because
{ label: 'Risk alerts', to: '/officer/alerts' }, * Tailwind scans source text — a class built from a variable is a class that
{ label: 'Forecasts', to: '/officer/forecasts' }, * never reaches the stylesheet.
{ label: 'Field visits', to: '/officer/visits' }, *
{ label: 'Trip-wires', to: '/officer/rules' }, * <p>Colour here is an aid to recognition, not information: the palette repeats
{ label: 'Data feeds', to: '/officer/feeds' }, * across menus and carries no meaning, so nobody can read a hue as a severity.
{ label: 'Data entry', to: '/officer/data-entry' }, * The risk scale stays reserved for bands, and only the alerts item borrows from
{ label: 'Core banking', to: '/officer/integration' }, * it — because there it <em>is</em> the meaning.
{ label: 'Reports', to: '/officer/reports' }, */
{ label: 'Settings', to: '/officer/settings' }, 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. * 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() { export function OfficerShell() {
return ( return (
<OfficerScopeProvider> <OfficerScopeProvider>
@@ -72,13 +451,44 @@ export function OfficerShell() {
function ShellBody() { function ShellBody() {
const navigate = useNavigate() const navigate = useNavigate()
const { pathname } = useLocation()
const session = officerSession() const session = officerSession()
const { block, month, setBlock, setMonth } = useScope() const { block, month, setBlock, setMonth } = useScope()
const openAlerts = alertsFor(block).filter((a) => a.state === 'OPEN').length // Scoped counts. These drove a badge and a progress bar off the unscoped demo
const progress = weeklyVisitProgress(block) // 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 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(' ') .split(' ')
.map((w) => w[0]) .map((w) => w[0])
.join('') .join('')
@@ -90,134 +500,156 @@ function ShellBody() {
navigate('/login', { replace: true }) 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 ( return (
<div className="min-h-screen flex bg-[#f4f7f5]"> <div className="min-h-screen flex flex-col bg-page">
{/* ── sidebar ─────────────────────────────────────────── */} {/* ── fixed header: brand bar with grouped menus ─────────── */}
<aside className="w-[212px] shrink-0 bg-forest-deep text-white flex flex-col sticky top-0 h-screen"> <header ref={headerRef} className="sticky top-0 z-40">
<div className="px-4 py-4 flex items-center gap-2.5 border-b border-white/10"> <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)]">
<UdyamMark size={24} onDark /> <NavLink to="/officer" onClick={close} className="flex items-center gap-2.5 shrink-0">
<div className="leading-tight"> <UdyamMark size={26} onDark />
<div className="text-[14px] font-bold"> <span className="leading-tight hidden sm:block">
Udyam<span className="text-amber-hi">Linc</span> <span className="block text-[14px] font-bold tracking-[-0.02em]">UdyamLinc</span>
</div> <span className="block text-[10px] text-white/55">Field Officer Console</span>
<div className="text-[9px] text-[#8fae9c] font-mono tracking-[0.1em]">FIELD OFFICER CONSOLE</div> </span>
</div> </NavLink>
</div>
<nav className="flex-1 py-3 overflow-y-auto"> <span className="w-px h-6 bg-white/20 hidden md:block" />
{NAV.map((n) => (
<NavLink
key={n.to}
to={n.to}
end={n.end}
className={({ isActive }) =>
[
'flex items-center gap-2.5 px-4 py-[9px] text-[12px] font-medium border-l-[3px] transition-colors',
isActive
? 'bg-white/[0.08] text-white border-amber font-semibold'
: 'text-[#a9c4b5] border-transparent hover:bg-white/[0.05] hover:text-white',
].join(' ')
}
>
<span className="w-2.5 h-2.5 rounded-[3px] bg-current opacity-70" />
{n.label}
{n.to === '/officer/alerts' && openAlerts > 0 && (
<span className="ml-auto bg-risk-red text-white text-[9.5px] font-bold px-1.5 rounded-full">
{openAlerts}
</span>
)}
</NavLink>
))}
</nav>
{/* weekly visit progress — the card pinned to the sidebar on the deck */} {/* No overflow on this element. An `overflow-x: auto` here scrolls the row
<div className="m-3 rounded-lg bg-white/[0.07] border border-white/10 px-3 py-2.5"> on a narrow window, but it also establishes a clipping context that cuts
<div className="text-[9.5px] text-[#8fae9c] font-mono tracking-[0.1em]">THIS WEEK</div> off the dropdown panels — and on Windows the always-visible scrollbars
<div className="text-[22px] font-bold leading-tight"> eat into a 56px bar. Labels collapse at small widths instead. */}
{progress.done} <span className="text-[13px] text-[#8fae9c]">/ {progress.target}</span> <nav className="flex items-center gap-0.5 min-w-0">
</div> {directLink(HOME)}
<div className="text-[9.5px] text-[#8fae9c] mb-1.5">high-risk visits done</div> {GROUPS.map((g) => (
<div className="h-[5px] rounded-full bg-white/15 overflow-hidden"> <NavMenu
<div key={g.label}
className="h-full rounded-full bg-green-hi" group={g}
style={{ width: `${Math.min(100, (progress.done / progress.target) * 100)}%` }} open={openMenu === g.label}
/> onToggle={() => toggle(g.label)}
</div> onClose={close}
</div> openAlerts={openAlerts}
</aside> />
))}
{directLink(REPORTS)}
</nav>
{/* ── work area ───────────────────────────────────────── */} <div className="ml-auto flex items-center gap-2 shrink-0">
<div className="flex-1 min-w-0 flex flex-col"> <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]">
<header className="bg-white border-b border-slate-200 px-6 py-2.5 flex items-center gap-3 sticky top-0 z-30"> <Search size={13} />
<div className="min-w-0"> Search enterprise or ID…
<div className="text-[13.5px] font-bold text-forest leading-tight">Portfolio overview</div> </span>
<div className="text-[10.5px] text-slate-500 truncate">
{block === ALL_BLOCKS ? (session?.branch ?? 'All blocks') : `${block} block`} · {monthRef(new Date(`${month}-01T00:00:00`)).fullEn}
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<FreshnessChip />
<select
value={block}
onChange={(e) => setBlock(e.target.value)}
className="border border-slate-300 rounded-md px-2 py-1 text-[11.5px] bg-white outline-none focus:border-green"
aria-label="Block scope"
>
<option value={ALL_BLOCKS}>All blocks</option>
{BLOCKS.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
<select
value={month}
onChange={(e) => setMonth(e.target.value)}
className="border border-slate-300 rounded-md px-2 py-1 text-[11.5px] bg-white outline-none focus:border-green"
aria-label="Month scope"
>
{months.map((m) => (
<option key={m.key} value={m.key}>
{m.fullEn}
</option>
))}
</select>
<NavLink <NavLink
to="/officer/alerts" 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`} aria-label={`${openAlerts} open alerts`}
> >
<span className="text-[14px]">🔔</span> <Bell size={15} />
{openAlerts > 0 && <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-risk-red" />} {openAlerts > 0 && (
<span className="absolute top-1 right-1 w-[6px] h-[6px] rounded-full bg-risk-red" />
)}
</NavLink> </NavLink>
<div className="flex items-center gap-2 pl-2 border-l border-slate-200"> <UserMenu
<span className="w-7 h-7 rounded-full bg-forest text-white grid place-items-center text-[10.5px] font-bold"> name={name}
{initials} role={(session?.role ?? 'FIELD_OFFICER').replace(/_/g, ' ').toLowerCase().replace(/^./, (c) => c.toUpperCase())}
</span> initials={initials}
<div className="leading-tight"> open={openMenu === '__user'}
<div className="text-[11.5px] font-semibold text-slate-700">{session?.fullName ?? 'Field Officer'}</div> onToggle={() => toggle('__user')}
<div className="text-[9.5px] text-slate-400">Field Officer</div> onClose={close}
</div> onSignOut={signOut}
<button onClick={signOut} className="text-[10.5px] text-slate-400 hover:text-risk-red ml-1"> />
Sign out </div>
</button> </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>
</div> </div>
</header>
<main className="flex-1 px-6 py-4 max-w-[1560px] w-full"> <div className="ml-auto flex items-center gap-2 shrink-0">
<Outlet /> {/* weekly visit progress, which used to live in the sidebar */}
</main> <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"> <FreshnessChip />
UdyamLinc · Predicting stress. Protecting enterprises. — Team LincFusion, LincPay Solutions Pvt. Ltd.
</footer> <MenuSelect
</div> 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> </div>
) )
} }
+118 -42
View File
@@ -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 { ModelComparisonBar } from '../../components/charts/ModelComparisonBar'
import { SECTORS } from '../../lib/demo/enterprises' 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 { exportCsv } from '../../lib/csv'
import { useScope } from './officerScope' import { useScope } from './officerScope'
/** /**
* MIS exports and the supervisory pack. The model figures are the ones the deck * MIS exports and the supervisory pack.
* publishes — measured on the simulated 2,400-enterprise dataset, not on live * <p>Model figures are read from `model_metric` — written by the backtest that
* institutional data, and labelled as such. * 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() { export function ReportsPage() {
const { block, month } = useScope() const { block, month } = useScope()
const enterprises = scopedEnterprises(block) // Exports carry borrower rows, so they come from the scoped API. An export is
const alerts = alertsFor(block) // the most consequential place to get tenancy wrong: a CSV leaves the building.
const visits = visitsFor(block) 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 }[] = [ const REPORTS: { name: string; detail: string; run: () => void }[] = [
{ {
@@ -73,48 +84,113 @@ export function ReportsPage() {
exportCsv( exportCsv(
`udyam-sectors-${block}-${month}.csv`, `udyam-sectors-${block}-${month}.csv`,
['Sector', 'Enterprises', 'High risk', 'Watch', 'Standard', 'Average score'], ['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 ( return (
<div className="space-y-4"> <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" /> Every figure below is read from `model_metric`, written by the backtest
<KpiTile label="Median lead time" value={`${BENCHMARKS.medianLeadTimeMonths} mo`} delta="before first missed instalment" /> that produced it. This block used to render four constants from
<KpiTile label="Alert precision · Red band" value={BENCHMARKS.alertPrecisionRed.toFixed(2)} delta="cost of a false positive is one visit" tone="watch" /> `lib/demo/ops.ts` — an AUC of 0.87 nothing had computed, and an on-device
<KpiTile label="On-device scoring" value={`< ${BENCHMARKS.onDeviceLatencyMs} ms`} delta="zero connectivity" /> latency for inference that does not run on a device. A juror asking "how
</div> 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"> {metrics?.available && (
<Card title="Model comparison — 90-day stress detection (AUC-ROC)"> <>
<div className="p-4"> <div className="flex flex-wrap gap-3">
<ModelComparisonBar /> <KpiTile
<p className="text-[10.5px] text-slate-400 mt-2 leading-snug"> label="AUC-ROC · 90-day stress"
Measured on a simulated {BENCHMARKS.datasetSize.toLocaleString('en-IN')}-enterprise dataset across{' '} value={fmt(best?.auc_roc)}
{BENCHMARKS.sectorsCovered} sectors and {BENCHMARKS.districtsCovered} districts. Production calibration delta={`${bestLabel} · expanding-window folds`}
will use institutional historical portfolios under a data-sharing agreement. tone="good"
</p> />
<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> </div>
</Card>
<Card title="Validation method"> <div className="grid gap-4 lg:grid-cols-2">
<ul className="p-4 space-y-2.5 text-[12px] text-slate-600"> <Card
{[ title="Model comparison — 90-day stress detection"
'Time-series split — no forward leakage across folds.', badge={metrics.modelVersion}
'Backtested against simulated stress events.', actions={<span className="text-[10.5px] text-ink-3">AUC-ROC, identical folds</span>}
'Precision measured at the Red band, where the cost of a false positive is one visit.', >
'Sector packs evaluated independently before pooling.', <div className="p-4">
].map((t) => ( <ModelComparisonBar arms={metrics.arms ?? {}} />
<li key={t} className="flex gap-2.5"> <p className="text-[10.5px] text-ink-3 mt-2 leading-snug">
<span className="w-1.5 h-1.5 rounded-full bg-green mt-1.5 shrink-0" /> Grey bars are single features, not models. Measured on {metrics.datasetRows?.toLocaleString('en-IN')}{' '}
{t} rows across 21 monthly as-of dates, {metrics.datasetPositives} of them positive.{' '}
</li> <b className="text-ink-2">
))} {syntheticPct}% of labels are generated DPD history
</ul> </b>
</Card> , so these figures measure the generator as much as the borrowers. Production calibration needs a
</div> 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}`}> <Card title="MIS exports" badge={`${block === 'ALL' ? 'all blocks' : block} · ${month}`}>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -133,7 +209,7 @@ export function ReportsPage() {
<td className="font-semibold text-slate-700">{r.name}</td> <td className="font-semibold text-slate-700">{r.name}</td>
<td className="whitespace-normal text-slate-600">{r.detail}</td> <td className="whitespace-normal text-slate-600">{r.detail}</td>
<td className="text-right text-slate-500"> <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>
<td className="text-right"> <td className="text-right">
<GhostBtn onClick={r.run}>Download CSV</GhostBtn> <GhostBtn onClick={r.run}>Download CSV</GhostBtn>
+4 -2
View File
@@ -4,7 +4,7 @@ import { get } from '../../lib/api'
import type { FeatureValueRow, RuleOutcome, RuleStatus, TripWireRule } from '../../lib/api' import type { FeatureValueRow, RuleOutcome, RuleStatus, TripWireRule } from '../../lib/api'
import { Card, Empty } from '../../components/ui/bits' import { Card, Empty } from '../../components/ui/bits'
import { DEMO_FEATURES, DEMO_RULES, demoFeatureValues, demoRuleOutcomes } from '../../lib/demo/feeds' 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' import { useScope } from './officerScope'
/** /**
@@ -19,7 +19,9 @@ import { useScope } from './officerScope'
*/ */
export function RulesPage() { export function RulesPage() {
const { block } = useScope() 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 [enterpriseId, setEnterpriseId] = useState(enterprises[0]?.enterpriseId ?? 'MSME-4417')
const rulesQ = useQuery({ queryKey: ['rules'], queryFn: () => get<TripWireRule[]>('/admin/rules') }) const rulesQ = useQuery({ queryKey: ['rules'], queryFn: () => get<TripWireRule[]>('/admin/rules') })
+8 -3
View File
@@ -16,7 +16,8 @@ import {
import { get } from '../../lib/api' import { get } from '../../lib/api'
import type { Band, Enterprise, Sector } from '../../lib/api' import type { Band, Enterprise, Sector } from '../../lib/api'
import { Card, Empty, StatusPill } from '../../components/ui/bits' 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 * 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 [term, setTerm] = useState('')
const [selected, setSelected] = useState<string | null>(null) const [selected, setSelected] = useState<string | null>(null)
const { data, isError } = useQuery({ const { data, isError, refetch } = useQuery({
queryKey: ['portfolio-enterprises'], queryKey: ['portfolio-enterprises'],
queryFn: () => get<Enterprise[]>('/officer/enterprises'), queryFn: () => get<Enterprise[]>('/officer/enterprises'),
}) })
const demo = isError 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 bySector = useMemo(() => {
const map = new Map<Sector, Enterprise[]>() const map = new Map<Sector, Enterprise[]>()
@@ -101,6 +104,8 @@ export function SectorPortfolioPage() {
const chosen = rows.find((e) => e.enterpriseId === selected) ?? null const chosen = rows.find((e) => e.enterpriseId === selected) ?? null
if (isError || !data) return <Unavailable what="Sector portfolio" onRetry={() => void refetch()} />
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* ── header ─────────────────────────────────────── */} {/* ── header ─────────────────────────────────────── */}
+1 -1
View File
@@ -93,7 +93,7 @@ export function SettingsPage() {
type="checkbox" type="checkbox"
checked={packs[s]} checked={packs[s]}
onChange={(e) => setPacks({ ...packs, [s]: e.target.checked })} 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-[12px] text-slate-700">{SECTORS[s].label}</span>
<span className="text-[11px] text-slate-400 ml-auto">{SECTORS[s].labelHi}</span> <span className="text-[11px] text-slate-400 ml-auto">{SECTORS[s].labelHi}</span>
+8 -3
View File
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' 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 type { FieldVisit, VisitState } from '../../lib/api'
import { import {
ActionChip, ActionChip,
@@ -33,7 +34,11 @@ export function VisitsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { block } = useScope() const { block } = useScope()
const officer = officerSession()?.fullName ?? 'Field Officer' 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 [filter, setFilter] = useState<VisitState | 'ALL'>('ALL')
const [logging, setLogging] = useState<FieldVisit | null>(null) const [logging, setLogging] = useState<FieldVisit | null>(null)
@@ -133,7 +138,7 @@ function LogOutcomeModal({ visit, onClose }: { visit: FieldVisit; onClose: () =>
const [note, setNote] = useState('') const [note, setNote] = useState('')
function save() { function save() {
logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset) logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset, visit)
onClose() onClose()
} }
+87
View File
@@ -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
View File
@@ -1,58 +1,115 @@
@import "tailwindcss"; @import "tailwindcss";
/* /*
* UdyamLinc design tokens * UdyamLinc design tokens — LincPay product design system
* Layout language: BharatLinc / CBSNexora (LincPay console family) * Source of truth: udyamlincdesign.html (brand system view).
* Brand palette: UdyamLinc deck — forest #0B5D3B, green #1E7A46, amber #E8A020 *
* 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 { @theme {
--color-forest-deep: #06301f; /* brand chrome */
--color-forest: #0b5d3b; --color-forest-deep: #072e22;
--color-forest-mid: #0f6f47; --color-forest: #0b4a37;
--color-green: #1e7a46; --color-forest-mid: #0e5c44;
--color-green-hi: #3fb871;
--color-green-soft: #eaf5ef;
--color-amber: #e8a020;
--color-amber-hi: #f2b544;
--color-amber-soft: #fdf4e3;
--color-risk-red: #c0392b;
--color-risk-soft: #fdeceb;
--color-ink: #17321f;
--color-tagline: #6b8477;
--font-sans: "Segoe UI", Roboto, -apple-system, "Helvetica Neue", Arial, sans-serif; /* 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; --font-mono: "Cascadia Mono", ui-monospace, "Roboto Mono", Consolas, monospace;
} }
body { :root {
@apply bg-[#f4f7f5] text-slate-900 font-sans text-[13px] antialiased; /* 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 { @apply w-full border-collapse text-[12.5px]; }
.ul-table thead th { .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 td { @apply px-4 py-2.5 border-b border-line whitespace-nowrap align-middle; }
.ul-table tbody tr:nth-child(even) { @apply bg-[#fafbfa]; } .ul-table tbody tr:last-child td { @apply border-b-0; }
.ul-table tbody tr:hover { @apply bg-[#f0f8f3]; } .ul-table tbody tr:hover { @apply bg-surface-2; }
.ul-table tbody tr.sel { @apply bg-lp-blue-soft; }
/* status pill with leading dot */ /* 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 { @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-1.5 h-1.5 rounded-full bg-current; } .pill::before { content: ""; @apply w-[5px] h-[5px] rounded-full bg-current shrink-0; }
.pill-ok { @apply bg-emerald-50 text-emerald-600; } .pill-ok { @apply bg-green-soft text-[#186b3d]; }
.pill-pend { @apply bg-amber-50 text-amber-600; } .pill-pend { @apply bg-amber-soft text-[#8a4e05]; }
.pill-info { @apply bg-blue-50 text-blue-600; } .pill-info { @apply bg-lp-blue-soft text-lp-blue; }
.pill-bad { @apply bg-red-50 text-red-600; } .pill-bad { @apply bg-risk-soft text-[#9c2b1f]; }
.pill-mut { @apply bg-slate-100 text-slate-500; } .pill-mut { @apply bg-surface-3 text-ink-2; }
/* Risk bands — the three states every screen speaks in */ /* Risk bands — the three states every screen speaks in */
.band-red { @apply bg-risk-soft text-risk-red; } .band-red { @apply bg-risk-soft text-[#9c2b1f]; }
.band-watch { @apply bg-amber-soft text-amber; } .band-watch { @apply bg-amber-soft text-[#8a4e05]; }
.band-standard { @apply bg-green-soft text-green; } .band-standard { @apply bg-green-soft text-[#186b3d]; }
/* Card chrome shared by console + PWA */ /* 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 */ /* Enterprise PWA: bottom tab bar sits above the home indicator */
.ul-safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); } .ul-safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
@@ -61,6 +118,165 @@ body {
.ul-blink { animation: ul-blink 1.2s infinite; } .ul-blink { animation: ul-blink 1.2s infinite; }
@keyframes ul-roll { from { transform: translateX(0); } to { transform: translateX(-50%); } } @keyframes ul-roll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
.ul-tape { animation: ul-roll 28s linear infinite; } .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) { @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
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import type { AlertState, RiskAlert } from './api' import type { AlertState, RiskAlert } from './api'
import { alertsFor } from './demo/ops'
/* /*
* Officer actions on the alert queue. * 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. */ /** 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 [version, setVersion] = useState(0)
const bump = useCallback(() => setVersion((v) => v + 1), []) const bump = useCallback(() => setVersion((v) => v + 1), [])
@@ -64,7 +75,8 @@ export function useAlerts(block: string) {
const states = readStates() const states = readStates()
// `version` is read so the memo-free derivation re-runs after every action. // `version` is read so the memo-free derivation re-runs after every action.
void version 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() } return { alerts, audit: readAudit() }
} }
+417 -3
View File
@@ -1,8 +1,43 @@
import axios from 'axios' import axios from 'axios'
/** udyamlinc-backend — Spring Boot skeleton, context path /api/v1/udyam. */ declare global {
export const API_BASE = interface Window {
import.meta.env.VITE_UDYAM_API_BASE_URL ?? 'http://udyamlinc-backend.lincpay/api/v1/udyam' __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. */ /** 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 }) export const api = axios.create({ baseURL: API_BASE, timeout: 6_000 })
@@ -169,6 +204,11 @@ export interface Enterprise {
band: Band band: Band
trend: number[] // 6-month score history, oldest first trend: number[] // 6-month score history, oldest first
lastUpdated: string 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 ₹. */ /** One month of the quantile forecast. Amounts are net cash position in ₹. */
@@ -780,3 +820,377 @@ export interface BranchCell {
stressRatePct?: number stressRatePct?: number
avgScore?: 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
View File
@@ -41,22 +41,13 @@ export const CANONICAL: CanonicalColumns = {
], ],
} }
const FINACLE: Record<string, string> = { /**
enterprise_ref: 'ENTITY_REF', * CBSNexora's field names. LincPay's own core banking platform, and the only
loan_account_ref: 'CUST_ACCT_ID', * core named anywhere in this system — where a lender runs someone else's, the
product: 'SCHM_CODE', * per-profile mapping absorbs their column names without the platform ever
sanctioned_amount: 'SANCT_AMT', * needing the product's name.
outstanding: 'PRIN_OS', */
emi_amount: 'INSTL_AMT', const CBSNEXORA: Record<string, string> = {
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> = {
enterprise_ref: 'borrowerRef', enterprise_ref: 'borrowerRef',
loan_account_ref: 'loanId', loan_account_ref: 'loanId',
product: 'productCode', product: 'productCode',
@@ -86,52 +77,14 @@ export function demoCatalog(): ConnectorCatalogEntry[] {
suggestedMapping: IDENTITY, suggestedMapping: IDENTITY,
}, },
{ {
vendor: 'FINACLE', vendor: 'CBSNEXORA',
displayName: 'Infosys Finacle (simulated)', displayName: 'CBSNexora (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)',
transport: 'REST', transport: 'REST',
simulated: true, simulated: true,
nativeColumns: [...Object.values(CUSTOM), 'borrowerName', 'borrowerMobile', 'branchCode'], nativeColumns: [...Object.values(CBSNEXORA), 'borrowerName', 'borrowerMobile', 'branchCode'],
activationNote: activationNote:
'For a lender whose loan management system is their own. One paged read-only endpoint; the mapping absorbs whatever they call the fields.', '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: CUSTOM, suggestedMapping: CBSNEXORA,
}, },
] ]
} }
@@ -139,21 +92,21 @@ export function demoCatalog(): ConnectorCatalogEntry[] {
export function demoProfiles(): ConnectorProfileRow[] { export function demoProfiles(): ConnectorProfileRow[] {
return [ return [
{ {
code: 'MPGB-FINACLE', code: 'MPGB-CBSNEXORA',
displayName: 'MP Gramin Bank — Finacle nightly extract', displayName: 'MP Gramin Bank — CBSNexora loan API',
vendor: 'FINACLE', vendor: 'CBSNEXORA',
vendorName: 'Infosys Finacle (simulated)', vendorName: 'CBSNexora (simulated)',
transport: 'FILE', transport: 'REST',
simulated: true, simulated: true,
activationNote: demoCatalog()[1].activationNote, activationNote: demoCatalog()[1].activationNote,
institutionCode: 'MPGB', institutionCode: 'MPGB',
endpoint: 'loanbook-*.csv', endpoint: 'https://cbsnexora.mpgb.example/api/loans',
scheduleCron: '0 30 1 * * *', scheduleCron: '0 30 1 * * *',
autoCommit: false, autoCommit: false,
active: true, active: true,
lastSyncAt: '2026-08-07T01:30:00', lastSyncAt: '2026-08-07T01:30:00',
lastStatus: 'PARTIAL', lastStatus: 'PARTIAL',
mapping: FINACLE, mapping: CBSNEXORA,
sourceColumns: demoCatalog()[1].nativeColumns, sourceColumns: demoCatalog()[1].nativeColumns,
}, },
{ {
@@ -175,22 +128,22 @@ export function demoProfiles(): ConnectorProfileRow[] {
sourceColumns: [], sourceColumns: [],
}, },
{ {
code: 'VSFB-LMS', code: 'VSFB-CBSNEXORA',
displayName: 'Vindhya SFB — in-house LMS over REST', displayName: 'Vindhya SFB — CBSNexora loan API',
vendor: 'CUSTOM_API', vendor: 'CBSNEXORA',
vendorName: 'In-house LMS over REST (simulated)', vendorName: 'CBSNexora (simulated)',
transport: 'REST', transport: 'REST',
simulated: true, simulated: true,
activationNote: demoCatalog()[4].activationNote, activationNote: demoCatalog()[1].activationNote,
institutionCode: 'VSFB', institutionCode: 'VSFB',
endpoint: 'https://lms.vindhyasfb.example/api/loans', endpoint: 'https://cbsnexora.vindhyasfb.example/api/loans',
scheduleCron: '0 15 1 * * *', scheduleCron: '0 15 1 * * *',
autoCommit: false, autoCommit: false,
active: true, active: true,
lastSyncAt: null, lastSyncAt: null,
lastStatus: 'NEVER_RUN', lastStatus: 'NEVER_RUN',
mapping: CUSTOM, mapping: CBSNEXORA,
sourceColumns: demoCatalog()[4].nativeColumns, sourceColumns: demoCatalog()[1].nativeColumns,
}, },
] ]
} }
@@ -244,8 +197,8 @@ export function demoRuns(): ConnectorSyncRun[] {
}, },
{ {
id: 13, id: 13,
profileCode: 'MPGB-FINACLE', profileCode: 'MPGB-CBSNEXORA',
vendor: 'FINACLE', vendor: 'CBSNEXORA',
institutionCode: 'MPGB', institutionCode: 'MPGB',
trigger: 'SCHEDULED', trigger: 'SCHEDULED',
triggeredBy: 'scheduler', triggeredBy: 'scheduler',
@@ -266,8 +219,8 @@ export function demoRuns(): ConnectorSyncRun[] {
}, },
{ {
id: 12, id: 12,
profileCode: 'MPGB-FINACLE', profileCode: 'MPGB-CBSNEXORA',
vendor: 'FINACLE', vendor: 'CBSNEXORA',
institutionCode: 'MPGB', institutionCode: 'MPGB',
trigger: 'MANUAL', trigger: 'MANUAL',
triggeredBy: 'supervisor', triggeredBy: 'supervisor',
+7 -1
View File
@@ -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 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> = { export const BLOCK_DISTRICT: Record<string, string> = {
Sehore: 'Sehore', Sehore: 'Sehore',
Vidisha: 'Vidisha', Vidisha: 'Vidisha',
Raisen: 'Raisen', Raisen: 'Raisen',
Bhopal: 'Bhopal', Bhopal: 'Bhopal',
Kamrup: 'Kamrup',
Nagaon: 'Nagaon',
} }
/** The six enterprises printed on the wireframe slide — reproduced exactly. */ /** The six enterprises printed on the wireframe slide — reproduced exactly. */
const PINNED: { enterpriseId: string; name: string; nameHi: string; sector: Sector; block: string; score: number }[] = [ 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-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-3902', name: 'Sharda Dairy Collective', nameHi: 'शारदा डेयरी समूह', sector: 'DAIRY', block: 'Vidisha', score: 71 },
{ enterpriseId: 'MSME-5120', name: 'Anand Food Products', nameHi: 'आनंद फ़ूड प्रोडक्ट्स', sector: 'FOOD_PROC', block: 'Raisen', score: 58 }, { enterpriseId: 'MSME-5120', name: 'Anand Food Products', nameHi: 'आनंद फ़ूड प्रोडक्ट्स', sector: 'FOOD_PROC', block: 'Raisen', score: 58 },
+49 -37
View File
@@ -11,6 +11,7 @@ import type {
import type { RiskAlert } from '../api' import type { RiskAlert } from '../api'
import { import {
ENTERPRISES, ENTERPRISES,
SECTORS, SECTORS,
breachPoint, breachPoint,
forecastFor, forecastFor,
@@ -27,8 +28,22 @@ import { currentMonthKey, dayOffsetIso, monthRef, pickInt, pickOne, rng, seedOf
export const ALL_BLOCKS = 'ALL' 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), (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 * counts are 142 / 9 / 31 / 102 with the deck's deltas; any narrower scope
* recomputes honestly from the filtered set. * recomputes honestly from the filtered set.
*/ */
export function portfolioSummary(block: string, month: string): PortfolioSummary { export function portfolioSummary(block: string, month: string, book: Enterprise[]): PortfolioSummary {
const rows = scopedEnterprises(block) const rows = scopedEnterprises(block, undefined, book)
const red = rows.filter((e) => e.band === 'RED').length const red = rows.filter((e) => e.band === 'RED').length
const watch = rows.filter((e) => e.band === 'WATCH').length const watch = rows.filter((e) => e.band === 'WATCH').length
const standard = rows.filter((e) => e.band === 'STANDARD').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'] const ALERT_STATES: AlertState[] = ['OPEN', 'OPEN', 'OPEN', 'ACKNOWLEDGED', 'ESCALATED', 'SNOOZED']
/** Every Red and Watch enterprise carries a live alert; Standard ones do not. */ /** Every Red and Watch enterprise carries a live alert; Standard ones do not. */
export function alertsFor(block: string): RiskAlert[] { export function alertsFor(block: string, book: Enterprise[]): RiskAlert[] {
return scopedEnterprises(block) return scopedEnterprises(block, undefined, book)
.filter((e) => e.band !== 'STANDARD') .filter((e) => e.band !== 'STANDARD')
.map((e, i) => { .map((e, i) => {
const r = rng(seedOf(`${e.enterpriseId}:alert`)) const r = rng(seedOf(`${e.enterpriseId}:alert`))
@@ -103,9 +118,16 @@ export function alertsFor(block: string): RiskAlert[] {
.sort((a, b) => b.score - a.score) .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 { 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 ───────────────────────────────────── // ── Field visits / playbook ─────────────────────────────────────
@@ -130,8 +152,8 @@ const OUTCOMES = [
'Buyer payment confirmed as delayed, not defaulted.', 'Buyer payment confirmed as delayed, not defaulted.',
] ]
export function visitsFor(block: string, officer = 'Aditya Vyas'): FieldVisit[] { export function visitsFor(block: string, book: Enterprise[], officer = 'Aditya Vyas'): FieldVisit[] {
return scopedEnterprises(block) return scopedEnterprises(block, undefined, book)
.filter((e) => e.band !== 'STANDARD') .filter((e) => e.band !== 'STANDARD')
.slice(0, 24) .slice(0, 24)
.map((e, i) => { .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. */ // ── Benchmarks ─────────────────────────────────────────────────
export function weeklyVisitProgress(block: string): { done: number; target: number } { //
const visits = visitsFor(block).filter((v) => v.priority === 1) // Removed 2026-08-24. This block held nine constants the Reports page rendered as
const target = block === ALL_BLOCKS ? 9 : Math.max(3, visits.length) // measurements: AUC 0.87 against an invented "bureau baseline", a 2,400-enterprise
const done = block === ALL_BLOCKS ? 6 : visits.filter((v) => v.state === 'DONE').length // dataset that did not exist, and an on-device inference latency for inference
return { done, target } // that does not run on a device. Nothing computed any of them.
} //
// The replacement is the `model_metric` table, written by
// ── Benchmarks (deck slide 10) ────────────────────────────────── // `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
export const BENCHMARKS = { // were here, which is the point.
aucRoc: 0.87,
aucRepaymentOnly: 0.68,
aucBureau: 0.61,
alertPrecisionRed: 0.79,
medianLeadTimeMonths: '3–6',
onDeviceLatencyMs: 250,
datasetSize: 2400,
sectorsCovered: 5,
districtsCovered: 4,
}
/** Band migration month over month — how the book moved. */ /** Band migration month over month — how the book moved. */
export function bandMigration(block: string): { month: string; red: number; watch: number; standard: number }[] { export function bandMigration(block: string, book: Enterprise[]): { month: string; red: number; watch: number; standard: number }[] {
const rows = scopedEnterprises(block) const rows = scopedEnterprises(block, undefined, book)
const r = rng(seedOf(`${block}:migration`)) const r = rng(seedOf(`${block}:migration`))
const base = new Date() const base = new Date()
return Array.from({ length: 6 }, (_, i) => { 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. */ /** 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) => { 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 red = rows.filter((e) => e.band === 'RED').length
const watch = rows.filter((e) => e.band === 'WATCH').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 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. */ /** Portfolio-level P10/P50/P90 aggregate over the 6-month horizon. */
export function portfolioForecast(block: string) { export function portfolioForecast(block: string, book: Enterprise[]) {
const rows = scopedEnterprises(block) const rows = scopedEnterprises(block, undefined, book)
const sample = rows.slice(0, 60) // enough for a stable aggregate without 142 × 6 work const sample = rows.slice(0, 60) // enough for a stable aggregate without 142 × 6 work
const paths = sample.map((e) => forecastFor(e)) const paths = sample.map((e) => forecastFor(e))
return paths[0].map((_, h) => ({ return paths[0].map((_, h) => ({
+73 -21
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import type { LedgerEntry, LedgerKind } from './api' import type { LedgerEntry, LedgerKind } from './api'
import { syncAppLedger } from './api'
import { dayOffsetIso, pickInt, pickOne, rng, seedOf, todayIso } from './demo/seed' 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 * Drain the offline queue to the server.
* flips PENDING → SYNCED locally so the UI's sync states are exercisable. *
* <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 { export async function syncPending(enterpriseId: string): Promise<number> {
const all = read() const pending = read().filter(
let n = 0 (e) => e.enterpriseId === enterpriseId && e.syncState === 'PENDING',
const next = all.map((e) => { )
if (e.enterpriseId === enterpriseId && e.syncState === 'PENDING') { if (pending.length === 0) return 0
n++
return { ...e, syncState: 'SYNCED' as const } try {
} await syncAppLedger(pending)
return e } catch {
}) return 0
if (n) {
write(next)
localStorage.setItem(SYNC_KEY, new Date().toISOString())
} }
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 { 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. */ /** 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(() => { useEffect(() => {
refresh() refresh()
window.addEventListener('ul-ledger-changed', refresh) window.addEventListener("ul-ledger-changed", refresh)
return () => window.removeEventListener('ul-ledger-changed', refresh) return () => window.removeEventListener("ul-ledger-changed", refresh)
}, [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 } 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])
}
+424
View File
@@ -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,
},
]
}
+78
View File
@@ -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
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import type { FieldVisit } from './api' import type { FieldVisit } from './api'
import { visitsFor } from './demo/ops'
/* /*
* Visits scheduled from the console during a demo session. * 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. */ /** 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 local = read()
const hit = local.find((v) => v.visitId === visitId) const hit = local.find((v) => v.visitId === visitId)
if (hit) { if (hit) {
write(local.map((v) => (v.visitId === visitId ? { ...v, state: 'DONE', outcome, loggedAt: new Date().toISOString() } : v))) write(local.map((v) => (v.visitId === visitId ? { ...v, state: 'DONE', outcome, loggedAt: new Date().toISOString() } : v)))
return return
} }
// A seeded visit: shadow it locally rather than mutating the generator. // A server visit: shadow it locally rather than reaching for the demo
const seeded = visitsFor('ALL').find((v) => v.visitId === visitId) // generator, which is not scoped to any bank. The caller supplies the row it
if (seeded) { // is closing, because it already has it on screen.
write([{ ...seeded, state: 'DONE', outcome, loggedAt: new Date().toISOString() }, ...local]) if (source) {
write([{ ...source, state: "DONE", outcome, loggedAt: new Date().toISOString() }, ...local])
} }
} }
/** Seeded playbook plus locally scheduled/updated visits, local taking precedence. */ /** 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 [local, setLocal] = useState<FieldVisit[]>(read)
const refresh = useCallback(() => setLocal(read()), []) const refresh = useCallback(() => setLocal(read()), [])
@@ -76,7 +85,8 @@ export function useVisits(block: string, officer: string) {
return () => window.removeEventListener('ul-visits-changed', refresh) return () => window.removeEventListener('ul-visits-changed', refresh)
}, [refresh]) }, [refresh])
const seeded = visitsFor(block, officer) void officer
const seeded = source
const overridden = new Set(local.map((v) => v.visitId)) const overridden = new Set(local.map((v) => v.visitId))
const scoped = local.filter((v) => block === 'ALL' || v.block === block) const scoped = local.filter((v) => block === 'ALL' || v.block === block)
const visits = [...scoped, ...seeded.filter((v) => !overridden.has(v.visitId))] const visits = [...scoped, ...seeded.filter((v) => !overridden.has(v.visitId))]
+458
View File
@@ -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&rsquo;s console and the borrower&rsquo;s phone, reading the same
record. Pick a row on the left &mdash; 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 &mdash; 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} &middot; {e.block} &middot;{' '}
{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> &middot; <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&rsquo;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 &mdash; the officer&rsquo;s and this
borrower&rsquo;s own. Neither column is copied from the other.
</p>
</section>
)
}
+51 -8
View File
@@ -42,7 +42,7 @@ const STATS = [
/** Demo credentials — mirrored by UdyamDataSeeder on the backend. */ /** Demo credentials — mirrored by UdyamDataSeeder on the backend. */
const DEMO = { const DEMO = {
officer: { username: 'aditya.vyas', password: 'Udyam@123' }, 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', tier: 'Partner bank',
note: 'its own book in full, borrower records included', note: 'its own book in full, borrower records included',
accounts: [ 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: '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: 'Sehore DCCB · head office', username: 'dccb.admin', secret: 'Udyam@123', mode: 'OFFICER' },
{ label: 'Field officer', username: 'aditya.vyas', secret: 'Udyam@123', mode: 'OFFICER' }, { label: 'Field officer', username: 'aditya.vyas', secret: 'Udyam@123', mode: 'OFFICER' },
@@ -90,7 +94,11 @@ const PERSONAS: {
{ {
tier: 'Enterprise', tier: 'Enterprise',
note: 'one borrower, itself', 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" className="hidden lg:flex flex-col flex-[1.4] px-14 py-12 relative overflow-hidden"
style={{ style={{
background: 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', 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"> <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 NABARD HACKATHON
</span> </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>
<div className="flex-1 flex flex-col justify-center max-w-[560px]"> <div className="flex-1 flex flex-col justify-center max-w-[560px]">
@@ -235,11 +251,21 @@ export function LoginPage() {
{/* ── right: sign-in ───────────────────────────────────── */} {/* ── 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="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="w-full max-w-[380px] mx-auto">
<div className="flex items-center gap-2 mb-1 lg:hidden"> <div className="flex items-center gap-2 mb-4">
<UdyamMark size={24} /> <div className="flex items-center gap-2 lg:hidden">
<span className="text-[16px] font-bold text-forest"> <UdyamMark size={24} />
Udyam<span className="text-amber">Linc</span> <span className="text-[16px] font-bold text-forest">
</span> 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> </div>
<h2 className="text-[19px] font-bold text-forest">{hi ? 'अपने उद्यम में जाएँ' : 'Sign in to the console'}</h2> <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.'} : 'Consent-first, explainable and auditable by design — no personally identifiable data.'}
</p> </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> </div>
</div> </div>
+267
View File
@@ -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;
}
+24
View File
@@ -3,10 +3,34 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // 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({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: { server: {
// 5180 belongs to BharatLinc-UI — UdyamLinc runs alongside it // 5180 belongs to BharatLinc-UI — UdyamLinc runs alongside it
port: 5182, 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,
}, },
}) })