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>
This commit is contained in:
@@ -1,248 +1,391 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { CalendarClock, CloudRain, Database, Siren, TrendingDown } from 'lucide-react'
|
||||
import { get } from '../../lib/api'
|
||||
import type { Enterprise, PortfolioSummary } from '../../lib/api'
|
||||
import {
|
||||
DriverBar,
|
||||
GhostBtn,
|
||||
KpiTile,
|
||||
PrimaryBtn,
|
||||
RiskBadge,
|
||||
ScoreDial,
|
||||
Sparkline,
|
||||
} from '../../components/ui/bits'
|
||||
import {
|
||||
DRIVER_META,
|
||||
SECTORS,
|
||||
breachPoint,
|
||||
forecastFor,
|
||||
reasonCodesFor,
|
||||
subScoresFor,
|
||||
} from '../../lib/demo/enterprises'
|
||||
import { portfolioSummary } from '../../lib/demo/ops'
|
||||
import { inr, monthRef } from '../../lib/demo/seed'
|
||||
import type { ClimateSignal, Enterprise, MarketSignal, PortfolioSummary } from '../../lib/api'
|
||||
import { Unavailable } from '../../components/ui/bits'
|
||||
import { SECTORS } from '../../lib/demo/enterprises'
|
||||
import { demoClimateSignals, demoMarketSignals } from '../../lib/demo/feeds'
|
||||
import { monthRef } from '../../lib/demo/seed'
|
||||
import { exportCsv } from '../../lib/csv'
|
||||
import { useScope } from './officerScope'
|
||||
import { ScheduleVisitModal } from './ScheduleVisitModal'
|
||||
import { DashboardTabs } from './DashboardTabs'
|
||||
|
||||
/**
|
||||
* The console's home screen and the deck's headline wireframe: KPI tiles, the
|
||||
* "Enterprises by risk" register, and a risk detail panel that explains the
|
||||
* selected enterprise in the officer's own language.
|
||||
* The officer's morning screen, organised by **when trouble arrives**.
|
||||
*
|
||||
* <p>The previous version ranked by score: five tiles counting bands, then forty
|
||||
* rows sorted by composite score. That is what a bureau score already shows. The
|
||||
* thing this platform can do that a bureau cannot is say <em>when</em> — trouble
|
||||
* four months out, while intervention is still cheap — and nothing on that screen
|
||||
* expressed time at all.
|
||||
*
|
||||
* <p>So the primary axis here is the horizon at which the downside case first
|
||||
* fails to cover the instalment. The triage strip is a funnel, the chart draws
|
||||
* the intervention window, and the list is a day's work rather than a register.
|
||||
* The full register still lives at <code>/officer/enterprises</code>; this screen
|
||||
* deliberately does not duplicate it.
|
||||
*
|
||||
* <p>The old layout is kept at <code>/officer/classic</code> — it is verified and
|
||||
* this one is new, and on a demo day that switch is worth having.
|
||||
*/
|
||||
|
||||
type Bucket = 'NOW' | 'WINDOW' | 'LATER' | 'CLEAR'
|
||||
|
||||
const BUCKETS: { key: Bucket; label: string; caption: string; tone: string }[] = [
|
||||
{ key: 'NOW', label: 'Act now', caption: 'breaching this month', tone: 'gf-now' },
|
||||
{ key: 'WINDOW', label: 'Month 2–4', caption: 'the window that matters', tone: 'gf-window' },
|
||||
{ key: 'LATER', label: 'Month 5–6', caption: 'on the horizon', tone: 'gf-watch' },
|
||||
{ key: 'CLEAR', label: 'Downside covered', caption: 'no breach forecast', tone: 'gf-clear' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Which bucket a borrower falls in — **on breach horizon alone**.
|
||||
*
|
||||
* <p>Deliberately one dimension. An earlier version mixed band into the last two
|
||||
* segments, which put every WATCH-band borrower with no forecast breach into
|
||||
* "Watching" and left "Healthy" reading zero — a funnel that bulged in the middle
|
||||
* and looked broken.
|
||||
*
|
||||
* <p>Horizon also has to win over score on its own merits. A STANDARD-band
|
||||
* enterprise whose downside stops covering the instalment in four months is the
|
||||
* entire point of this product; ranking by current score would bury it under
|
||||
* borrowers already in arrears, who are already known to everyone.
|
||||
*/
|
||||
function bucketOf(e: Enterprise): Bucket {
|
||||
const h = e.firstBreachHorizon
|
||||
if (h == null) return 'CLEAR'
|
||||
if (h <= 1) return 'NOW'
|
||||
if (h <= 4) return 'WINDOW'
|
||||
return 'LATER'
|
||||
}
|
||||
|
||||
export function OfficerDashboard() {
|
||||
const navigate = useNavigate()
|
||||
const { block, month } = useScope()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<Bucket | 'ALL'>('ALL')
|
||||
const [scheduling, setScheduling] = useState<Enterprise | null>(null)
|
||||
|
||||
const { data } = useQuery({
|
||||
const { data, isError, refetch } = useQuery({
|
||||
queryKey: ['portfolio', block, month],
|
||||
queryFn: () => get<PortfolioSummary>('/officer/portfolio', { block, month }),
|
||||
})
|
||||
|
||||
// Demo fallback: with the backend down, the screen still shows the full book.
|
||||
const summary = data ?? portfolioSummary(block, month)
|
||||
const rows = summary.rows
|
||||
// No demo fallback: the demo portfolio carries no owner, so substituting it
|
||||
// would show this bank every other bank's borrowers.
|
||||
const rows = useMemo(() => data?.rows ?? [], [data])
|
||||
|
||||
const selected = useMemo(
|
||||
() => rows.find((e) => e.enterpriseId === selectedId) ?? rows[0],
|
||||
[rows, selectedId],
|
||||
)
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<Bucket, number> = { NOW: 0, WINDOW: 0, LATER: 0, CLEAR: 0 }
|
||||
rows.forEach((e) => { c[bucketOf(e)] += 1 })
|
||||
return c
|
||||
}, [rows])
|
||||
|
||||
function handleExport() {
|
||||
exportCsv(
|
||||
`udyamlinc-portfolio-${block}-${month}.csv`,
|
||||
['Enterprise ID', 'Name', 'Sector', 'Block', 'Score', 'Band', 'Loan outstanding', 'Instalment'],
|
||||
rows.map((e) => [
|
||||
e.enterpriseId,
|
||||
e.name,
|
||||
SECTORS[e.sector].label,
|
||||
e.block,
|
||||
e.score,
|
||||
e.band,
|
||||
e.loanOutstanding,
|
||||
e.monthlyInstalment,
|
||||
]),
|
||||
)
|
||||
}
|
||||
// How many borrowers first breach at each horizon — the intervention window, drawn.
|
||||
const horizon = useMemo(() => {
|
||||
const h = [0, 0, 0, 0, 0, 0]
|
||||
rows.forEach((e) => {
|
||||
const n = e.firstBreachHorizon
|
||||
if (n != null && n >= 1 && n <= 6) h[n - 1] += 1
|
||||
})
|
||||
return h
|
||||
}, [rows])
|
||||
|
||||
// The day's work: soonest breach first, then worst score. Capped, because an
|
||||
// officer visits nine borrowers a week and a list of forty is a list of none.
|
||||
const worklist = useMemo(() => {
|
||||
const pool = filter === 'ALL'
|
||||
? rows.filter((e) => bucketOf(e) !== 'CLEAR')
|
||||
: rows.filter((e) => bucketOf(e) === filter)
|
||||
return [...pool]
|
||||
.sort((a, b) =>
|
||||
(a.firstBreachHorizon ?? 99) - (b.firstBreachHorizon ?? 99) || b.score - a.score)
|
||||
.slice(0, 9)
|
||||
}, [rows, filter])
|
||||
|
||||
if (isError || !data) return <Unavailable what="Portfolio" onRetry={() => void refetch()} />
|
||||
|
||||
const maxH = Math.max(1, ...horizon)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── KPI tiles ─────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
<KpiTile
|
||||
label="Total enterprises"
|
||||
value={summary.total}
|
||||
delta={`${summary.totalDelta >= 0 ? '+' : ''}${summary.totalDelta} this month`}
|
||||
/>
|
||||
<KpiTile
|
||||
label="High risk"
|
||||
value={summary.red}
|
||||
delta={`${summary.redDelta >= 0 ? '+' : ''}${summary.redDelta} since last week`}
|
||||
tone="red"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Watch"
|
||||
value={summary.watch}
|
||||
delta={`${summary.watchDelta >= 0 ? '+' : ''}${summary.watchDelta} since last week`}
|
||||
tone="watch"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Standard"
|
||||
value={summary.standard}
|
||||
delta={summary.standardDelta === 0 ? 'stable' : `${summary.standardDelta > 0 ? '+' : ''}${summary.standardDelta} since last week`}
|
||||
tone="good"
|
||||
/>
|
||||
<DashboardTabs />
|
||||
<div className="gf">
|
||||
{/* ── triage funnel ─────────────────────────────────────
|
||||
One strip, four segments, urgency decreasing left to right. A row of
|
||||
separate tiles counting bands reads as a census; this reads as a queue. */}
|
||||
<div className="gf-triage">
|
||||
{BUCKETS.map((b) => {
|
||||
const active = filter === b.key
|
||||
return (
|
||||
<button
|
||||
key={b.key}
|
||||
onClick={() => setFilter(active ? 'ALL' : b.key)}
|
||||
className={`gf-seg ${b.tone} ${active ? 'is-on' : ''}`}
|
||||
>
|
||||
<span className="gf-seg-label">{b.label}</span>
|
||||
<span className="gf-seg-val num">{counts[b.key]}</span>
|
||||
<span className="gf-seg-cap">{b.caption}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-start flex-col xl:flex-row">
|
||||
{/* ── enterprises by risk ─────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 bg-white border border-slate-200 rounded-[11px] overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b border-slate-200 flex items-center">
|
||||
<h3 className="text-[12.5px] font-bold text-forest">Enterprises by risk</h3>
|
||||
<span className="text-[10px] text-slate-400 ml-2">updated {summary.updatedMinutesAgo} minutes ago</span>
|
||||
<GhostBtn className="ml-auto" onClick={handleExport}>
|
||||
Export CSV
|
||||
</GhostBtn>
|
||||
<div className="gf-row2">
|
||||
{/* ── lead-time chart ───────────────────────────────── */}
|
||||
<section className="gf-card">
|
||||
<header className="gf-head">
|
||||
<h2>When the book breaches</h2>
|
||||
<span>Borrowers whose downside first misses the instalment, by month</span>
|
||||
</header>
|
||||
<div className="gf-bars">
|
||||
{horizon.map((n, i) => {
|
||||
const m = monthRef(new Date(new Date().getFullYear(), new Date().getMonth() + i + 1, 1))
|
||||
const inWindow = i >= 1 && i <= 3
|
||||
return (
|
||||
<div key={m.key} className="gf-bar">
|
||||
<span className="gf-bar-n num">{n || ''}</span>
|
||||
<span className="gf-bar-track">
|
||||
<span
|
||||
className={`gf-bar-fill ${i === 0 ? 'is-now' : inWindow ? 'is-window' : ''}`}
|
||||
style={{ height: n === 0 ? "3px" : `${Math.max(14, (n / maxH) * 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="gf-bar-x">{m.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="gf-note">
|
||||
<TrendingDown size={13} />
|
||||
The middle months are where a visit still changes the outcome. {counts.WINDOW} borrowers
|
||||
sit there now — none of them has missed a payment yet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="overflow-x-auto max-h-[560px] overflow-y-auto">
|
||||
<table className="ul-table">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr>
|
||||
<th>Enterprise</th>
|
||||
<th>Sector</th>
|
||||
<th>Block</th>
|
||||
<th className="text-right">Score</th>
|
||||
<th>Status</th>
|
||||
<th>6-month trend</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 40).map((e) => (
|
||||
<tr
|
||||
key={e.enterpriseId}
|
||||
onClick={() => setSelectedId(e.enterpriseId)}
|
||||
className={`cursor-pointer ${e.enterpriseId === selected?.enterpriseId ? '!bg-green-soft' : ''}`}
|
||||
>
|
||||
<td>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`w-6 h-6 rounded-full grid place-items-center text-[10px] font-bold ${
|
||||
e.band === 'RED' ? 'band-red' : e.band === 'WATCH' ? 'band-watch' : 'band-standard'
|
||||
}`}
|
||||
>
|
||||
{e.name[0]}
|
||||
</span>
|
||||
<span className="leading-tight">
|
||||
<span className="block font-semibold text-slate-700">{e.name}</span>
|
||||
<span className="block font-mono text-[10px] text-slate-400">{e.enterpriseId}</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-slate-600">{SECTORS[e.sector].short}</td>
|
||||
<td className="text-slate-600">{e.block}</td>
|
||||
<td
|
||||
className={`text-right font-bold ${
|
||||
e.band === 'RED' ? 'text-risk-red' : e.band === 'WATCH' ? 'text-amber' : 'text-green'
|
||||
}`}
|
||||
>
|
||||
{e.score}
|
||||
</td>
|
||||
<td>
|
||||
<RiskBadge band={e.band} />
|
||||
</td>
|
||||
<td>
|
||||
<Sparkline values={e.trend} band={e.band} />
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<button
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
navigate(`/officer/enterprises/${e.enterpriseId}`)
|
||||
}}
|
||||
className="text-slate-400 hover:text-forest px-1"
|
||||
aria-label={`Open ${e.name}`}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rows.length > 40 && (
|
||||
<div className="px-4 py-2 border-t border-slate-100 text-[10.5px] text-slate-400">
|
||||
Showing the 40 highest-risk of {rows.length} — the full register is under Enterprises.
|
||||
</div>
|
||||
)}
|
||||
{/* ── context ───────────────────────────────────────── */}
|
||||
<div className="gf-side">
|
||||
<FreshnessCard />
|
||||
<MarketCard />
|
||||
<EngineCard scored={rows.length} />
|
||||
</div>
|
||||
|
||||
{/* ── risk detail ─────────────────────────────────── */}
|
||||
{selected && <RiskDetail enterprise={selected} onSchedule={() => setScheduling(selected)} />}
|
||||
</div>
|
||||
|
||||
{/* ── the day's work ────────────────────────────────────── */}
|
||||
<section className="gf-card">
|
||||
<header className="gf-head">
|
||||
<h2>Today’s list</h2>
|
||||
<span>
|
||||
{filter === 'ALL'
|
||||
? 'Soonest breach first'
|
||||
: `Filtered · ${BUCKETS.find((b) => b.key === filter)?.label}`}
|
||||
{' · '}{worklist.length} of {rows.length}
|
||||
</span>
|
||||
<button
|
||||
className="gf-link"
|
||||
onClick={() =>
|
||||
exportCsv(
|
||||
`udyam-worklist-${block}-${month}.csv`,
|
||||
['Enterprise', 'Sector', 'Score', 'Band', 'Breaches', 'Top driver'],
|
||||
worklist.map((e) => [
|
||||
e.name, SECTORS[e.sector]?.label ?? e.sector, e.score, e.band,
|
||||
e.firstBreachMonth ?? 'never', e.topDriver ?? '',
|
||||
]),
|
||||
)
|
||||
}
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{worklist.length === 0 && <p className="gf-empty">Nothing outstanding in this filter.</p>}
|
||||
|
||||
<ul className="gf-list">
|
||||
{worklist.map((e) => {
|
||||
const h = e.firstBreachHorizon
|
||||
const when = e.firstBreachMonth
|
||||
? monthRef(new Date(`${e.firstBreachMonth}-01T00:00:00`)).fullEn
|
||||
: null
|
||||
return (
|
||||
<li key={e.enterpriseId} className="gf-item">
|
||||
<button
|
||||
className="gf-item-main"
|
||||
onClick={() => navigate(`/officer/enterprises/${e.enterpriseId}`)}
|
||||
>
|
||||
<span
|
||||
className={`gf-score ${
|
||||
e.band === 'RED' ? 'is-red' : e.band === 'WATCH' ? 'is-watch' : 'is-ok'
|
||||
}`}
|
||||
>
|
||||
<b className="num">{e.score}</b>
|
||||
</span>
|
||||
<span className="gf-item-body">
|
||||
<span className="gf-item-top">
|
||||
<b>{e.name}</b>
|
||||
<span className="gf-meta">
|
||||
{SECTORS[e.sector]?.short ?? e.sector} · {e.block}
|
||||
</span>
|
||||
{when ? (
|
||||
<span className={`gf-when ${h != null && h <= 1 ? 'is-now' : ''}`}>
|
||||
<CalendarClock size={12} />
|
||||
breaches {when}
|
||||
</span>
|
||||
) : (
|
||||
<span className="gf-when is-clear">downside covered</span>
|
||||
)}
|
||||
</span>
|
||||
{/* The reason the model gave, in the officer's own words. */}
|
||||
<span className="gf-why">
|
||||
{e.topDriver ?? 'No reason code stored for this run.'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button className="gf-act" onClick={() => setScheduling(e)}>
|
||||
Schedule visit
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{scheduling && <ScheduleVisitModal enterprise={scheduling} onClose={() => setScheduling(null)} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Right-hand panel: score dial, top drivers, the reason sentence and two actions. */
|
||||
function RiskDetail({ enterprise, onSchedule }: { enterprise: Enterprise; onSchedule: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const sub = subScoresFor(enterprise)
|
||||
const reason = reasonCodesFor(enterprise)[0]
|
||||
const points = forecastFor(enterprise)
|
||||
const breach = breachPoint(points)
|
||||
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Context cards — small, and each answers one question */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function FreshnessCard() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['freshness'],
|
||||
queryFn: () =>
|
||||
get<{ districtName: string; marketStalenessDays: number | null; climateStalenessDays: number | null }[]>(
|
||||
'/admin/ingestion/freshness',
|
||||
),
|
||||
})
|
||||
const rows = data ?? []
|
||||
const worst = (k: 'marketStalenessDays' | 'climateStalenessDays') => {
|
||||
const ages = rows.map((r) => r[k]).filter((n): n is number => n != null)
|
||||
return ages.length ? Math.max(...ages) : null
|
||||
}
|
||||
const mandi = worst('marketStalenessDays')
|
||||
const climate = worst('climateStalenessDays')
|
||||
|
||||
return (
|
||||
<div className="w-full xl:w-[320px] shrink-0 bg-white border border-slate-200 rounded-[11px] overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b border-slate-200">
|
||||
<h3 className="text-[12.5px] font-bold text-forest">Risk detail</h3>
|
||||
<div className="text-[10.5px] text-slate-500">
|
||||
{enterprise.name} · {enterprise.enterpriseId}
|
||||
<section className="gf-card gf-mini">
|
||||
<header className="gf-head">
|
||||
<h2><Database size={13} /> Data freshness</h2>
|
||||
</header>
|
||||
<dl className="gf-kv">
|
||||
<div>
|
||||
<dt>Mandi prices</dt>
|
||||
<dd className={mandi != null && mandi > 10 ? 'is-stale' : ''}>{mandi ?? '—'}d old</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="grid place-items-center mb-4">
|
||||
<ScoreDial score={enterprise.score} band={enterprise.band} size={116} />
|
||||
<div>
|
||||
<dt>Climate</dt>
|
||||
<dd className={climate != null && climate > 10 ? 'is-stale' : ''}>{climate ?? '—'}d old</dd>
|
||||
</div>
|
||||
<div><dt>Districts</dt><dd>{rows.length}</dd></div>
|
||||
</dl>
|
||||
<p className="gf-note gf-note-sm">A trip-wire refuses to fire on data older than its tolerance.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="font-mono text-[9.5px] tracking-[0.12em] text-slate-400 mb-2">TOP DRIVERS</div>
|
||||
<div className="space-y-2 mb-4">
|
||||
{DRIVER_META.map((d) => (
|
||||
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
|
||||
))}
|
||||
</div>
|
||||
function MarketCard() {
|
||||
const marketQ = useQuery({
|
||||
queryKey: ['market-signals'],
|
||||
queryFn: () => get<MarketSignal[]>('/admin/ingestion/signals/market', { weeks: 8 }),
|
||||
})
|
||||
const climateQ = useQuery({
|
||||
queryKey: ['climate-signals'],
|
||||
queryFn: () => get<ClimateSignal[]>('/admin/ingestion/signals/climate', { weeks: 8 }),
|
||||
})
|
||||
const market = marketQ.data ?? demoMarketSignals()
|
||||
const climate = climateQ.data ?? demoClimateSignals()
|
||||
|
||||
<div className="bg-risk-soft border border-risk-red/15 rounded-lg px-3 py-2.5 mb-4">
|
||||
<p className="text-[11.5px] text-slate-700 leading-relaxed">
|
||||
{reason.sentenceEn}
|
||||
{breachRef && ` Shortfall expected by ${breachRef.fullEn}.`}
|
||||
</p>
|
||||
{breach && (
|
||||
<p className="text-[10.5px] text-risk-red font-semibold mt-1.5">
|
||||
Projected gap {inr(breach.obligation - breach.p10)} at P10
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
const latest = new Map<string, MarketSignal>()
|
||||
for (const r of market) {
|
||||
const held = latest.get(r.commodityName)
|
||||
if (!held || r.weekStart > held.weekStart) latest.set(r.commodityName, r)
|
||||
}
|
||||
const movers = Array.from(latest.values())
|
||||
.map((m) => {
|
||||
const hist = market
|
||||
.filter((r) => r.commodityName === m.commodityName && r.weekStart < m.weekStart)
|
||||
.map((r) => r.modalPrice)
|
||||
.filter((p): p is number => p != null)
|
||||
.slice(0, 8)
|
||||
const price = m.modalPrice ?? 0
|
||||
const mean = hist.length ? hist.reduce((s, p) => s + p, 0) / hist.length : price
|
||||
return { name: m.commodityName, price, pct: mean ? ((price - mean) / mean) * 100 : 0 }
|
||||
})
|
||||
.sort((a, b) => Math.abs(b.pct) - Math.abs(a.pct))
|
||||
.slice(0, 2)
|
||||
|
||||
<div className="flex gap-2">
|
||||
<PrimaryBtn className="flex-1" onClick={onSchedule}>
|
||||
Schedule visit
|
||||
</PrimaryBtn>
|
||||
<GhostBtn className="flex-1" onClick={() => navigate(`/officer/enterprises/${enterprise.enterpriseId}`)}>
|
||||
Full forecast
|
||||
</GhostBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const c = climate.length ? climate.reduce((a, b) => (a.weekStart > b.weekStart ? a : b)) : null
|
||||
|
||||
return (
|
||||
<section className="gf-card gf-mini">
|
||||
<header className="gf-head">
|
||||
<h2><CloudRain size={13} /> Market & climate</h2>
|
||||
</header>
|
||||
<dl className="gf-kv">
|
||||
{c && (
|
||||
<div>
|
||||
<dt>Rain vs normal</dt>
|
||||
<dd className={Math.abs(c.rainfallAnomalyPct ?? 0) > 25 ? 'is-stale' : ''}>
|
||||
{(c.rainfallAnomalyPct ?? 0) > 0 ? '+' : ''}
|
||||
{Math.round(c.rainfallAnomalyPct ?? 0)}%
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{movers.map((m) => (
|
||||
<div key={m.name}>
|
||||
<dt>{m.name.toLowerCase()}</dt>
|
||||
<dd className={Math.abs(m.pct) > 12 ? 'is-stale' : ''}>
|
||||
₹{Math.round(m.price).toLocaleString('en-IN')}
|
||||
<small>{m.pct > 0 ? ' +' : ' '}{m.pct.toFixed(0)}%</small>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EngineCard({ scored }: { scored: number }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['model-metrics'],
|
||||
queryFn: () =>
|
||||
get<{ available: boolean; modelVersion?: string; arms?: Record<string, { baseline: boolean; auc_roc?: number }> }>(
|
||||
'/admin/model/metrics',
|
||||
),
|
||||
})
|
||||
const auc = data?.arms
|
||||
? Object.entries(data.arms)
|
||||
.filter(([, a]) => !a.baseline)
|
||||
.map(([, a]) => a.auc_roc ?? 0)
|
||||
.sort((a, b) => b - a)[0]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<section className="gf-card gf-mini">
|
||||
<header className="gf-head">
|
||||
<h2><Siren size={13} /> Scoring engine</h2>
|
||||
</header>
|
||||
<dl className="gf-kv">
|
||||
<div><dt>Engine</dt><dd className="is-live">{data?.modelVersion ?? 'model'}</dd></div>
|
||||
<div><dt>Scored</dt><dd>{scored}</dd></div>
|
||||
<div><dt>Measured AUC</dt><dd>{auc ? auc.toFixed(3) : '—'}</dd></div>
|
||||
</dl>
|
||||
<p className="gf-note gf-note-sm">Labels are generated history — calibration pending.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user