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 { 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 officer's morning screen, organised by **when trouble arrives**. * *

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 when — trouble * four months out, while intervention is still cheap — and nothing on that screen * expressed time at all. * *

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 /officer/enterprises; this screen * deliberately does not duplicate it. * *

The old layout is kept at /officer/classic — 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**. * *

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

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 [filter, setFilter] = useState('ALL') const [scheduling, setScheduling] = useState(null) const { data, isError, refetch } = useQuery({ queryKey: ['portfolio', block, month], queryFn: () => get('/officer/portfolio', { block, month }), }) // 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 counts = useMemo(() => { const c: Record = { NOW: 0, WINDOW: 0, LATER: 0, CLEAR: 0 } rows.forEach((e) => { c[bucketOf(e)] += 1 }) return c }, [rows]) // 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 void refetch()} /> const maxH = Math.max(1, ...horizon) return (

{/* ── 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. */}
{BUCKETS.map((b) => { const active = filter === b.key return ( ) })}
{/* ── lead-time chart ───────────────────────────────── */}

When the book breaches

Borrowers whose downside first misses the instalment, by month
{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 (
{n || ''} {m.label}
) })}

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.

{/* ── context ───────────────────────────────────────── */}
{/* ── the day's work ────────────────────────────────────── */}

Today’s list

{filter === 'ALL' ? 'Soonest breach first' : `Filtered · ${BUCKETS.find((b) => b.key === filter)?.label}`} {' · '}{worklist.length} of {rows.length}
{worklist.length === 0 &&

Nothing outstanding in this filter.

}
    {worklist.map((e) => { const h = e.firstBreachHorizon const when = e.firstBreachMonth ? monthRef(new Date(`${e.firstBreachMonth}-01T00:00:00`)).fullEn : null return (
  • ) })}
{scheduling && setScheduling(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 (

Data freshness

Mandi prices
10 ? 'is-stale' : ''}>{mandi ?? '—'}d old
Climate
10 ? 'is-stale' : ''}>{climate ?? '—'}d old
Districts
{rows.length}

A trip-wire refuses to fire on data older than its tolerance.

) } function MarketCard() { const marketQ = useQuery({ queryKey: ['market-signals'], queryFn: () => get('/admin/ingestion/signals/market', { weeks: 8 }), }) const climateQ = useQuery({ queryKey: ['climate-signals'], queryFn: () => get('/admin/ingestion/signals/climate', { weeks: 8 }), }) const market = marketQ.data ?? demoMarketSignals() const climate = climateQ.data ?? demoClimateSignals() const latest = new Map() 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) const c = climate.length ? climate.reduce((a, b) => (a.weekStart > b.weekStart ? a : b)) : null return (

Market & climate

{c && (
Rain vs normal
25 ? 'is-stale' : ''}> {(c.rainfallAnomalyPct ?? 0) > 0 ? '+' : ''} {Math.round(c.rainfallAnomalyPct ?? 0)}%
)} {movers.map((m) => (
{m.name.toLowerCase()}
12 ? 'is-stale' : ''}> ₹{Math.round(m.price).toLocaleString('en-IN')} {m.pct > 0 ? ' +' : ' '}{m.pct.toFixed(0)}%
))}
) } function EngineCard({ scored }: { scored: number }) { const { data } = useQuery({ queryKey: ['model-metrics'], queryFn: () => get<{ available: boolean; modelVersion?: string; arms?: Record }>( '/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 (

Scoring engine

Engine
{data?.modelVersion ?? 'model'}
Scored
{scored}
Measured AUC
{auc ? auc.toFixed(3) : '—'}

Labels are generated history — calibration pending.

) }