diff --git a/src/features/officer/AlertsPage.tsx b/src/features/officer/AlertsPage.tsx index a78c7d4..105f561 100644 --- a/src/features/officer/AlertsPage.tsx +++ b/src/features/officer/AlertsPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' -import { officerSession } from '../../lib/api' +import { fetchAlerts, fetchEnterprises, joinAlerts, officerSession } from '../../lib/api' import type { AlertState, RiskAlert } from '../../lib/api' import { ActionChip, Card, Empty, GhostBtn, RiskBadge, StatusPill } from '../../components/ui/bits' import { SECTORS } from '../../lib/demo/enterprises' @@ -25,7 +26,20 @@ export function AlertsPage() { const navigate = useNavigate() const { block } = useScope() const [filter, setFilter] = useState('OPEN') - const { alerts, audit } = useAlerts(block) + // Two scoped lists, intersected: the alert carries only an enterprise id, and + // the borrower fields come from the enterprise list, which is filtered to this + // institution. An alert whose borrower is not on this book resolves to nothing + // and is dropped. + const alertsQ = useQuery({ + queryKey: ['officer-alerts', block], + queryFn: () => fetchAlerts(block), + }) + const entsQ = useQuery({ + queryKey: ['officer-enterprises', block], + queryFn: () => fetchEnterprises(block), + }) + const joined = joinAlerts(alertsQ.data ?? [], entsQ.data ?? []) + const { alerts, audit } = useAlerts(block, joined) const actor = officerSession()?.fullName ?? 'Field Officer' const rows = alerts.filter((a) => filter === 'ALL' || a.state === filter) @@ -34,6 +48,26 @@ export function AlertsPage() { setAlertState(alert, to, actor) } + // No demo fallback here, on purpose. The demo portfolio carries no owner, so + // showing it would list other banks' borrowers to whoever is signed in. An + // unreachable server is a thing to say, not a thing to paper over. + if (alertsQ.isError || entsQ.isError) { + return ( + +
+

Alert queue unavailable

+

+ 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. +

+ alertsQ.refetch()}> + Try again + +
+
+ ) + } + return (
@@ -80,8 +114,8 @@ export function AlertsPage() {
- {a.enterpriseId} · {SECTORS[a.sector].short} · {a.block} · score{' '} - {a.score} · raised {a.raisedAt} + {a.enterpriseId} · {SECTORS[a.sector]?.short ?? a.sector} · {a.block} · score{' '} + {a.score} · raised {a.raisedAt?.slice(0, 16).replace('T', ' ')}

{a.title}

diff --git a/src/features/officer/EnterpriseDetailPage.tsx b/src/features/officer/EnterpriseDetailPage.tsx index 29243c2..230c2f9 100644 --- a/src/features/officer/EnterpriseDetailPage.tsx +++ b/src/features/officer/EnterpriseDetailPage.tsx @@ -1,7 +1,16 @@ import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' import { useNavigate, useParams } from 'react-router-dom' -import { officerSession } from '../../lib/api' -import type { Enterprise } from '../../lib/api' +import { enterpriseDetail, isNotFound, officerSession } from '../../lib/api' +import type { + Enterprise, + FieldVisit, + LedgerEntry, + EnterpriseDetail, + ForecastPointRow, + ReasonCodeRow, + StressScoreRow, +} from '../../lib/api' import { ActionChip, Card, @@ -22,8 +31,6 @@ import { breachPoint, findEnterprise, forecastFor, - reasonCodesFor, - stressScoreFor, subScoresFor, tripWiresFor, } from '../../lib/demo/enterprises' @@ -43,18 +50,110 @@ const TABS: { key: Tab; label: string }[] = [ { key: 'VISITS', label: 'Visits' }, ] -/** Everything the officer needs about one enterprise, in five tabs. */ +/** + * Sector label that cannot throw. + * + *

`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. + * + *

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. + * + *

`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 + 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. + * + *

Reads the live record. This screen used to compute the score, the + * sub-scores and the drivers in the browser from a JavaScript copy of the rule + * weights — which meant the "SHAP" badge sat over numbers no model had produced, + * while the real SHAP values sat unread in `reason_code`. Everything on this page + * now comes from the scoring run that actually happened, and the engine that + * produced it is named on the score card. + */ export function EnterpriseDetailPage() { const { enterpriseId = '' } = useParams() const navigate = useNavigate() const [tab, setTab] = useState('OVERVIEW') const [scheduling, setScheduling] = useState(false) - const e = findEnterprise(enterpriseId) + const { data, error, isError, isLoading } = useQuery({ + queryKey: ['enterprise-detail', enterpriseId], + queryFn: () => enterpriseDetail(enterpriseId), + enabled: Boolean(enterpriseId), + }) + + // A 404 is an answer, not a failure. The tenancy model returns one for a + // borrower on another bank's book, so falling back to demo data here would + // show exactly the row the backend refused. + const refused = isNotFound(error) + const demoEnterprise = refused ? undefined : findEnterprise(enterpriseId) + const e = mergeEnterprise(data, demoEnterprise) + const demo = !refused && (isError || !data) + + if (isLoading) return + if (refused) return if (!e) return - const points = forecastFor(e) - const breach = breachPoint(points) + // Live forecast rows where we have them; the in-file generator only where the + // backend gave us nothing, so an unreachable API degrades rather than blanks. + const points = data?.forecast?.length + ? data.forecast.map((p) => ({ + month: p.month, + horizon: p.horizon, + p10: p.p10, + p50: p.p50, + p90: p.p90, + obligation: p.obligation, + })) + : forecastFor(demoEnterprise ?? e) + const breach = breachPoint(points as never) const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null return ( @@ -69,12 +168,13 @@ export function EnterpriseDetailPage() {

{e.name}

-
- {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
-
+
+ {demo && Demo data} setScheduling(true)}>Schedule visit setTab('FORECAST')}>Full forecast
@@ -100,28 +200,77 @@ export function EnterpriseDetailPage() { ))}
- {tab === 'OVERVIEW' && } - {tab === 'FORECAST' && } - {tab === 'DRIVERS' && } - {tab === 'LEDGER' && } - {tab === 'VISITS' && } + {tab === 'OVERVIEW' && } + {tab === 'FORECAST' && } + {tab === 'DRIVERS' && } + {tab === 'LEDGER' && } + {tab === 'VISITS' && } {scheduling && setScheduling(false)} />} ) } -function OverviewTab({ e }: { e: Enterprise }) { - const score = stressScoreFor(e) - const reasons = reasonCodesFor(e) +/** How the engine that produced a score should be described, and coloured. */ +const ENGINE_LABEL: Record = { + 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 (
- +
- + + + {/* 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. */} +
+ {engine.text} + {score && {score.confidence}% confidence} +
+

{engine.note}

+
- -
6-month score history
+ +
6-month score history
@@ -131,28 +280,64 @@ function OverviewTab({ e }: { e: Enterprise }) { {[ ['Loan outstanding', inr(e.loanOutstanding)], ['Monthly instalment', inr(e.monthlyInstalment)], - ['Sector pack', SECTORS[e.sector].label], + ['Sector pack', sectorLabel(e.sector)], ['Block / district', `${e.block} / ${e.district}`], ['Owners on record', String(e.ownerCount)], ['Vintage', `${e.vintageMonths} months`], + ['Last scored', score?.computedAt ? score.computedAt.slice(0, 16).replace('T', ' ') : '—'], ].map(([k, v]) => (
-
{k}
-
{v}
+
{k}
+
{v}
))} + +
+
+ Five risk families +
+
+ {families.map((f) => ( +
+ {f.label} + + = 65 ? 'bg-risk-red' : (f.value ?? 0) >= 35 ? 'bg-amber' : 'bg-green' + }`} + style={{ width: `${Math.min(100, f.value ?? 0)}%` }} + /> + + + {f.value ?? '—'} + +
+ ))} +
+
- + Top drivers, ranked} + >
- {reasons.map((r) => ( -
-
- {r.driver} · {r.weight} + {reasons.length === 0 && } + {reasons.slice(0, 5).map((r) => ( +
+ + {r.rank} + +
+
+ {r.driverHi || r.driver} + +{r.weight} +
+

{r.sentenceEn}

+

{r.sentenceHi}

-

{r.sentenceEn}

-

{r.sentenceHi}

))}
@@ -161,11 +346,29 @@ function OverviewTab({ e }: { e: Enterprise }) { ) } -function ForecastTab({ e }: { e: Enterprise }) { - const points = forecastFor(e) +function ForecastTab({ e, live }: { e: Enterprise; live: ForecastPointRow[] }) { + // Live rows carry the engine that produced them; the in-file generator does + // not, so the badge can always say which one the reader is looking at. + const engine = live.length ? live[0].engine : null + const points = live.length + ? live.map((p) => ({ + month: p.month, + label: monthRef(new Date(`${p.month}-01T00:00:00`)).label, + labelHi: monthRef(new Date(`${p.month}-01T00:00:00`)).labelHi, + p10: p.p10, + p50: p.p50, + p90: p.p90, + obligation: p.obligation, + })) + : forecastFor(e) return (
- + Missing inputs widen the band; they never lower its centre.} + className="lg:col-span-2" + >
@@ -207,14 +410,47 @@ function ForecastTab({ e }: { e: Enterprise }) { ) } -function DriversTab({ e }: { e: Enterprise }) { - const sub = subScoresFor(e) +function DriversTab({ + e, + score, + reasons, +}: { + e: Enterprise + score: StressScoreRow | null + reasons: ReasonCodeRow[] +}) { + // The five families as the run stored them. Only where the backend gave us + // nothing do we fall back to the in-browser approximation — and the badge says + // which of the two you are looking at. + const live = score != null + const sub = live + ? { + liquidity: score.liquidity ?? 0, + behavioural: score.behavioural ?? 0, + trendVolatility: score.trendVolatility ?? 0, + obligationLoad: score.obligationLoad ?? 0, + externalShock: score.externalShock ?? 0, + } + : subScoresFor(e) + const wires = tripWiresFor(e) + const isModel = score?.engine === 'MODEL' + return (
- + + {isModel + ? 'Contribution to the model’s score' + : 'Each family’s weighted share of the composite'} + + } + >
- +
{DRIVER_META.map((d) => ( @@ -223,7 +459,38 @@ function DriversTab({ e }: { e: Enterprise }) {
- + +
+ {reasons.length === 0 && } + {reasons.map((r) => ( +
+ + {r.rank} + +
+
+ {r.driver} + + + + +{r.weight} +
+

{r.sentenceEn}

+

{r.sentenceHi}

+
+
+ ))} +
+

+ Sentences are rendered once and stored, so the wording an officer acted on survives a later + change to the templates. +

+
+ +
@@ -259,8 +526,11 @@ function DriversTab({ e }: { e: Enterprise }) { ) } -function LedgerTab({ e }: { e: Enterprise }) { - const entries = loadLedger(e.enterpriseId).slice(0, 40) +function LedgerTab({ e, live }: { e: Enterprise; live: LedgerEntry[] }) { + // Server entries first — the payload carries this borrower's last sixty, + // tenant-checked. loadLedger reads whatever this browser happens to hold from + // the PWA demo, which is this device's data, not the enterprise's. + const entries = (live.length ? live : loadLedger(e.enterpriseId)).slice(0, 40) const label = (kind: string, code: string) => CATEGORIES[kind as keyof typeof CATEGORIES]?.find((c) => c.code === code)?.en ?? code @@ -301,8 +571,10 @@ function LedgerTab({ e }: { e: Enterprise }) { ) } -function VisitsTab({ e }: { e: Enterprise }) { - const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer') +function VisitsTab({ e, live }: { e: Enterprise; live: FieldVisit[] }) { + // The detail payload already carries this enterprise's visits, tenant-checked + // on the way out. Locally scheduled ones merge over them. + const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer', live) const mine = visits.filter((v) => v.enterpriseId === e.enterpriseId) return ( diff --git a/src/features/officer/EnterprisesPage.tsx b/src/features/officer/EnterprisesPage.tsx index 972ae16..c578430 100644 --- a/src/features/officer/EnterprisesPage.tsx +++ b/src/features/officer/EnterprisesPage.tsx @@ -4,8 +4,9 @@ import { useNavigate } from 'react-router-dom' import { get } from '../../lib/api' import type { Band, Enterprise, Sector } from '../../lib/api' import { ActionChip, Empty, GhostBtn, RiskBadge, Sparkline, inputCls } from '../../components/ui/bits' +import { Unavailable } from '../../components/ui/bits' import { SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises' -import { scopedEnterprises } from '../../lib/demo/ops' + import { inrCompact } from '../../lib/demo/seed' import { exportCsv } from '../../lib/csv' import { useScope } from './officerScope' @@ -25,12 +26,15 @@ export function EnterprisesPage() { const [band, setBand] = useState('ALL') const [sector, setSector] = useState('ALL') - const { data } = useQuery({ + const { data, isError, refetch } = useQuery({ queryKey: ['enterprises', block], queryFn: () => get('/officer/enterprises', { block }), }) - const all = data ?? scopedEnterprises(block) + // No fallback: the demo portfolio has no owner, so it cannot be shown to a + // specific bank. An unreachable server is stated, not substituted. + // Memoised so the derivations below do not recompute on every render. + const all = useMemo(() => data ?? [], [data]) const rows = useMemo(() => { const needle = q.trim().toLowerCase() @@ -61,6 +65,8 @@ export function EnterprisesPage() { ) } + if (isError || !data) return void refetch()} /> + return (
diff --git a/src/features/officer/SectorPortfolioPage.tsx b/src/features/officer/SectorPortfolioPage.tsx index 45e65a7..a45c97c 100644 --- a/src/features/officer/SectorPortfolioPage.tsx +++ b/src/features/officer/SectorPortfolioPage.tsx @@ -16,7 +16,8 @@ import { import { get } from '../../lib/api' import type { Band, Enterprise, Sector } from '../../lib/api' import { Card, Empty, StatusPill } from '../../components/ui/bits' -import { BANDS, ENTERPRISES, SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises' +import { Unavailable } from '../../components/ui/bits' +import { BANDS, SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises' /** * The portfolio as a bank supervisor thinks about it: sectors first, then the @@ -60,12 +61,14 @@ export function SectorPortfolioPage() { const [term, setTerm] = useState('') const [selected, setSelected] = useState(null) - const { data, isError } = useQuery({ + const { data, isError, refetch } = useQuery({ queryKey: ['portfolio-enterprises'], queryFn: () => get('/officer/enterprises'), }) const demo = isError - const all = data ?? ENTERPRISES + // Same rule: ENTERPRISES is the unscoped demo book. + // Memoised so the derivations below do not recompute on every render. + const all = useMemo(() => data ?? [], [data]) const bySector = useMemo(() => { const map = new Map() @@ -101,6 +104,8 @@ export function SectorPortfolioPage() { const chosen = rows.find((e) => e.enterpriseId === selected) ?? null + if (isError || !data) return void refetch()} /> + return (
{/* ── header ─────────────────────────────────────── */} diff --git a/src/features/officer/VisitsPage.tsx b/src/features/officer/VisitsPage.tsx index f233d53..d0d34a3 100644 --- a/src/features/officer/VisitsPage.tsx +++ b/src/features/officer/VisitsPage.tsx @@ -1,6 +1,7 @@ +import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { officerSession } from '../../lib/api' +import { fetchVisits, officerSession } from '../../lib/api' import type { FieldVisit, VisitState } from '../../lib/api' import { ActionChip, @@ -33,7 +34,11 @@ export function VisitsPage() { const navigate = useNavigate() const { block } = useScope() const officer = officerSession()?.fullName ?? 'Field Officer' - const { visits } = useVisits(block, officer) + const visitsQ = useQuery({ + queryKey: ['officer-visits', block], + queryFn: () => fetchVisits(block), + }) + const { visits } = useVisits(block, officer, visitsQ.data ?? []) const [filter, setFilter] = useState('ALL') const [logging, setLogging] = useState(null) @@ -133,7 +138,7 @@ function LogOutcomeModal({ visit, onClose }: { visit: FieldVisit; onClose: () => const [note, setNote] = useState('') function save() { - logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset) + logOutcome(visit.visitId, note.trim() ? `${preset} ${note.trim()}` : preset, visit) onClose() } diff --git a/src/features/officer/useTenantData.ts b/src/features/officer/useTenantData.ts new file mode 100644 index 0000000..5335e62 --- /dev/null +++ b/src/features/officer/useTenantData.ts @@ -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. + * + *

Why these exist

+ * + *

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. + * + *

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. + * + *

The rule

+ * + *

Borrower rows come from the tenant-scoped API or they do not render. + * 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. + * + *

Feeds, data sources, connectors and the rule catalogue keep their demo + * fallbacks. Nothing in them is a borrower. + */ + +export interface Scoped { + 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 { + 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 { + 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. + * + *

`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 { + 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() + }, + } +} diff --git a/src/lib/alertStore.ts b/src/lib/alertStore.ts index 0276a13..21c0ae8 100644 --- a/src/lib/alertStore.ts +++ b/src/lib/alertStore.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState } from 'react' import type { AlertState, RiskAlert } from './api' -import { alertsFor } from './demo/ops' /* * Officer actions on the alert queue. @@ -51,7 +50,19 @@ export function setAlertState(alert: RiskAlert, to: AlertState, actor: string, n } /** The alert queue for a block, with any officer actions applied on top. */ -export function useAlerts(block: string) { +/** + * Live alerts with local dispositions overlaid. + * + *

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. + * + *

Acknowledge / escalate / snooze still live in local storage — they are demo + * gestures, not writes — so they are merged over whatever the server returned. + */ +export function useAlerts(block: string, source: RiskAlert[]) { const [version, setVersion] = useState(0) const bump = useCallback(() => setVersion((v) => v + 1), []) @@ -64,7 +75,8 @@ export function useAlerts(block: string) { const states = readStates() // `version` is read so the memo-free derivation re-runs after every action. void version - const alerts = alertsFor(block).map((a) => ({ ...a, state: states[a.alertId] ?? a.state })) + void block + const alerts = source.map((a) => ({ ...a, state: states[a.alertId] ?? a.state })) return { alerts, audit: readAudit() } } diff --git a/src/lib/demo/enterprises.ts b/src/lib/demo/enterprises.ts index be19086..7dedeab 100644 --- a/src/lib/demo/enterprises.ts +++ b/src/lib/demo/enterprises.ts @@ -43,16 +43,22 @@ export const SECTORS: Record = { Sehore: 'Sehore', Vidisha: 'Vidisha', Raisen: 'Raisen', Bhopal: 'Bhopal', + Kamrup: 'Kamrup', + Nagaon: 'Nagaon', } /** The six enterprises printed on the wireframe slide — reproduced exactly. */ const PINNED: { enterpriseId: string; name: string; nameHi: string; sector: Sector; block: string; score: number }[] = [ + // Kaziranga is the borrower the officer console opens on: score 3, nothing + // visibly wrong, and a forecast breach in November. Pinned so the enterprise + // app can show the same business from the owner's side. + { enterpriseId: 'MSME-6151', name: 'Kaziranga Fruit Farm', nameHi: 'काज़ीरंगा फ्रूट फ़ार्म', sector: 'HORTICULTURE', block: 'Kamrup', score: 3 }, { enterpriseId: 'MSME-4417', name: 'Sehore Poultry Farm', nameHi: 'सीहोर पोल्ट्री फ़ार्म', sector: 'POULTRY', block: 'Sehore', score: 79 }, { enterpriseId: 'MSME-3902', name: 'Sharda Dairy Collective', nameHi: 'शारदा डेयरी समूह', sector: 'DAIRY', block: 'Vidisha', score: 71 }, { enterpriseId: 'MSME-5120', name: 'Anand Food Products', nameHi: 'आनंद फ़ूड प्रोडक्ट्स', sector: 'FOOD_PROC', block: 'Raisen', score: 58 }, diff --git a/src/lib/demo/ops.ts b/src/lib/demo/ops.ts index ccad73d..4a05113 100644 --- a/src/lib/demo/ops.ts +++ b/src/lib/demo/ops.ts @@ -11,6 +11,7 @@ import type { import type { RiskAlert } from '../api' import { ENTERPRISES, + SECTORS, breachPoint, forecastFor, @@ -27,8 +28,22 @@ import { currentMonthKey, dayOffsetIso, monthRef, pickInt, pickOne, rng, seedOf export const ALL_BLOCKS = 'ALL' -export function scopedEnterprises(block: string, sector?: Sector | 'ALL'): Enterprise[] { - return ENTERPRISES.filter( +/** + * Filter a book by block and sector. + * + *

`book` is required and has no default on purpose. It used to close over + * `ENTERPRISES` — the fabricated 142-borrower demo portfolio, which carries no + * owner — so every aggregate built on it mixed all banks together and showed the + * result to whoever was signed in. Callers now pass the tenant-scoped list from + * `useTenantEnterprises`, and a caller that has no list cannot accidentally get + * the demo one. + */ +export function scopedEnterprises( + block: string, + sector: Sector | "ALL" | undefined, + book: Enterprise[], +): Enterprise[] { + return book.filter( (e) => (block === ALL_BLOCKS || e.block === block) && (!sector || sector === 'ALL' || e.sector === sector), ) } @@ -38,8 +53,8 @@ export function scopedEnterprises(block: string, sector?: Sector | 'ALL'): Enter * counts are 142 / 9 / 31 / 102 with the deck's deltas; any narrower scope * recomputes honestly from the filtered set. */ -export function portfolioSummary(block: string, month: string): PortfolioSummary { - const rows = scopedEnterprises(block) +export function portfolioSummary(block: string, month: string, book: Enterprise[]): PortfolioSummary { + const rows = scopedEnterprises(block, undefined, book) const red = rows.filter((e) => e.band === 'RED').length const watch = rows.filter((e) => e.band === 'WATCH').length const standard = rows.filter((e) => e.band === 'STANDARD').length @@ -66,8 +81,8 @@ export function portfolioSummary(block: string, month: string): PortfolioSummary const ALERT_STATES: AlertState[] = ['OPEN', 'OPEN', 'OPEN', 'ACKNOWLEDGED', 'ESCALATED', 'SNOOZED'] /** Every Red and Watch enterprise carries a live alert; Standard ones do not. */ -export function alertsFor(block: string): RiskAlert[] { - return scopedEnterprises(block) +export function alertsFor(block: string, book: Enterprise[]): RiskAlert[] { + return scopedEnterprises(block, undefined, book) .filter((e) => e.band !== 'STANDARD') .map((e, i) => { const r = rng(seedOf(`${e.enterpriseId}:alert`)) @@ -103,9 +118,16 @@ export function alertsFor(block: string): RiskAlert[] { .sort((a, b) => b.score - a.score) } -/** The single alert the enterprise app shows on its Home tab. */ +/** + * The single alert the enterprise app shows on its Home tab. + * + *

The one sanctioned use of the unscoped demo book, and it is safe because of + * the shape of the question: the borrower PWA is signed in as one enterprise and + * this returns that enterprise's own alert or nothing. There is no list, so there + * is nothing to leak across banks. + */ export function alertForEnterprise(enterpriseId: string): RiskAlert | null { - return alertsFor(ALL_BLOCKS).find((a) => a.enterpriseId === enterpriseId) ?? null + return alertsFor(ALL_BLOCKS, ENTERPRISES).find((a) => a.enterpriseId === enterpriseId) ?? null } // ── Field visits / playbook ───────────────────────────────────── @@ -130,8 +152,8 @@ const OUTCOMES = [ 'Buyer payment confirmed as delayed, not defaulted.', ] -export function visitsFor(block: string, officer = 'Aditya Vyas'): FieldVisit[] { - return scopedEnterprises(block) +export function visitsFor(block: string, book: Enterprise[], officer = 'Aditya Vyas'): FieldVisit[] { + return scopedEnterprises(block, undefined, book) .filter((e) => e.band !== 'STANDARD') .slice(0, 24) .map((e, i) => { @@ -156,31 +178,21 @@ export function visitsFor(block: string, officer = 'Aditya Vyas'): FieldVisit[] }) } -/** "This week 6 / 9 high-risk visits done" — the sidebar progress card on slide 6. */ -export function weeklyVisitProgress(block: string): { done: number; target: number } { - const visits = visitsFor(block).filter((v) => v.priority === 1) - const target = block === ALL_BLOCKS ? 9 : Math.max(3, visits.length) - const done = block === ALL_BLOCKS ? 6 : visits.filter((v) => v.state === 'DONE').length - return { done, target } -} - -// ── Benchmarks (deck slide 10) ────────────────────────────────── - -export const BENCHMARKS = { - aucRoc: 0.87, - aucRepaymentOnly: 0.68, - aucBureau: 0.61, - alertPrecisionRed: 0.79, - medianLeadTimeMonths: '3–6', - onDeviceLatencyMs: 250, - datasetSize: 2400, - sectorsCovered: 5, - districtsCovered: 4, -} +// ── Benchmarks ───────────────────────────────────────────────── +// +// Removed 2026-08-24. This block held nine constants the Reports page rendered as +// measurements: AUC 0.87 against an invented "bureau baseline", a 2,400-enterprise +// dataset that did not exist, and an on-device inference latency for inference +// that does not run on a device. Nothing computed any of them. +// +// The replacement is the `model_metric` table, written by +// `UdyamLinc-ML/training/train_stress.py` at the end of every run and served by +// GET /admin/model/metrics. The measured figures are lower than the ones that +// were here, which is the point. /** Band migration month over month — how the book moved. */ -export function bandMigration(block: string): { month: string; red: number; watch: number; standard: number }[] { - const rows = scopedEnterprises(block) +export function bandMigration(block: string, book: Enterprise[]): { month: string; red: number; watch: number; standard: number }[] { + const rows = scopedEnterprises(block, undefined, book) const r = rng(seedOf(`${block}:migration`)) const base = new Date() return Array.from({ length: 6 }, (_, i) => { @@ -197,9 +209,9 @@ export function bandMigration(block: string): { month: string; red: number; watc } /** Per-sector-pack rollup for the Forecasts screen. */ -export function sectorRollup(block: string) { +export function sectorRollup(block: string, book: Enterprise[]) { return (Object.keys(SECTORS) as Sector[]).map((s) => { - const rows = scopedEnterprises(block, s) + const rows = scopedEnterprises(block, s, book) const red = rows.filter((e) => e.band === 'RED').length const watch = rows.filter((e) => e.band === 'WATCH').length const avg = rows.length ? rows.reduce((a, e) => a + e.score, 0) / rows.length : 0 @@ -216,8 +228,8 @@ export function sectorRollup(block: string) { } /** Portfolio-level P10/P50/P90 aggregate over the 6-month horizon. */ -export function portfolioForecast(block: string) { - const rows = scopedEnterprises(block) +export function portfolioForecast(block: string, book: Enterprise[]) { + const rows = scopedEnterprises(block, undefined, book) const sample = rows.slice(0, 60) // enough for a stable aggregate without 142 × 6 work const paths = sample.map((e) => forecastFor(e)) return paths[0].map((_, h) => ({ diff --git a/src/lib/visitStore.ts b/src/lib/visitStore.ts index 39aff6d..c8f8ec5 100644 --- a/src/lib/visitStore.ts +++ b/src/lib/visitStore.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState } from 'react' import type { FieldVisit } from './api' -import { visitsFor } from './demo/ops' /* * Visits scheduled from the console during a demo session. @@ -51,22 +50,32 @@ export function scheduleVisit(input: { } /** Record the outcome of a visit — this is what feeds the next retraining cycle. */ -export function logOutcome(visitId: string, outcome: string) { +export function logOutcome(visitId: string, outcome: string, source?: FieldVisit) { const local = read() const hit = local.find((v) => v.visitId === visitId) if (hit) { write(local.map((v) => (v.visitId === visitId ? { ...v, state: 'DONE', outcome, loggedAt: new Date().toISOString() } : v))) return } - // A seeded visit: shadow it locally rather than mutating the generator. - const seeded = visitsFor('ALL').find((v) => v.visitId === visitId) - if (seeded) { - write([{ ...seeded, state: 'DONE', outcome, loggedAt: new Date().toISOString() }, ...local]) + // A server visit: shadow it locally rather than reaching for the demo + // generator, which is not scoped to any bank. The caller supplies the row it + // is closing, because it already has it on screen. + if (source) { + write([{ ...source, state: "DONE", outcome, loggedAt: new Date().toISOString() }, ...local]) } } /** Seeded playbook plus locally scheduled/updated visits, local taking precedence. */ -export function useVisits(block: string, officer: string) { +/** + * Live visits with locally scheduled ones merged in. + * + *

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(read) const refresh = useCallback(() => setLocal(read()), []) @@ -76,7 +85,8 @@ export function useVisits(block: string, officer: string) { return () => window.removeEventListener('ul-visits-changed', refresh) }, [refresh]) - const seeded = visitsFor(block, officer) + void officer + const seeded = source const overridden = new Set(local.map((v) => v.visitId)) const scoped = local.filter((v) => block === 'ALL' || v.block === block) const visits = [...scoped, ...seeded.filter((v) => !overridden.has(v.visitId))]