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 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
Downside case fails to cover obligations from {breachRef?.fullEn} — projected gap{' '}
{inr(breach.obligation - breach.p10)} at P10.
{engine.note} {r.sentenceEn} {r.sentenceHi}
Rows in red are months where the downside case does not cover what is due.
{r.sentenceEn} {r.sentenceHi}
Sentences are rendered once and stored, so the wording an officer acted on survives a later
change to the templates.
Trip-wires fire independently of the model, so every alert stays auditable.
{e.name}
{[
['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]) => (
{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)}
{wires.map((w) => (
Code
Rule
Threshold
State
))}
{w.code}
{w.label}
{w.detail}
{w.threshold}
{w.fired ? 'Fired' : 'Clear'}
{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)}
{mine.map((v) => (
Visit
Action
Due
State
Outcome
))}
{v.visitId}
{v.action}
{v.dueDate}
{v.outcome ?? '—'}