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:
LincPay
2026-08-25 13:45:48 +05:30
co-authored by Claude Opus 5
parent 13c628fa4b
commit 1ffc1cbd4c
10 changed files with 2046 additions and 268 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+19 -1
View File
@@ -1,6 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { LoginPage } from './pages/LoginPage'
import { DemoPage } from './pages/DemoPage'
import { enterpriseSession, officerSession, homeFor, isApexRole } from './lib/api'
// Field officer console
@@ -11,7 +12,9 @@ import { RegionalOfficesPage } from './features/nabard/RegionalOfficesPage'
import { BankScorecardPage } from './features/nabard/BankScorecardPage'
import { DigestPage } from './features/nabard/DigestPage'
import { InstitutionsPage } from './features/nabard/InstitutionsPage'
import { SourceCoveragePage } from './features/nabard/SourceCoveragePage'
import { OfficerDashboard } from './features/officer/OfficerDashboard'
import { OfficerDashboardClassic } from './features/officer/OfficerDashboardClassic'
import { EnterprisesPage } from './features/officer/EnterprisesPage'
import { SectorPortfolioPage } from './features/officer/SectorPortfolioPage'
import { EnterpriseDetailPage } from './features/officer/EnterpriseDetailPage'
@@ -19,6 +22,7 @@ import { AlertsPage } from './features/officer/AlertsPage'
import { ForecastsPage } from './features/officer/ForecastsPage'
import { VisitsPage } from './features/officer/VisitsPage'
import { RulesPage } from './features/officer/RulesPage'
import { DataSourcesPage } from './features/officer/DataSourcesPage'
import { DataFeedsPage } from './features/officer/DataFeedsPage'
import { DataEntryPage } from './features/officer/DataEntryPage'
import { IntegrationPage } from './features/officer/IntegrationPage'
@@ -81,6 +85,16 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route path="/" element={<RootRedirect />} />
{/* Both surfaces on one screen, for the jury round. Officer session
required — the borrower half signs itself in per selection. */}
<Route
path="/demo"
element={
<RequireOfficer>
<DemoPage />
</RequireOfficer>
}
/>
<Route path="/login" element={<LoginPage />} />
{/* Field officer console — portfolio, risk detail and recommended action */}
@@ -92,7 +106,9 @@ export default function App() {
</RequireOfficer>
}
>
<Route index element={<OfficerDashboard />} />
<Route index element={<OfficerDashboardClassic />} />
{/* Two readings of one book: by score, and by when trouble arrives. */}
<Route path="early-warning" element={<OfficerDashboard />} />
<Route path="portfolio" element={<SectorPortfolioPage />} />
<Route path="enterprises" element={<EnterprisesPage />} />
<Route path="enterprises/:enterpriseId" element={<EnterpriseDetailPage />} />
@@ -100,6 +116,7 @@ export default function App() {
<Route path="forecasts" element={<ForecastsPage />} />
<Route path="visits" element={<VisitsPage />} />
<Route path="rules" element={<RulesPage />} />
<Route path="sources" element={<DataSourcesPage />} />
<Route path="feeds" element={<DataFeedsPage />} />
<Route path="data-entry" element={<DataEntryPage />} />
<Route path="integration" element={<IntegrationPage />} />
@@ -121,6 +138,7 @@ export default function App() {
<Route path="banks" element={<BankScorecardPage />} />
<Route path="digest" element={<DigestPage />} />
<Route path="institutions" element={<InstitutionsPage />} />
<Route path="sources" element={<SourceCoveragePage />} />
</Route>
{/* Enterprise PWA — offline, Hindi-first */}
+91 -20
View File
@@ -8,7 +8,10 @@ import { BANDS } from '../../lib/demo/enterprises'
/* the bottom (RiskBadge / ScoreDial / Sparkline / DriverBar) are new. */
/* ------------------------------------------------------------------ */
export function StatusPill({ status }: { status: string }) {
export function StatusPill({ status }: { status: string | null | undefined }) {
// Defensive because this renders wire data on six screens. A row that arrives
// without a status should show as unknown; it should not take the page down.
if (!status) return <span className="pill pill-mut">—</span>
const s = status.toUpperCase()
const cls =
s.includes('DONE') || s.includes('CLOSED') || s.includes('SYNCED') || s.includes('ACTIVE')
@@ -37,11 +40,11 @@ export function Card({
className?: string
}) {
return (
<div className={`bg-white border border-slate-200 rounded-[11px] shadow-[0_1px_3px_rgba(0,0,0,0.05)] overflow-hidden ${className}`}>
<div className={`ul-card overflow-hidden ${className}`}>
{title && (
<div className="px-3.5 py-2.5 border-b border-slate-200 flex items-center gap-2">
<h3 className="text-[12.5px] font-bold text-forest">{title}</h3>
{badge && <span className="font-mono text-[9.5px] bg-green-soft text-forest px-1.5 py-0.5 rounded-lg">{badge}</span>}
<div className="px-4 py-3 border-b border-line flex items-center gap-2.5">
<h3 className="text-[13.5px] font-semibold text-ink tracking-[-0.012em]">{title}</h3>
{badge && <span className="font-mono text-[9.5px] bg-lp-blue-soft text-lp-blue px-1.5 py-0.5 rounded-md">{badge}</span>}
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
</div>
)}
@@ -83,7 +86,7 @@ export function Modal({
export function SubNav({ items }: { items: { label: string; to: string; end?: boolean }[] }) {
return (
<div className="flex gap-1 border-b border-slate-200 mb-4 overflow-x-auto">
<div className="flex gap-1 border-b border-line mb-4 overflow-x-auto">
{items.map((i) => (
<NavLink
key={i.to}
@@ -92,7 +95,7 @@ export function SubNav({ items }: { items: { label: string; to: string; end?: bo
className={({ isActive }) =>
[
'px-3.5 py-2 text-[11.5px] font-semibold whitespace-nowrap border-b-2 -mb-px',
isActive ? 'text-forest border-amber' : 'text-slate-500 border-transparent hover:text-forest',
isActive ? 'text-lp-blue border-lp-blue' : 'text-ink-3 border-transparent hover:text-ink',
].join(' ')
}
>
@@ -124,7 +127,7 @@ export function Field({
}
export const inputCls =
'w-full border border-slate-300 rounded-md px-2.5 py-1.5 text-[12.5px] outline-none focus:border-green focus:ring-2 focus:ring-green/15 bg-white'
'w-full border border-line-s rounded-[8px] px-2.5 py-1.5 text-[12.5px] outline-none focus:border-lp-blue focus:ring-2 focus:ring-lp-blue/15 bg-white'
export function PrimaryBtn({
children,
@@ -134,7 +137,7 @@ export function PrimaryBtn({
return (
<button
{...rest}
className={`bg-forest hover:bg-forest-mid disabled:opacity-50 disabled:cursor-not-allowed text-white text-[12px] font-semibold px-3.5 py-1.5 rounded-md transition-colors ${className}`}
className={`bg-lp-blue hover:bg-lp-blue-d disabled:opacity-50 disabled:cursor-not-allowed text-white text-[12.5px] font-semibold px-3.5 py-[7px] rounded-[8px] transition-colors ${className}`}
>
{children}
</button>
@@ -149,7 +152,7 @@ export function GhostBtn({
return (
<button
{...rest}
className={`border border-slate-300 hover:bg-slate-50 disabled:opacity-50 text-slate-700 text-[12px] font-semibold px-3.5 py-1.5 rounded-md transition-colors ${className}`}
className={`border border-line-s hover:bg-surface-3 disabled:opacity-50 text-ink-2 text-[12.5px] font-semibold px-3.5 py-[7px] rounded-[8px] transition-colors ${className}`}
>
{children}
</button>
@@ -166,7 +169,7 @@ export function ActionChip({
{...rest}
className={[
'text-[11px] font-semibold px-2.5 py-1 rounded-full border transition-colors',
active ? 'bg-forest text-white border-forest' : 'bg-white text-slate-600 border-slate-300 hover:border-green',
active ? 'bg-lp-blue text-white border-lp-blue' : 'bg-white text-ink-2 border-line-s hover:border-lp-blue',
].join(' ')}
>
{children}
@@ -176,7 +179,7 @@ export function ActionChip({
/** Empty/loading state used wherever a list can come back with nothing. */
export function Empty({ text }: { text: string }) {
return <div className="py-10 text-center text-[12px] text-slate-400">{text}</div>
return <div className="py-10 text-center text-[12px] text-ink-3">{text}</div>
}
// ── UdyamLinc risk primitives ───────────────────────────────────
@@ -194,7 +197,7 @@ export function ScoreDial({ score, band, size = 96 }: { score: number; band: Ban
const stroke = size * 0.11
const r = (size - stroke) / 2
const circ = 2 * Math.PI * r
const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e8a020' : '#1e7a46'
const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e08019' : '#28ae64'
return (
<div className="relative grid place-items-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90" role="img" aria-label={`Stress score ${score} of 100`}>
@@ -238,7 +241,7 @@ export function Sparkline({
const min = Math.min(...values)
const max = Math.max(...values)
const span = Math.max(1, max - min)
const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e8a020' : '#1e7a46'
const colour = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#e08019' : '#28ae64'
const pts = values
.map((v, i) => {
const x = (i / (values.length - 1)) * (width - 4) + 2
@@ -285,16 +288,16 @@ export function KpiTile({
tone?: 'neutral' | 'red' | 'watch' | 'good'
}) {
const dot =
tone === 'red' ? 'bg-risk-red' : tone === 'watch' ? 'bg-amber' : tone === 'good' ? 'bg-green' : 'bg-forest'
const deltaCls = tone === 'red' ? 'text-risk-red' : tone === 'watch' ? 'text-amber' : 'text-slate-400'
tone === 'red' ? 'bg-risk-red' : tone === 'watch' ? 'bg-amber' : tone === 'good' ? 'bg-green' : 'bg-lp-blue'
const deltaCls = tone === 'red' ? 'text-risk-red' : tone === 'watch' ? 'text-amber' : 'text-ink-3'
return (
<div className="ul-card px-3.5 py-3 flex-1 min-w-[150px]">
<div className="flex items-start justify-between">
<span className="text-[10.5px] text-slate-500">{label}</span>
<span className="text-[10px] uppercase tracking-[0.07em] text-ink-3 font-bold">{label}</span>
<span className={`w-2.5 h-2.5 rounded-[3px] ${dot}`} />
</div>
<div className="text-[26px] font-bold text-forest leading-tight mt-1">{value}</div>
{delta && <div className={`text-[10.5px] ${deltaCls}`}>{delta}</div>}
<div className="text-[28px] font-bold text-ink leading-none mt-1.5 tracking-[-0.03em] num">{value}</div>
{delta && <div className={`text-[11.5px] mt-1.5 ${deltaCls}`}>{delta}</div>}
</div>
)
}
@@ -303,8 +306,76 @@ export function KpiTile({
export function UdyamMark({ size = 26, onDark = false }: { size?: number; onDark?: boolean }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" aria-label="UdyamLinc" role="img">
<path d="M32 3 58 17.5v29L32 61 6 46.5v-29z" fill={onDark ? '#3fb871' : '#0b5d3b'} />
<path d="M32 3 58 17.5v29L32 61 6 46.5v-29z" fill={onDark ? '#4cc582' : '#0b4a37'} />
<path d="M34.5 15 20 36h10l-2.5 13L44 27H33.5z" fill={onDark ? '#f2b544' : '#e8a020'} />
</svg>
)
}
/**
* The design system's `.alert` box — a tinted panel with a leading glyph, a bold
* first line and a plain second.
*
* <p>Four tones and no more. `high` and `watch` map to the risk scale so a box
* cannot be tinted a severity the band does not carry; `info` is brand blue for
* a statement of fact; `good` is the only one that says nothing is wrong.
*/
export function AlertBox({
tone,
glyph,
title,
children,
}: {
tone: 'high' | 'watch' | 'info' | 'good'
glyph: React.ReactNode
title: string
children?: React.ReactNode
}) {
const cls = {
high: 'bg-risk-soft border-risk-red/35 text-[#9c2b1f]',
watch: 'bg-amber-soft border-amber/40 text-[#8a4e05]',
info: 'bg-lp-blue-soft border-lp-blue/30 text-lp-blue',
good: 'bg-green-soft border-green/30 text-[#186b3d]',
}[tone]
return (
<div className={`flex gap-2.5 px-3.5 py-2.5 rounded-[8px] border text-[12px] leading-[1.5] ${cls}`}>
<span className="shrink-0 mt-px">{glyph}</span>
<div className="min-w-0">
<b className="block mb-0.5">{title}</b>
{children && <span className="text-ink-2 block">{children}</span>}
</div>
</div>
)
}
/**
* What a screen shows instead of borrower rows it cannot verify.
*
* <p>Deliberately not an error toast. The console's other failure states fall
* back to demo data, which is right for a price series and wrong for a
* portfolio: the demo borrowers have no owner, so substituting them shows one
* bank another bank's book. This panel is the substitute — it explains why the
* screen is empty rather than letting an empty screen read as "no alerts".
*/
export function Unavailable({
what,
onRetry,
}: {
what: string
onRetry?: () => void
}) {
return (
<div className="ul-card p-8 text-center">
<p className="text-[13px] font-semibold text-ink">{what} unavailable</p>
<p className="text-[11.5px] text-ink-3 mt-1 max-w-[48ch] mx-auto leading-snug">
The backend is not reachable. Borrower data is scoped to your own institution and is never
reconstructed locally, so nothing is shown rather than something that might not be yours.
</p>
{onRetry && (
<GhostBtn className="mt-3" onClick={onRetry}>
Try again
</GhostBtn>
)}
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { NavLink } from 'react-router-dom'
/**
* Two ways to read the same book.
*
* <p><b>Portfolio overview</b> ranks by composite score — the register an officer
* already knows how to read, and the view that has been demonstrated.
* <b>Early warning</b> ranks by when the downside first stops covering the
* instalment, which is the thing this platform can show that a bureau score
* cannot.
*
* <p>They are tabs rather than one merged screen because they answer different
* questions and sort by different keys. Merging them would mean picking one sort
* order and losing the other.
*/
const TABS = [
{ to: '/officer', label: 'Portfolio overview', end: true },
{ to: '/officer/early-warning', label: 'Early warning', end: false },
]
export function DashboardTabs() {
return (
<div className="flex gap-1 border-b border-line mb-4">
{TABS.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) =>
[
'px-3.5 py-2 text-[12px] font-semibold whitespace-nowrap border-b-2 -mb-px transition-colors',
isActive
? 'text-lp-blue border-lp-blue'
: 'text-ink-3 border-transparent hover:text-ink',
].join(' ')
}
>
{t.label}
</NavLink>
))}
</div>
)
}
+348 -205
View File
@@ -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"
/>
</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>
<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">
<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
onClick={(ev) => {
ev.stopPropagation()
navigate(`/officer/enterprises/${e.enterpriseId}`)
}}
className="text-slate-400 hover:text-forest px-1"
aria-label={`Open ${e.name}`}
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>
</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 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>
{/* ── context ───────────────────────────────────────── */}
<div className="gf-side">
<FreshnessCard />
<MarketCard />
<EngineCard scored={rows.length} />
</div>
</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>
)}
</div>
{/* ── risk detail ─────────────────────────────────── */}
{selected && <RiskDetail enterprise={selected} onSchedule={() => setScheduling(selected)} />}
</div>
</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}
</div>
</div>
<div className="p-4">
<div className="grid place-items-center mb-4">
<ScoreDial score={enterprise.score} band={enterprise.band} size={116} />
</div>
<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>
<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>
<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>
<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>
<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>
)
}
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()
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)
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 &amp; 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>
)
}
@@ -0,0 +1,519 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { AlertTriangle, Clock, Info } from 'lucide-react'
import { enterpriseDetail, get } from '../../lib/api'
import type { ClimateSignal, Enterprise, MarketSignal, PortfolioSummary } from '../../lib/api'
import {
AlertBox,
Card,
DriverBar,
Empty,
GhostBtn,
KpiTile,
PrimaryBtn,
RiskBadge,
ScoreDial,
Sparkline,
Unavailable,
} from '../../components/ui/bits'
import {
DRIVER_META,
SECTORS,
breachPoint,
forecastFor,
} from '../../lib/demo/enterprises'
import { useTenantAlerts, useTenantVisits } from './useTenantData'
import type { ReasonCodeRow, StressScoreRow } from '../../lib/api'
import { demoClimateSignals, demoMarketSignals } from '../../lib/demo/feeds'
import { inr, 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.
*/
export function OfficerDashboardClassic() {
const navigate = useNavigate()
const { block, month } = useScope()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [scheduling, setScheduling] = useState<Enterprise | null>(null)
const { data, isError, refetch } = useQuery({
queryKey: ['portfolio', block, month],
queryFn: () => get<PortfolioSummary>('/officer/portfolio', { block, month }),
})
// No demo fallback. The demo portfolio has no owner on it, so substituting it
// here would show this bank every other bank's borrowers.
const summary = data
// Memoised on the query result rather than on a fresh array literal, so the
// selection below does not recompute on every render.
const rows = useMemo(() => summary?.rows ?? [], [summary])
const selected = useMemo(
() => rows.find((e) => e.enterpriseId === selectedId) ?? rows[0],
[rows, selectedId],
)
const detailQ = useQuery({
queryKey: ["enterprise-detail", selected?.enterpriseId],
queryFn: () => enterpriseDetail(selected!.enterpriseId),
enabled: Boolean(selected?.enterpriseId),
})
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.sector,
e.block,
e.score,
e.band,
e.loanOutstanding,
e.monthlyInstalment,
]),
)
}
if (isError || !summary) {
return <Unavailable what="Portfolio" onRetry={() => void refetch()} />
}
return (
<div>
<DashboardTabs />
{/* ── 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"
/>
</div>
<div className="grid gap-3.5 items-start grid-cols-1 xl:grid-cols-[1.75fr_1fr]">
{/* ── enterprises by risk ─────────────────────────── */}
<div className="min-w-0 ul-card overflow-hidden">
<div className="px-4 py-3 border-b border-line flex items-center gap-2.5">
<h3 className="text-[13.5px] font-semibold text-ink tracking-[-0.012em]">Enterprises by risk</h3>
<span className="text-[11.5px] text-ink-3">
Ranked by Composite Stress Score · updated {summary.updatedMinutesAgo} min ago
</span>
<GhostBtn className="ml-auto !py-1 !px-2.5 !text-[11.5px]" onClick={handleExport}>
Export CSV
</GhostBtn>
</div>
<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 ? 'sel' : ''}`}
>
<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-ink">{e.name}</span>
<span className="block font-mono text-[10px] text-ink-3">{e.enterpriseId}</span>
</span>
</div>
</td>
<td className="text-ink-2">{SECTORS[e.sector]?.short ?? e.sector}</td>
<td className="text-ink-2">{e.block}</td>
<td
className={`text-right font-bold num ${
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-ink-3 hover:text-lp-blue 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-line text-[10.5px] text-ink-3">
Showing the 40 highest-risk of {rows.length} — the full register is under Portfolio → Enterprises.
</div>
)}
</div>
{/* ── risk detail ─────────────────────────────────── */}
{selected && (
<RiskDetail
enterprise={selected}
score={detailQ.data?.score ?? null}
reasons={detailQ.data?.reasons ?? []}
onSchedule={() => setScheduling(selected)}
/>
)}
</div>
{/* ── what happened, what is coming, what to do ─────── */}
<div className="grid gap-3.5 mt-3.5 grid-cols-1 lg:grid-cols-3">
<TripWiresFired block={block} />
<ClimateMarketOverlay />
<YourWeek block={block} />
</div>
{scheduling && <ScheduleVisitModal enterprise={scheduling} onClose={() => setScheduling(null)} />}
</div>
)
}
/* ------------------------------------------------------------------ */
/* The three panels under the register */
/* ------------------------------------------------------------------ */
/**
* What crossed a line today, as opposed to what merely scores badly.
*
* <p>Kept beside the score register rather than folded into it because they
* answer different questions. A score says how stressed an enterprise is; a
* fired trip-wire says a specific agreed threshold was crossed, and an officer
* can be asked to justify acting on the second in a way the first does not
* support.
*/
function TripWiresFired({ block }: { block: string }) {
const navigate = useNavigate()
const { rows, unavailable } = useTenantAlerts(block)
const open = rows.filter((a) => a.state === "OPEN").slice(0, 3)
return (
<Card
title="Trip-wires fired today"
actions={<span className="text-[11.5px] text-ink-3">Rule-based, independent of the model</span>}
>
<div className="p-4 grid gap-2.5">
{unavailable && <Empty text="Alert queue unavailable — backend not reachable." />}
{!unavailable && open.length === 0 && <Empty text="No trip-wire fired in this scope today." />}
{open.map((a) => (
<button key={a.alertId} onClick={() => navigate('/officer/alerts')} className="text-left">
<AlertBox
tone={a.band === 'RED' ? 'high' : 'watch'}
glyph={a.band === 'RED' ? <AlertTriangle size={14} /> : <Clock size={14} />}
title={a.title}
>
{a.enterpriseName} — {a.reason.sentenceEn}
</AlertBox>
</button>
))}
</div>
</Card>
)
}
/**
* The outside world, in four lines.
*
* <p>Reads the same ingestion endpoints the feeds screen does, so a number here
* and a number there cannot disagree. Staleness is deliberately on the face of
* it: a mandi price is only worth reading next to how old it is.
*/
function ClimateMarketOverlay() {
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()
// Newest row per commodity, then the two that moved most — a panel of four
// lines should carry the two worth acting on, not the two alphabetically first.
const latestByCommodity = new Map<string, MarketSignal>()
for (const row of market) {
const held = latestByCommodity.get(row.commodityName)
if (!held || row.weekStart > held.weekStart) latestByCommodity.set(row.commodityName, row)
}
const movers = Array.from(latestByCommodity.values())
.map((m) => {
// A week with no arrivals stores a null price rather than a zero, so it has
// to drop out of the baseline entirely — averaging it in as zero would read
// as a crash in a commodity that simply did not trade.
const history = 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 = history.length ? history.reduce((s, p) => s + p, 0) / history.length : price
return { row: m, price, changePct: mean ? ((price - mean) / mean) * 100 : 0 }
})
.sort((a, b) => Math.abs(b.changePct) - Math.abs(a.changePct))
.slice(0, 2)
const latestClimate = climate.length
? climate.reduce((a, b) => (a.weekStart > b.weekStart ? a : b))
: null
const rows: { name: string; sub: string; value: string; tone: 'high' | 'watch' | 'std' }[] = []
if (latestClimate) {
const rain = latestClimate.rainfallAnomalyPct ?? 0
rows.push({
name: 'Rainfall anomaly',
sub: `${latestClimate.districtName} · vs 30-year normal`,
value: `${rain > 0 ? '+' : ''}${Math.round(rain)}%`,
tone: Math.abs(rain) > 25 ? 'watch' : 'std',
})
const heat = latestClimate.heatAnomalyC ?? 0
rows.push({
name: 'Heat anomaly',
sub: heat > 1.5 ? 'Poultry mortality risk' : 'Within normal range',
value: `${heat > 0 ? '+' : ''}${heat.toFixed(1)}°C`,
tone: heat > 1.5 ? 'watch' : 'std',
})
}
for (const m of movers) {
rows.push({
name: `Mandi — ${m.row.commodityName.toLowerCase()}`,
sub: `${m.row.districtName} · vs 8-week mean`,
value: `₹${Math.round(m.price).toLocaleString('en-IN')}`,
tone: Math.abs(m.changePct) > 12 ? 'high' : Math.abs(m.changePct) > 5 ? 'watch' : 'std',
})
}
return (
<Card
title="Climate & market overlay"
actions={<span className="text-[11.5px] text-ink-3">The signals behind the shock family</span>}
>
<div className="p-4 grid gap-3">
{rows.length === 0 && <Empty text="No signal ingested yet — run ingestion." />}
{rows.map((r) => (
<div key={r.name} className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-[12px] font-semibold text-ink">{r.name}</div>
<div className="text-[11px] text-ink-3 truncate">{r.sub}</div>
</div>
<span
className={`pill ${r.tone === 'high' ? 'pill-bad' : r.tone === 'watch' ? 'pill-pend' : 'pill-ok'}`}
>
{r.value}
</span>
</div>
))}
</div>
</Card>
)
}
/**
* The playbook, ranked — the screen's only instruction to a person.
*
* <p>Everything above it describes a state. This says what to do about it, in
* priority order, which is the difference between a dashboard an officer reads
* and one an officer works from.
*/
function YourWeek({ block }: { block: string }) {
const navigate = useNavigate()
const { rows, unavailable } = useTenantVisits(block)
const visits = rows
.filter((v) => v.state !== "DONE")
.sort((a, b) => a.priority - b.priority || a.dueDate.localeCompare(b.dueDate))
.slice(0, 4)
return (
<Card
title="Your week"
actions={<span className="text-[11.5px] text-ink-3">Playbook, ranked by expected impact</span>}
>
<div className="p-4 grid gap-3">
{unavailable && <Empty text="Visit list unavailable — backend not reachable." />}
{!unavailable && visits.length === 0 && <Empty text="Nothing outstanding in this scope." />}
{visits.map((v, i) => (
<button
key={v.visitId}
onClick={() => navigate('/officer/visits')}
className="flex items-start gap-2.5 text-left group"
>
<span
className={`pill ${v.priority === 1 ? 'pill-bad' : 'pill-pend'} !px-0 w-[22px] justify-center shrink-0`}
>
{i + 1}
</span>
<span className="min-w-0">
<span className="block text-[12px] font-semibold text-ink group-hover:text-lp-blue">
{v.enterpriseName}
</span>
<span className="block text-[11px] text-ink-3 truncate">
{v.action} · {v.state === 'OVERDUE' ? 'Overdue' : `Due ${v.dueDate}`}
</span>
</span>
</button>
))}
</div>
</Card>
)
}
/** Right-hand panel: score dial, top drivers, the reason sentence and two actions. */
function RiskDetail({
enterprise,
score,
reasons,
onSchedule,
}: {
enterprise: Enterprise
score: StressScoreRow | null
reasons: ReasonCodeRow[]
onSchedule: () => void
}) {
const navigate = useNavigate()
// The five families as the scoring run stored them. This panel used to call
// subScoresFor() — a JavaScript reimplementation of the old rule weights — so
// the bars on the console's home screen were a browser-side approximation of
// an engine that is no longer the one scoring, while the model's SHAP values
// sat unread in reason_code.
const sub = score
? {
liquidity: score.liquidity ?? 0,
behavioural: score.behavioural ?? 0,
trendVolatility: score.trendVolatility ?? 0,
obligationLoad: score.obligationLoad ?? 0,
externalShock: score.externalShock ?? 0,
}
: null
const reason = reasons[0]
const isModel = score?.engine === 'MODEL'
const points = forecastFor(enterprise)
const breach = breachPoint(points)
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
return (
<div className="min-w-0 ul-card overflow-hidden">
<div className="px-4 py-3 border-b border-line flex items-center gap-2.5">
<h3 className="text-[13.5px] font-semibold text-ink tracking-[-0.012em]">Risk detail</h3>
<span className="text-[11.5px] text-ink-3 truncate">
{enterprise.name} · {enterprise.enterpriseId}
</span>
</div>
<div className="p-4">
<div className="grid place-items-center">
<ScoreDial score={score?.score ?? enterprise.score} band={(score?.band ?? enterprise.band) as never} size={124} />
</div>
{/* Band, sector and block as one chip row — the three facts that decide
whether this score is even the officer's to act on. */}
<div className="flex items-center justify-center gap-1.5 flex-wrap mt-3 mb-4">
<RiskBadge band={enterprise.band} />
<span className="pill pill-mut">{SECTORS[enterprise.sector].short}</span>
<span className="pill pill-mut">{enterprise.block}</span>
</div>
<div className="text-[10px] uppercase tracking-[0.07em] text-ink-3 font-bold mb-2.5">
Top drivers
<span className="font-normal normal-case tracking-normal text-ink-3">
{" "}
· {isModel ? "SHAP contribution" : "weighted share"}
</span>
</div>
<div className="space-y-2 mb-4">
{!sub && <Empty text="Not scored yet." />}
{sub &&
DRIVER_META.map((d) => (
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
))}
</div>
<AlertBox
tone={enterprise.band === 'RED' ? 'high' : enterprise.band === 'WATCH' ? 'watch' : 'info'}
glyph={<Info size={14} />}
title="Reason code"
>
{reason?.sentenceEn ?? "No reason code stored for this run."}
{breachRef && ` Shortfall expected by ${breachRef.fullEn}.`}
{breach && (
<b className="block mt-1 text-risk-red">
Projected gap {inr(breach.obligation - breach.p10)} at P10
</b>
)}
</AlertBox>
<div className="flex gap-2 mt-4">
<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>
)
}
+252 -36
View File
@@ -1,58 +1,115 @@
@import "tailwindcss";
/*
* UdyamLinc design tokens
* Layout language: BharatLinc / CBSNexora (LincPay console family)
* Brand palette: UdyamLinc deck — forest #0B5D3B, green #1E7A46, amber #E8A020
* UdyamLinc design tokens — LincPay product design system
* Source of truth: udyamlincdesign.html (brand system view).
*
* The palette is sampled from the LincPay logo: blue #2a71b8 carries interaction
* (active nav, primary action, focus), green #28ae64 carries health and success,
* and brand-deep #0b4a37 carries chrome. Risk keeps its own three-colour scale,
* separate from brand, because a band must never be read as a brand accent.
*
* Existing screens address these through semantic Tailwind names — `text-forest`,
* `bg-green-soft`, `band-watch` — so the whole console re-skins from this block
* rather than from twenty files.
*/
@theme {
--color-forest-deep: #06301f;
--color-forest: #0b5d3b;
--color-forest-mid: #0f6f47;
--color-green: #1e7a46;
--color-green-hi: #3fb871;
--color-green-soft: #eaf5ef;
--color-amber: #e8a020;
--color-amber-hi: #f2b544;
--color-amber-soft: #fdf4e3;
--color-risk-red: #c0392b;
--color-risk-soft: #fdeceb;
--color-ink: #17321f;
--color-tagline: #6b8477;
/* brand chrome */
--color-forest-deep: #072e22;
--color-forest: #0b4a37;
--color-forest-mid: #0e5c44;
--font-sans: "Segoe UI", Roboto, -apple-system, "Helvetica Neue", Arial, sans-serif;
/* interaction — LincPay blue */
--color-lp-blue: #2a71b8;
--color-lp-blue-d: #215a94;
--color-lp-blue-l: #4a8dd0;
--color-lp-blue-soft: #e9f1fa;
/* health — LincPay green */
--color-green: #28ae64;
--color-green-d: #1f8b50;
--color-green-hi: #4cc582;
--color-green-soft: #e7f6ee;
/* watch */
--color-amber: #e08019;
--color-amber-hi: #f0ae5c;
--color-amber-soft: #fdf3e6;
/* risk */
--color-risk-red: #c0392b;
--color-risk-soft: #fbecea;
/* ink and line */
--color-ink: #0d1418;
--color-ink-2: #4d5a63;
--color-ink-3: #87939c;
--color-line: #e2e7ec;
--color-line-s: #c8d1d9;
--color-page: #f4f6f8;
--color-surface-2: #fafbfc;
--color-surface-3: #eff2f5;
--color-tagline: #87939c;
--radius-card: 12px;
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: "Cascadia Mono", ui-monospace, "Roboto Mono", Consolas, monospace;
}
body {
@apply bg-[#f4f7f5] text-slate-900 font-sans text-[13px] antialiased;
:root {
/* Shadows are three fixed steps in the design; keep them as raw vars so a
component can reach for one without inventing its own blur. */
--sh: 0 1px 2px rgba(13, 20, 24, 0.06), 0 1px 3px rgba(13, 20, 24, 0.04);
--sh2: 0 4px 14px rgba(13, 20, 24, 0.09);
--sh3: 0 14px 36px rgba(13, 20, 24, 0.16);
}
/* Modern data table — same rhythm as BharatLinc's .bl-table */
body {
@apply bg-page text-ink font-sans text-[13px] antialiased;
}
/* Tabular figures wherever a number is compared down a column. */
.num,
.mn {
font-variant-numeric: tabular-nums;
}
/* Data table */
.ul-table { @apply w-full border-collapse text-[12.5px]; }
.ul-table thead th {
@apply bg-slate-50 text-[9.5px] uppercase tracking-[0.06em] text-slate-500 text-left px-3.5 py-2 border-b border-slate-200 whitespace-nowrap font-semibold;
@apply bg-surface-2 text-[10px] uppercase tracking-[0.07em] text-ink-3 text-left px-4 py-2 border-b border-line whitespace-nowrap font-bold;
}
.ul-table tbody td { @apply px-3.5 py-2 border-b border-slate-100 whitespace-nowrap; }
.ul-table tbody tr:nth-child(even) { @apply bg-[#fafbfa]; }
.ul-table tbody tr:hover { @apply bg-[#f0f8f3]; }
.ul-table tbody td { @apply px-4 py-2.5 border-b border-line whitespace-nowrap align-middle; }
.ul-table tbody tr:last-child td { @apply border-b-0; }
.ul-table tbody tr:hover { @apply bg-surface-2; }
.ul-table tbody tr.sel { @apply bg-lp-blue-soft; }
/* status pill with leading dot */
.pill { @apply inline-flex items-center gap-1.5 text-[10.5px] font-semibold px-2.5 py-0.5 rounded-[10px]; }
.pill::before { content: ""; @apply w-1.5 h-1.5 rounded-full bg-current; }
.pill-ok { @apply bg-emerald-50 text-emerald-600; }
.pill-pend { @apply bg-amber-50 text-amber-600; }
.pill-info { @apply bg-blue-50 text-blue-600; }
.pill-bad { @apply bg-red-50 text-red-600; }
.pill-mut { @apply bg-slate-100 text-slate-500; }
.pill { @apply inline-flex items-center gap-1.5 text-[11px] font-bold px-2 py-0.5 rounded-[20px] leading-[1.6]; }
.pill::before { content: ""; @apply w-[5px] h-[5px] rounded-full bg-current shrink-0; }
.pill-ok { @apply bg-green-soft text-[#186b3d]; }
.pill-pend { @apply bg-amber-soft text-[#8a4e05]; }
.pill-info { @apply bg-lp-blue-soft text-lp-blue; }
.pill-bad { @apply bg-risk-soft text-[#9c2b1f]; }
.pill-mut { @apply bg-surface-3 text-ink-2; }
/* Risk bands — the three states every screen speaks in */
.band-red { @apply bg-risk-soft text-risk-red; }
.band-watch { @apply bg-amber-soft text-amber; }
.band-standard { @apply bg-green-soft text-green; }
.band-red { @apply bg-risk-soft text-[#9c2b1f]; }
.band-watch { @apply bg-amber-soft text-[#8a4e05]; }
.band-standard { @apply bg-green-soft text-[#186b3d]; }
/* Card chrome shared by console + PWA */
.ul-card { @apply bg-white border border-slate-200 rounded-lg; }
.ul-card {
@apply bg-white border border-line rounded-card;
box-shadow: var(--sh);
}
/* Header dropdown panel — the officer console's grouped nav */
.ul-menu {
@apply bg-white border border-line rounded-card overflow-hidden;
box-shadow: var(--sh3);
}
/* Enterprise PWA: bottom tab bar sits above the home indicator */
.ul-safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
@@ -61,6 +118,165 @@ body {
.ul-blink { animation: ul-blink 1.2s infinite; }
@keyframes ul-roll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
.ul-tape { animation: ul-roll 28s linear infinite; }
@keyframes ul-fade { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; } }
.ul-pane { animation: ul-fade 0.18s ease; }
@media (prefers-reduced-motion: reduce) {
.ul-tape, .ul-blink { animation: none; }
.ul-tape, .ul-blink, .ul-pane { animation: none; }
}
/* ==================================================================
* Officer dashboard
*
* Scoped under `.gf`. The layout is the change here, not the palette:
* the screen is organised by *when* a borrower's downside stops covering
* the instalment, because lead time is the thing this platform can show
* that a bureau score cannot.
*
* It stays light. Two dark treatments were tried and both read as a
* different application dropped inside twenty light screens — a dashboard
* is not a place to spend that inconsistency.
*
* Severity keeps the three meanings it carries everywhere else. The
* accent stays LincPay blue.
* ================================================================== */
.gf { display: flex; flex-direction: column; gap: 14px; }
.gf .num { font-variant-numeric: tabular-nums; }
/* ── 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. */
.gf-triage {
display: grid; gap: 1px;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
background: var(--color-line);
border: 1px solid var(--color-line);
border-radius: var(--radius-card); overflow: hidden;
box-shadow: var(--sh);
}
.gf-seg {
background: #fff; padding: 15px 17px 14px; text-align: left; position: relative;
transition: background .18s; cursor: pointer; border: none; color: inherit; font: inherit;
}
.gf-seg::after {
content: ""; position: absolute; inset: auto 0 0 0; height: 3px;
background: var(--seg, var(--color-ink-3));
}
.gf-seg:hover { background: var(--color-surface-2); }
.gf-seg.is-on { background: var(--seg-soft, var(--color-surface-3)); }
.gf-seg.is-on::after { height: 5px; }
.gf-now { --seg: var(--color-risk-red); --seg-soft: var(--color-risk-soft); }
.gf-window { --seg: var(--color-amber); --seg-soft: var(--color-amber-soft); }
.gf-watch { --seg: var(--color-lp-blue); --seg-soft: var(--color-lp-blue-soft); }
.gf-clear { --seg: var(--color-green); --seg-soft: var(--color-green-soft); }
.gf-seg-label {
display: block; font-size: 10px; letter-spacing: .09em; text-transform: uppercase;
color: var(--color-ink-3); font-weight: 700;
}
.gf-seg-val {
display: block; font-size: 32px; font-weight: 700; line-height: 1.05;
letter-spacing: -.03em; margin: 5px 0 2px; color: var(--seg);
font-variant-numeric: tabular-nums;
}
.gf-seg-cap { display: block; font-size: 11.5px; color: var(--color-ink-3); }
/* ── cards ─────────────────────────────────────────────────────── */
.gf-card {
background: #fff; border: 1px solid var(--color-line);
border-radius: var(--radius-card); padding: 16px 18px; box-shadow: var(--sh);
}
.gf-row2 { display: grid; gap: 14px; grid-template-columns: 1.7fr 1fr; align-items: stretch; }
/* The chart card fills the row rather than leaving a well beneath it: the side
column stacks three cards and is always the taller of the two. */
.gf-row2 > .gf-card { display: flex; flex-direction: column; }
.gf-side { display: grid; gap: 14px; align-content: start; }
.gf-mini { padding: 14px 16px; }
@media (max-width: 1000px) { .gf-row2 { grid-template-columns: 1fr; } }
.gf-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 13px; }
.gf-head h2 {
font-size: 13.5px; font-weight: 600; letter-spacing: -.012em; margin: 0;
display: flex; align-items: center; gap: 7px; color: var(--color-ink);
}
.gf-head span { font-size: 11.5px; color: var(--color-ink-3); }
.gf-link {
margin-left: auto; font-size: 11.5px; font-weight: 600; color: var(--color-lp-blue);
background: none; border: none; cursor: pointer; padding: 0;
}
.gf-link:hover { text-decoration: underline; }
/* ── lead-time bars ────────────────────────────────────────────── */
.gf-bars { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; flex: 1; min-height: 160px; }
.gf-bar { display: flex; flex-direction: column; align-items: center; gap: 5px; min-height: 0; }
.gf-bar-n { font-size: 12.5px; font-weight: 700; color: var(--color-ink-2); height: 15px; }
.gf-bar-track {
flex: 1; width: 100%; display: flex; align-items: flex-end;
background: var(--color-surface-3); border-radius: 6px; overflow: hidden;
}
.gf-bar-fill { width: 100%; border-radius: 6px; background: var(--color-line-s); transition: height 1s cubic-bezier(.2,.8,.2,1); }
.gf-bar-fill.is-now { background: var(--color-risk-red); }
.gf-bar-fill.is-window { background: var(--color-amber); }
.gf-bar-x { font-size: 10.5px; color: var(--color-ink-3); }
.gf-note {
display: flex; gap: 7px; align-items: flex-start; margin: 13px 0 0;
font-size: 11.5px; color: var(--color-ink-3); line-height: 1.5; max-width: 64ch;
}
.gf-note-sm { margin-top: 10px; font-size: 10.5px; }
/* ── key/value mini cards ──────────────────────────────────────── */
.gf-kv { display: grid; gap: 9px; margin: 0; }
.gf-kv > div { display: flex; align-items: baseline; gap: 10px; }
.gf-kv dt { font-size: 11.5px; color: var(--color-ink-3); }
.gf-kv dd {
margin: 0 0 0 auto; font-size: 13px; font-weight: 700; color: var(--color-ink);
font-variant-numeric: tabular-nums;
}
.gf-kv dd small { font-weight: 500; color: var(--color-ink-3); }
.gf-kv dd.is-stale { color: var(--color-amber); }
.gf-kv dd.is-live { color: var(--color-green); font-family: var(--font-mono); font-size: 11.5px; }
/* ── work list ─────────────────────────────────────────────────── */
.gf-list {
list-style: none; margin: 0; padding: 0; display: grid; gap: 1px;
background: var(--color-line); border: 1px solid var(--color-line);
border-radius: 10px; overflow: hidden;
}
.gf-item { display: flex; align-items: stretch; gap: 12px; padding: 11px 13px; background: #fff; transition: background .15s; }
.gf-item:hover { background: var(--color-surface-2); }
.gf-item-main {
flex: 1; display: flex; gap: 12px; align-items: center; text-align: left;
background: none; border: none; color: inherit; font: inherit; cursor: pointer;
padding: 0; min-width: 0;
}
.gf-score {
width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center;
flex: none; font-size: 14.5px; border: 1px solid;
}
.gf-score.is-red { background: var(--color-risk-soft); color: #9c2b1f; border-color: rgba(192,57,43,.22); }
.gf-score.is-watch { background: var(--color-amber-soft); color: #8a4e05; border-color: rgba(224,128,25,.24); }
.gf-score.is-ok { background: var(--color-green-soft); color: #186b3d; border-color: rgba(40,174,100,.22); }
.gf-item-body { min-width: 0; }
.gf-item-top { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; }
.gf-item-top b { font-size: 13px; font-weight: 700; color: var(--color-ink); }
.gf-meta { font-size: 11px; color: var(--color-ink-3); }
.gf-when {
display: inline-flex; align-items: center; gap: 4px; font-size: 10.5px; font-weight: 700;
padding: 2px 8px; border-radius: 20px;
background: var(--color-amber-soft); color: #8a4e05;
}
.gf-when.is-now { background: var(--color-risk-soft); color: #9c2b1f; }
.gf-when.is-clear { background: var(--color-green-soft); color: #186b3d; }
.gf-why { display: block; font-size: 11.5px; color: var(--color-ink-2); margin-top: 2px; }
.gf-act {
align-self: center; flex: none; font-size: 11.5px; font-weight: 600;
padding: 7px 12px; border-radius: 8px; cursor: pointer;
background: #fff; color: var(--color-lp-blue);
border: 1px solid var(--color-line-s); transition: background .15s, border-color .15s;
}
.gf-act:hover { background: var(--color-lp-blue); border-color: var(--color-lp-blue); color: #fff; }
.gf-empty { font-size: 12.5px; color: var(--color-ink-3); padding: 22px 0; text-align: center; }
.gf button:focus-visible { outline: 2px solid var(--color-lp-blue); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gf-bar-fill { transition: none; } }
+458
View File
@@ -0,0 +1,458 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
borrower,
borrowerLogin,
fetchAppEnrolled,
fetchModelMetrics,
officerSession,
} from '../lib/api'
import type { AppHomeWire, Enterprise, ReasonCodeRow } from '../lib/api'
import { useTenantEnterprises } from '../features/officer/useTenantData'
import { ALL_BLOCKS } from '../lib/demo/ops'
import { inr, monthRef } from '../lib/demo/seed'
import { SECTORS } from '../lib/demo/enterprises'
import '../styles/demo.css'
/**
* One borrower, both sides, one screen.
*
* <h2>Why this page exists</h2>
*
* <p>The jury has thirty minutes and two surfaces to understand: a lender's
* console and a borrower's phone. Shown in sequence they read as two products.
* Shown together, with the same borrower selected on both, they read as what
* they are — one record, two audiences, and a shortfall the lender can see
* months before it arrives.
*
* <h2>Everything here is live</h2>
*
* <p>The left column is the officer's own tenant-scoped API. The right column is
* a <em>real</em> borrower sign-in: picking a row calls {@code /auth/app-login}
* and every panel beside it is fetched with that borrower's token. Nothing is
* mirrored from the left column, which is the point — if the two sides agree it
* is because they read one record, not because one was copied into the other.
*
* <p>The borrower token is held in React state and never written to storage, so
* this page cannot disturb a real app session open in another tab.
*/
const DEMO_PIN = '1234'
type Bucket = 'NOW' | 'WINDOW' | 'LATER' | 'CLEAR'
/** Same thresholds as the officer dashboard's triage funnel. */
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'
}
const BUCKET_META: Record<Bucket, { label: string; cls: string; caption: string }> = {
NOW: { label: 'Act now', cls: 'is-now', caption: 'breaches this month' },
WINDOW: { label: 'In window', cls: 'is-window', caption: 'breaches in 2-4 months' },
LATER: { label: 'Watch', cls: 'is-watch', caption: 'breaches beyond 4 months' },
CLEAR: { label: 'Covered', cls: 'is-clear', caption: 'downside still covers' },
}
function monthLabel(month: string | null | undefined): string {
if (!month) return '—'
return monthRef(new Date(`${month}-01T00:00:00`)).fullEn
}
export function DemoPage() {
const session = officerSession()
const book = useTenantEnterprises(ALL_BLOCKS)
const metricsQ = useQuery({ queryKey: ['model-metrics'], queryFn: fetchModelMetrics })
// Not every borrower has the app. Knowing which do lets the page open on one
// that can actually be shown, instead of failing a sign-in in front of a jury.
const enrolledQ = useQuery({
queryKey: ['app-enrolled', ALL_BLOCKS],
queryFn: () => fetchAppEnrolled(ALL_BLOCKS),
})
const enrolled = useMemo(() => new Set(enrolledQ.data ?? []), [enrolledQ.data])
const [selected, setSelected] = useState<string | null>(null)
const [token, setToken] = useState<string | null>(null)
const [signInError, setSignInError] = useState<string | null>(null)
// Worst first — the borrower with the least lead time is the one worth opening.
const rows = useMemo(() => {
const order: Record<Bucket, number> = { NOW: 0, WINDOW: 1, LATER: 2, CLEAR: 3 }
return [...book.rows].sort((a, b) => {
const d = order[bucketOf(a)] - order[bucketOf(b)]
return d !== 0 ? d : b.score - a.score
})
}, [book.rows])
// Open on the most urgent borrower who is actually on the app, so the page is
// already telling its whole story when it appears. Falling back to the most
// urgent row overall keeps the lender half working when nobody is enrolled.
useEffect(() => {
if (selected || !rows.length || enrolledQ.isLoading) return
const first = rows.find((e) => enrolled.has(e.enterpriseId)) ?? rows[0]
setSelected(first.enterpriseId)
}, [rows, selected, enrolled, enrolledQ.isLoading])
// A real sign-in as the selected borrower, repeated whenever the selection
// changes. The previous token is dropped rather than reused.
useEffect(() => {
if (!selected) return
let cancelled = false
setToken(null)
setSignInError(null)
if (enrolledQ.isSuccess && !enrolled.has(selected)) {
setSignInError('NOT_ENROLLED')
return
}
borrowerLogin(selected, DEMO_PIN)
.then((s) => {
if (!cancelled) setToken(s.token)
})
.catch(() => {
if (!cancelled) setSignInError('NOT_ENROLLED')
})
return () => {
cancelled = true
}
}, [selected, enrolled, enrolledQ.isSuccess])
const homeQ = useQuery({
queryKey: ['demo-app-home', selected, token],
queryFn: () => borrower.home(token as string),
enabled: Boolean(token),
})
const reasonsQ = useQuery({
queryKey: ['demo-app-reasons', selected, token],
queryFn: () => borrower.reasons(token as string),
enabled: Boolean(token),
})
const lender = rows.find((e) => e.enterpriseId === selected) ?? null
const home = homeQ.data ?? null
const reasons = reasonsQ.data ?? []
const counts = useMemo(() => {
const c: Record<Bucket, number> = { NOW: 0, WINDOW: 0, LATER: 0, CLEAR: 0 }
for (const e of book.rows) c[bucketOf(e)]++
return c
}, [book.rows])
const arm = metricsQ.data?.arms?.lightgbm
const engine = home?.forecast?.[0]?.engine ?? null
return (
<div className="dm">
<header className="dm-top">
<div className="dm-top-lede">
<h1>One borrower, both sides</h1>
<p>
The lender&rsquo;s console and the borrower&rsquo;s phone, reading the same
record. Pick a row on the left &mdash; the phone signs in as that borrower.
</p>
</div>
<dl className="dm-facts">
<div>
<dt>Signed in as</dt>
<dd>{session?.fullName ?? '—'}</dd>
</div>
<div>
<dt>Branch</dt>
<dd>{session?.branch ?? '—'}</dd>
</div>
<div>
<dt>Borrowers</dt>
<dd className="num">{book.rows.length}</dd>
</div>
<div>
<dt>Forecast engine</dt>
<dd>{engine ?? '—'}</dd>
</div>
</dl>
</header>
{book.unavailable && (
<div className="dm-warn">
The server could not be reached, so no borrowers are listed. This page never
substitutes demo rows &mdash; a book that cannot be read is shown as unread.{' '}
<button onClick={book.retry}>Retry</button>
</div>
)}
<div className="dm-split">
{/* ── lender ─────────────────────────────────────────── */}
<section className="dm-col">
<div className="dm-col-head">
<span className="dm-tag">The lender sees</span>
<h2>Officer console</h2>
</div>
<div className="dm-triage">
{(['NOW', 'WINDOW', 'LATER', 'CLEAR'] as Bucket[]).map((b) => (
<div key={b} className={`dm-seg ${BUCKET_META[b].cls}`}>
<span className="dm-seg-label">{BUCKET_META[b].label}</span>
<span className="dm-seg-val num">{counts[b]}</span>
<span className="dm-seg-cap">{BUCKET_META[b].caption}</span>
</div>
))}
</div>
<ul className="dm-list">
{rows.slice(0, 7).map((e) => {
const b = bucketOf(e)
return (
<li key={e.enterpriseId}>
<button
className={`dm-row ${selected === e.enterpriseId ? 'is-on' : ''}`}
onClick={() => setSelected(e.enterpriseId)}
>
<span className={`dm-score ${BUCKET_META[b].cls}`}>{e.score}</span>
<span className="dm-row-body">
<span className="dm-row-top">
<b>{e.name}</b>
<span className={`dm-when ${BUCKET_META[b].cls}`}>
{e.firstBreachMonth ? monthLabel(e.firstBreachMonth) : 'covered'}
</span>
</span>
<span className="dm-meta">
{SECTORS[e.sector]?.short ?? e.sector} &middot; {e.block} &middot;{' '}
{e.enterpriseId}
{enrolled.has(e.enterpriseId) && (
<span className="dm-hasapp" title="Has the phone app">
on the app
</span>
)}
</span>
{e.topDriver && <span className="dm-why">{e.topDriver}</span>}
</span>
</button>
</li>
)
})}
{rows.length === 0 && !book.unavailable && (
<li className="dm-empty">
{book.loading ? 'Loading the book…' : 'No borrowers on this book.'}
</li>
)}
</ul>
</section>
{/* ── borrower ───────────────────────────────────────── */}
<section className="dm-col">
<div className="dm-col-head">
<span className="dm-tag">The borrower sees</span>
<h2>Their own phone</h2>
</div>
<div className="dm-phone">
<div className="dm-phone-bar">
<span>{lender?.name ?? '—'}</span>
<span className="dm-live">
{signInError === 'NOT_ENROLLED'
? 'no app'
: token
? 'signed in'
: 'signing in…'}
</span>
</div>
<div className="dm-phone-body">
{signInError === 'NOT_ENROLLED' ? (
<div className="dm-none">
<b>This borrower is not on the app.</b>
<span>
They are still scored and still forecast — from core banking and
UPI aggregates, which need nothing installed on a phone. The app
adds a daily ledger and gives the owner the reasons in their own
language; it is not what makes the borrower visible.
</span>
</div>
) : null}
{signInError !== 'NOT_ENROLLED' && home?.alert ? (
<div className="dm-alert">
<b>{home.alert.title}</b>
<span>
Estimated {inr(home.alert.shortfall ?? 0)} short
{home.alert.breachMonth ? ` · ${monthLabel(home.alert.breachMonth)}` : ''}
</span>
</div>
) : signInError !== 'NOT_ENROLLED' && home ? (
<div className="dm-ok">No shortfall expected in the next six months.</div>
) : signInError !== 'NOT_ENROLLED' ? (
<div className="dm-skel" />
) : null}
{/* Six-month strip. The bar is the downside case, the line is what the
instalment needs. A bar below the line is the whole product. */}
{signInError !== 'NOT_ENROLLED' && home?.forecast?.length ? (
<div className="dm-fc">
<div className="dm-fc-head">
<span>Next six months</span>
<span>worst case vs instalment</span>
</div>
<div className="dm-bars">
{(() => {
const strip = home.forecast.slice(0, 6)
const top = Math.max(
...strip.map((q) => Math.max(q.p10, q.p50, q.obligation)),
1,
)
return strip.map((p) => (
<div key={p.month} className="dm-bar">
<div className="dm-bar-track">
<div
className={`dm-bar-fill ${p.breach ? 'is-breach' : ''}`}
style={{ height: `${Math.max(2, (Math.max(p.p10, 0) / top) * 100)}%` }}
/>
<div
className="dm-bar-need"
style={{ bottom: `${Math.min(100, (p.obligation / top) * 100)}%` }}
/>
</div>
<span className="dm-bar-x">
{monthRef(new Date(`${p.month}-01T00:00:00`)).label}
</span>
</div>
))
})()}
</div>
<p className="dm-legend">
<i className="dm-key-bar" /> worst case <i className="dm-key-line" /> instalment
due
</p>
</div>
) : null}
{signInError !== 'NOT_ENROLLED' && reasons.length > 0 && (
<div className="dm-reasons">
<div className="dm-fc-head">
<span>Why</span>
</div>
<ul>
{reasons.slice(0, 3).map((r: ReasonCodeRow) => (
<li key={r.id}>{r.sentenceEn}</li>
))}
</ul>
</div>
)}
{signInError !== 'NOT_ENROLLED' && home && (
<div className="dm-today">
<span>
Today in <b className="num">{inr(home.todayIn ?? 0)}</b>
</span>
<span>
out <b className="num">{inr(home.todayOut ?? 0)}</b>
</span>
</div>
)}
</div>
</div>
</section>
</div>
<Agreement lender={lender} home={home} reasons={reasons} />
<footer className="dm-foot">
<span>
{arm
? `Scoring model: LightGBM, AUC ${arm.auc_roc?.toFixed(3) ?? '—'} on ${
metricsQ.data?.datasetRows ?? '—'
} rows`
: 'Scoring model metrics unavailable'}
{metricsQ.data?.syntheticLabelShare === 1 && ' · labels are synthetic'}
</span>
<span>
<Link to="/officer">Officer console</Link> &middot; <Link to="/app">Borrower app</Link>
</span>
</footer>
</div>
)
}
/**
* The claim this page is making, checked rather than asserted.
*
* <p>Each row puts the officer's value beside the borrower's own, fetched under
* two different tokens from two different endpoints. They agree because they are
* one record. Where they disagree this says so — a demo that cannot fail is not
* demonstrating anything.
*/
function Agreement({
lender,
home,
reasons,
}: {
lender: Enterprise | null
home: AppHomeWire | null
reasons: ReasonCodeRow[]
}) {
if (!lender || !home) return null
const appScore = Number((home.enterprise as Record<string, unknown>)?.score ?? NaN)
const appBreach = home.forecast?.find((p) => p.breach)?.month ?? null
const checks = [
{
k: 'Stress score',
lender: String(lender.score),
app: Number.isNaN(appScore) ? '—' : String(appScore),
},
{
k: 'First month the instalment is missed',
lender: lender.firstBreachMonth ? monthLabel(lender.firstBreachMonth) : 'none',
app: appBreach ? monthLabel(appBreach) : 'none',
},
{
// The officer row carries the rendered sentence, not the driver code, so
// this compares sentence to sentence. Comparing `driver` here would read
// 'obligationLoad' against 'The instalment takes 88% of monthly inflow'
// and report a disagreement between two halves of the same reason.
k: 'Leading reason',
lender: lender.topDriver ?? '—',
app: reasons[0]?.sentenceEn ?? '—',
},
]
return (
<section className="dm-agree">
<div className="dm-col-head">
<span className="dm-tag">The point</span>
<h2>Both sides are reading one record</h2>
</div>
<div className="dm-table-wrap">
<table>
<thead>
<tr>
<th></th>
<th>Officer console</th>
<th>Borrower&rsquo;s phone</th>
<th></th>
</tr>
</thead>
<tbody>
{checks.map((c) => {
const same = c.lender.toLowerCase() === c.app.toLowerCase()
return (
<tr key={c.k}>
<th scope="row">{c.k}</th>
<td>{c.lender}</td>
<td>{c.app}</td>
<td className={same ? 'dm-yes' : 'dm-no'}>{same ? 'agree' : 'differ'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<p className="dm-note">
Fetched under two separate tokens &mdash; the officer&rsquo;s and this
borrower&rsquo;s own. Neither column is copied from the other.
</p>
</section>
)
}
+47 -4
View File
@@ -42,7 +42,7 @@ const STATS = [
/** Demo credentials — mirrored by UdyamDataSeeder on the backend. */
const DEMO = {
officer: { username: 'aditya.vyas', password: 'Udyam@123' },
enterprise: { id: 'MSME-4417', pin: '1234' },
enterprise: { id: 'MSME-6151', pin: '1234' },
}
/**
@@ -82,6 +82,10 @@ const PERSONAS: {
tier: 'Partner bank',
note: 'its own book in full, borrower records included',
accounts: [
// Assam Gramin first: its book carries the widest spread of lead times, which
// is what the early-warning view is built to show. MP Gramin is 8 breaching
// now against 2 in the window — a single spike rather than a distribution.
{ label: 'Assam Gramin · head office', username: 'agvb.admin', secret: 'Udyam@123', mode: 'OFFICER' },
{ label: 'MP Gramin · head office', username: 'mpgb.admin', secret: 'Udyam@123', mode: 'OFFICER' },
{ label: 'Sehore DCCB · head office', username: 'dccb.admin', secret: 'Udyam@123', mode: 'OFFICER' },
{ label: 'Field officer', username: 'aditya.vyas', secret: 'Udyam@123', mode: 'OFFICER' },
@@ -90,7 +94,11 @@ const PERSONAS: {
{
tier: 'Enterprise',
note: 'one borrower, itself',
accounts: [{ label: 'MSME-4417', username: 'MSME-4417', secret: '1234', mode: 'ENTERPRISE' }],
accounts: [
// The same borrower the officer console opens on, seen from the owner's side.
{ label: 'MSME-6151 · Kaziranga Fruit Farm', username: 'MSME-6151', secret: '1234', mode: 'ENTERPRISE' },
{ label: 'MSME-4417 · Sehore Poultry Farm', username: 'MSME-4417', secret: '1234', mode: 'ENTERPRISE' },
],
},
]
@@ -187,7 +195,7 @@ export function LoginPage() {
className="hidden lg:flex flex-col flex-[1.4] px-14 py-12 relative overflow-hidden"
style={{
background:
'radial-gradient(rgba(63,184,113,0.06) 1px, transparent 1px), linear-gradient(150deg, #06301f 0%, #0b5d3b 58%, #0f6f47 100%)',
'radial-gradient(rgba(76,197,130,0.06) 1px, transparent 1px), linear-gradient(150deg, #072e22 0%, #0b4a37 58%, #0e5c44 100%)',
backgroundSize: '26px 26px, cover',
}}
>
@@ -199,6 +207,14 @@ export function LoginPage() {
<span className="font-mono text-[9px] tracking-[0.2em] text-[#9dbdaa] border border-white/15 rounded-full px-2.5 py-0.5 ml-1">
NABARD HACKATHON
</span>
<img
src="/nabard-logo.png"
alt="NABARD"
className="h-10 w-auto object-contain bg-white rounded-md px-2 py-1 ml-auto shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
</div>
<div className="flex-1 flex flex-col justify-center max-w-[560px]">
@@ -235,12 +251,22 @@ export function LoginPage() {
{/* ── right: sign-in ───────────────────────────────────── */}
<div className="flex-1 min-w-[380px] bg-white text-slate-900 flex flex-col justify-center px-10 py-12">
<div className="w-full max-w-[380px] mx-auto">
<div className="flex items-center gap-2 mb-1 lg:hidden">
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2 lg:hidden">
<UdyamMark size={24} />
<span className="text-[16px] font-bold text-forest">
Udyam<span className="text-amber">Linc</span>
</span>
</div>
<img
src="/nabard-logo.png"
alt="NABARD"
className="h-10 w-auto object-contain ml-auto shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
</div>
<h2 className="text-[19px] font-bold text-forest">{hi ? 'अपने उद्यम में जाएँ' : 'Sign in to the console'}</h2>
<p className="text-[12px] text-slate-500 mt-1 mb-5">
@@ -398,6 +424,23 @@ export function LoginPage() {
? 'आपकी निजी जानकारी कहीं नहीं रखी जाती।'
: 'Consent-first, explainable and auditable by design — no personally identifiable data.'}
</p>
{/* Event attribution. Lives on the sign-in panel rather than the
branding panel because that one is `hidden lg:flex` and vanishes
below 1024px. No white chip needed here — the panel is white. */}
<div className="flex items-center justify-center gap-2.5 mt-5 pt-5 border-t border-slate-200">
<img
src="/nabard-logo.png"
alt="NABARD"
className="h-9 w-auto object-contain shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
<span className="text-[10px] text-slate-400 font-mono leading-relaxed">
Built for the NABARD Hackathon 2025
</span>
</div>
</div>
</div>
</div>
+267
View File
@@ -0,0 +1,267 @@
/* ==================================================================
* Demo page — one borrower, both sides
*
* Scoped under `.dm`. Its whole job is a comparison, so the two columns
* carry equal weight and the phone is drawn as a phone: the jury has to
* recognise which side is which without being told twice.
*
* Severity keeps the meaning it carries everywhere else in the console —
* red is act-now, amber is in-window, blue is watch, green is covered.
* Nothing here invents a new colour language for one screen.
*
* Tokens come from index.css (`@theme`), so this file defines no colours
* of its own beyond the two phone-chrome shades.
* ================================================================== */
.dm {
max-width: 1280px;
margin: 0 auto;
padding: 24px 20px 48px;
display: flex;
flex-direction: column;
gap: 18px;
color: var(--color-ink);
}
.dm .num { font-variant-numeric: tabular-nums; }
/* ── header ────────────────────────────────────────────────────── */
.dm-top {
display: flex; gap: 26px; align-items: flex-end; flex-wrap: wrap;
padding-bottom: 16px; border-bottom: 1px solid var(--color-line);
}
.dm-top-lede { flex: 1 1 340px; min-width: 0; }
.dm-top h1 {
font-size: 25px; font-weight: 700; letter-spacing: -0.028em; margin: 0;
text-wrap: balance; color: var(--color-ink);
}
.dm-top p {
margin: 6px 0 0; font-size: 13px; line-height: 1.55;
color: var(--color-ink-2); max-width: 62ch;
}
.dm-facts { display: flex; gap: 26px; margin: 0; flex-wrap: wrap; }
.dm-facts dt {
font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--color-ink-3); font-weight: 700;
}
.dm-facts dd {
margin: 3px 0 0; font-size: 14px; font-weight: 700; color: var(--color-ink);
font-variant-numeric: tabular-nums;
}
.dm-warn {
background: var(--color-amber-soft); border: 1px solid rgba(224, 128, 25, 0.28);
border-radius: var(--radius-card); padding: 12px 15px;
font-size: 12.5px; color: #7a4505; line-height: 1.55;
}
.dm-warn button {
font: inherit; font-weight: 700; color: var(--color-lp-blue);
background: none; border: none; cursor: pointer; padding: 0; text-decoration: underline;
}
/* ── two columns of equal weight ───────────────────────────────── */
.dm-split { display: grid; gap: 20px; grid-template-columns: 1.35fr 1fr; align-items: start; }
@media (max-width: 960px) { .dm-split { grid-template-columns: 1fr; } }
.dm-col-head { margin-bottom: 11px; }
.dm-tag {
display: inline-block; font-size: 9.5px; letter-spacing: 0.12em;
text-transform: uppercase; font-weight: 700; color: var(--color-lp-blue);
background: var(--color-lp-blue-soft); border-radius: 20px; padding: 3px 9px;
}
.dm-col-head h2 {
font-size: 16px; font-weight: 700; letter-spacing: -0.018em;
margin: 7px 0 0; color: var(--color-ink);
}
/* ── triage ────────────────────────────────────────────────────── */
.dm-triage {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px;
background: var(--color-line); border: 1px solid var(--color-line);
border-radius: var(--radius-card); overflow: hidden; box-shadow: var(--sh);
margin-bottom: 14px;
}
.dm-seg { background: #fff; padding: 12px 13px 11px; position: relative; }
.dm-seg::after {
content: ""; position: absolute; inset: auto 0 0 0; height: 3px;
background: var(--seg, var(--color-ink-3));
}
.dm-seg.is-now { --seg: var(--color-risk-red); }
.dm-seg.is-window { --seg: var(--color-amber); }
.dm-seg.is-watch { --seg: var(--color-lp-blue); }
.dm-seg.is-clear { --seg: var(--color-green); }
.dm-seg-label {
display: block; font-size: 9.5px; letter-spacing: 0.09em; text-transform: uppercase;
color: var(--color-ink-3); font-weight: 700;
}
.dm-seg-val {
display: block; font-size: 27px; font-weight: 700; line-height: 1.05;
letter-spacing: -0.03em; margin: 4px 0 2px; color: var(--seg);
font-variant-numeric: tabular-nums;
}
.dm-seg-cap { display: block; font-size: 10.5px; color: var(--color-ink-3); line-height: 1.35; }
/* ── work list ─────────────────────────────────────────────────── */
.dm-list {
list-style: none; margin: 0; padding: 0; display: grid; gap: 1px;
background: var(--color-line); border: 1px solid var(--color-line);
border-radius: var(--radius-card); overflow: hidden; box-shadow: var(--sh);
}
.dm-row {
width: 100%; display: flex; gap: 12px; align-items: center; text-align: left;
background: #fff; border: none; color: inherit; font: inherit; cursor: pointer;
padding: 11px 13px; transition: background 0.15s;
border-left: 3px solid transparent;
}
.dm-row:hover { background: var(--color-surface-2); }
.dm-row.is-on { background: var(--color-lp-blue-soft); border-left-color: var(--color-lp-blue); }
.dm-score {
width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center;
flex: none; font-size: 14px; font-weight: 700; border: 1px solid;
font-variant-numeric: tabular-nums;
}
.dm-score.is-now { background: var(--color-risk-soft); color: #9c2b1f; border-color: rgba(192, 57, 43, 0.22); }
.dm-score.is-window { background: var(--color-amber-soft); color: #8a4e05; border-color: rgba(224, 128, 25, 0.24); }
.dm-score.is-watch { background: var(--color-lp-blue-soft); color: #1d5490; border-color: rgba(42, 113, 184, 0.22); }
.dm-score.is-clear { background: var(--color-green-soft); color: #186b3d; border-color: rgba(40, 174, 100, 0.22); }
.dm-row-body { min-width: 0; flex: 1; }
.dm-row-top { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.dm-row-top b { font-size: 13px; font-weight: 700; }
.dm-when {
font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 20px;
background: var(--color-amber-soft); color: #8a4e05; white-space: nowrap;
}
.dm-when.is-now { background: var(--color-risk-soft); color: #9c2b1f; }
.dm-when.is-clear { background: var(--color-green-soft); color: #186b3d; }
.dm-meta { display: block; font-size: 10.5px; color: var(--color-ink-3); margin-top: 1px; }
.dm-why { display: block; font-size: 11.5px; color: var(--color-ink-2); margin-top: 3px; }
.dm-empty {
background: #fff; padding: 22px; text-align: center;
font-size: 12.5px; color: var(--color-ink-3);
}
/* ── the phone ─────────────────────────────────────────────────── */
.dm-phone {
max-width: 340px; margin: 0 auto; background: #fff;
border: 8px solid #1c2429; border-radius: 26px; overflow: hidden;
box-shadow: 0 10px 30px rgba(13, 20, 24, 0.16);
}
.dm-phone-bar {
background: #1c2429; color: #fff; padding: 6px 14px 10px;
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
font-size: 12px; font-weight: 700;
}
.dm-live { font-size: 9.5px; font-weight: 600; color: #7fe0ad; letter-spacing: 0.04em; }
.dm-phone-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
.dm-alert {
background: var(--color-risk-red); color: #fff; border-radius: 10px; padding: 11px 13px;
display: flex; flex-direction: column; gap: 3px;
}
.dm-alert b { font-size: 13px; line-height: 1.35; }
.dm-alert span { font-size: 11.5px; color: rgba(255, 255, 255, 0.9); }
.dm-ok {
background: var(--color-green-soft); border: 1px solid rgba(40, 174, 100, 0.22);
color: #186b3d; border-radius: 10px; padding: 11px 13px; font-size: 12px;
}
.dm-skel { height: 62px; border-radius: 10px; background: var(--color-surface-3); }
.dm-fc-head {
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
font-size: 9.5px; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--color-ink-3); font-weight: 700; margin-bottom: 7px;
}
.dm-bars { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; height: 92px; }
.dm-bar { display: flex; flex-direction: column; gap: 4px; min-height: 0; }
.dm-bar-track {
flex: 1; position: relative; display: flex; align-items: flex-end;
background: var(--color-surface-3); border-radius: 4px;
}
.dm-bar-fill { width: 100%; border-radius: 4px; background: var(--color-lp-blue-l); }
.dm-bar-fill.is-breach { background: var(--color-risk-red); }
/* What the instalment needs. A bar ending below this line is the shortfall. */
.dm-bar-need {
position: absolute; left: -1px; right: -1px; height: 0;
border-top: 2px dashed var(--color-ink-2);
}
.dm-bar-x { font-size: 9.5px; color: var(--color-ink-3); text-align: center; }
.dm-legend {
margin: 8px 0 0; font-size: 10px; color: var(--color-ink-3);
display: flex; align-items: center; gap: 5px; flex-wrap: wrap;
}
.dm-key-bar {
width: 10px; height: 10px; border-radius: 2px;
background: var(--color-lp-blue-l); display: inline-block;
}
.dm-key-line {
width: 12px; border-top: 2px dashed var(--color-ink-2);
display: inline-block; margin-left: 6px;
}
.dm-reasons ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.dm-reasons li {
font-size: 11.5px; line-height: 1.45; color: var(--color-ink-2);
padding-left: 12px; position: relative;
}
.dm-reasons li::before {
content: ""; position: absolute; left: 0; top: 6px;
width: 5px; height: 5px; border-radius: 50%; background: var(--color-amber);
}
.dm-today {
display: flex; gap: 14px; font-size: 11.5px; color: var(--color-ink-3);
border-top: 1px solid var(--color-line); padding-top: 9px;
}
.dm-today b { color: var(--color-ink); }
/* ── agreement table ───────────────────────────────────────────── */
.dm-agree {
background: #fff; border: 1px solid var(--color-line);
border-radius: var(--radius-card); padding: 18px 20px; box-shadow: var(--sh);
}
.dm-table-wrap { overflow-x: auto; }
.dm-agree table { width: 100%; border-collapse: collapse; min-width: 520px; }
.dm-agree thead th {
font-size: 9.5px; letter-spacing: 0.09em; text-transform: uppercase;
color: var(--color-ink-3); font-weight: 700; text-align: left;
padding: 0 12px 7px 0; border-bottom: 1px solid var(--color-line);
}
.dm-agree tbody th {
font-size: 12px; font-weight: 600; color: var(--color-ink-2);
text-align: left; padding: 10px 12px 10px 0;
}
.dm-agree tbody td {
font-size: 13px; font-weight: 700; color: var(--color-ink);
padding: 10px 12px 10px 0; font-variant-numeric: tabular-nums;
}
.dm-agree tbody tr + tr th,
.dm-agree tbody tr + tr td { border-top: 1px solid var(--color-line); }
.dm-yes { color: var(--color-green); font-size: 11px; font-weight: 700; }
.dm-no { color: var(--color-risk-red); font-size: 11px; font-weight: 700; }
.dm-note {
margin: 12px 0 0; font-size: 11.5px; color: var(--color-ink-3);
line-height: 1.5; max-width: 70ch;
}
.dm-foot {
display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap;
font-size: 11.5px; color: var(--color-ink-3);
border-top: 1px solid var(--color-line); padding-top: 14px;
}
.dm-foot a { color: var(--color-lp-blue); font-weight: 600; }
.dm button:focus-visible { outline: 2px solid var(--color-lp-blue); outline-offset: 2px; }
/* A borrower with no phone app. Stated rather than shown as an error: the
platform not needing the app is a claim worth making on this screen. */
.dm-none {
background: var(--color-surface-3); border: 1px solid var(--color-line);
border-radius: 10px; padding: 12px 13px;
display: flex; flex-direction: column; gap: 5px;
}
.dm-none b { font-size: 12.5px; color: var(--color-ink); }
.dm-none span { font-size: 11.5px; line-height: 1.5; color: var(--color-ink-2); }
.dm-hasapp {
margin-left: 7px; font-size: 9px; font-weight: 700; letter-spacing: 0.04em;
text-transform: uppercase; color: var(--color-lp-blue);
background: var(--color-lp-blue-soft); border-radius: 20px; padding: 1px 6px;
white-space: nowrap;
}