import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate, useParams } from 'react-router-dom' import { enterpriseDetail, isNotFound, officerSession } from '../../lib/api' import type { Enterprise, FieldVisit, LedgerEntry, EnterpriseDetail, ForecastPointRow, ReasonCodeRow, StressScoreRow, } from '../../lib/api' import { ActionChip, Card, DriverBar, Empty, GhostBtn, PrimaryBtn, RiskBadge, ScoreDial, Sparkline, StatusPill, } from '../../components/ui/bits' import { ForecastFan } from '../../components/charts/ForecastFan' import { SubScoreRadar } from '../../components/charts/SubScoreRadar' import { DRIVER_META, SECTORS, breachPoint, findEnterprise, forecastFor, subScoresFor, tripWiresFor, } from '../../lib/demo/enterprises' import { inr, inrCompact, monthRef } from '../../lib/demo/seed' import { loadLedger } from '../../lib/ledgerStore' import { CATEGORIES } from '../../lib/ledgerStore' import { useVisits } from '../../lib/visitStore' import { ScheduleVisitModal } from './ScheduleVisitModal' type Tab = 'OVERVIEW' | 'FORECAST' | 'DRIVERS' | 'LEDGER' | 'VISITS' const TABS: { key: Tab; label: string }[] = [ { key: 'OVERVIEW', label: 'Overview' }, { key: 'FORECAST', label: 'Forecast' }, { key: 'DRIVERS', label: 'Drivers' }, { key: 'LEDGER', label: 'Ledger' }, { key: 'VISITS', label: 'Visits' }, ] /** * 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 { 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 // 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 (

{/* ── header ─────────────────────────────────────────── */}

{e.name}

{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
{breach && (
!

Downside case fails to cover obligations from {breachRef?.fullEn} — projected gap{' '} {inr(breach.obligation - breach.p10)} at P10.

)}
{TABS.map((t) => ( setTab(t.key)}> {t.label} ))}
{tab === 'OVERVIEW' && } {tab === 'FORECAST' && } {tab === 'DRIVERS' && } {tab === 'LEDGER' && } {tab === 'VISITS' && } {scheduling && setScheduling(false)} />}
) } /** 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
{[ ['Loan outstanding', inr(e.loanOutstanding)], ['Monthly instalment', inr(e.monthlyInstalment)], ['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}
))}
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.length === 0 && } {reasons.slice(0, 5).map((r) => (
{r.rank}
{r.driverHi || r.driver} +{r.weight}

{r.sentenceEn}

{r.sentenceHi}

))}
) } 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" >
{points.map((p) => { const short = p.p10 < p.obligation return ( ) })}
Month P10 P50 Due
{p.label} {inrCompact(p.p10)} {inrCompact(p.p50)} {inrCompact(p.obligation)}

Rows in red are months where the downside case does not cover what is due.

) } 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) => ( ))}
{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.

{wires.map((w) => ( ))}
Code Rule Threshold State
{w.code} {w.label} {w.detail} {w.threshold} {w.fired ? 'Fired' : 'Clear'}

Trip-wires fire independently of the model, so every alert stays auditable.

) } 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 return (
{entries.map((l) => ( ))}
Date Type Category Amount Sync
{l.entryDate} {l.kind === 'IN' ? 'Money in' : l.kind === 'OUT' ? 'Money out' : 'Stock'} {label(l.kind, l.category)} {inr(l.amount)}
{!entries.length && }
) } 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 (
{mine.map((v) => ( ))}
Visit Action Due State Outcome
{v.visitId} {v.action} {v.dueDate} {v.outcome ?? '—'}
{!mine.length && }
) }