diff --git a/public/nabard-logo.png b/public/nabard-logo.png new file mode 100644 index 0000000..325dc6d Binary files /dev/null and b/public/nabard-logo.png differ diff --git a/src/App.tsx b/src/App.tsx index 4024482..a620b10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> + {/* Both surfaces on one screen, for the jury round. Officer session + required — the borrower half signs itself in per selection. */} + + + + } + /> } /> {/* Field officer console — portfolio, risk detail and recommended action */} @@ -92,7 +106,9 @@ export default function App() { } > - } /> + } /> + {/* Two readings of one book: by score, and by when trouble arrives. */} + } /> } /> } /> } /> @@ -100,6 +116,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -121,6 +138,7 @@ export default function App() { } /> } /> } /> + } /> {/* Enterprise PWA — offline, Hindi-first */} diff --git a/src/components/ui/bits.tsx b/src/components/ui/bits.tsx index ff6d24e..8474e33 100644 --- a/src/components/ui/bits.tsx +++ b/src/components/ui/bits.tsx @@ -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 — 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 ( -
+
{title && ( -
-

{title}

- {badge && {badge}} +
+

{title}

+ {badge && {badge}} {actions &&
{actions}
}
)} @@ -83,7 +86,7 @@ export function Modal({ export function SubNav({ items }: { items: { label: string; to: string; end?: boolean }[] }) { return ( -
+
{items.map((i) => ( [ '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 ( @@ -149,7 +152,7 @@ export function GhostBtn({ return ( @@ -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
{text}
+ return
{text}
} // ── 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 (
@@ -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 (
- {label} + {label}
-
{value}
- {delta &&
{delta}
} +
{value}
+ {delta &&
{delta}
}
) } @@ -303,8 +306,76 @@ export function KpiTile({ export function UdyamMark({ size = 26, onDark = false }: { size?: number; onDark?: boolean }) { return ( - + ) } + +/** + * The design system's `.alert` box — a tinted panel with a leading glyph, a bold + * first line and a plain second. + * + *

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 ( +

+ {glyph} +
+ {title} + {children && {children}} +
+
+ ) +} + +/** + * What a screen shows instead of borrower rows it cannot verify. + * + *

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 ( +

+

{what} unavailable

+

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

+ {onRetry && ( + + Try again + + )} +
+ ) +} diff --git a/src/features/officer/DashboardTabs.tsx b/src/features/officer/DashboardTabs.tsx new file mode 100644 index 0000000..83a4459 --- /dev/null +++ b/src/features/officer/DashboardTabs.tsx @@ -0,0 +1,43 @@ +import { NavLink } from 'react-router-dom' + +/** + * Two ways to read the same book. + * + *

Portfolio overview ranks by composite score — the register an officer + * already knows how to read, and the view that has been demonstrated. + * Early warning ranks by when the downside first stops covering the + * instalment, which is the thing this platform can show that a bureau score + * cannot. + * + *

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 ( +

+ {TABS.map((t) => ( + + [ + '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} + + ))} +
+ ) +} diff --git a/src/features/officer/OfficerDashboard.tsx b/src/features/officer/OfficerDashboard.tsx index 76df816..47e01d3 100644 --- a/src/features/officer/OfficerDashboard.tsx +++ b/src/features/officer/OfficerDashboard.tsx @@ -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**. + * + *

The previous version ranked by score: five tiles counting bands, then forty + * rows sorted by composite score. That is what a bureau score already shows. The + * thing this platform can do that a bureau cannot is say when — trouble + * four months out, while intervention is still cheap — and nothing on that screen + * expressed time at all. + * + *

So the primary axis here is the horizon at which the downside case first + * fails to cover the instalment. The triage strip is a funnel, the chart draws + * the intervention window, and the list is a day's work rather than a register. + * The full register still lives at /officer/enterprises; this screen + * deliberately does not duplicate it. + * + *

The old layout is kept at /officer/classic — it is verified and + * this one is new, and on a demo day that switch is worth having. */ + +type Bucket = 'NOW' | 'WINDOW' | 'LATER' | 'CLEAR' + +const BUCKETS: { key: Bucket; label: string; caption: string; tone: string }[] = [ + { key: 'NOW', label: 'Act now', caption: 'breaching this month', tone: 'gf-now' }, + { key: 'WINDOW', label: 'Month 2–4', caption: 'the window that matters', tone: 'gf-window' }, + { key: 'LATER', label: 'Month 5–6', caption: 'on the horizon', tone: 'gf-watch' }, + { key: 'CLEAR', label: 'Downside covered', caption: 'no breach forecast', tone: 'gf-clear' }, +] + +/** + * Which bucket a borrower falls in — **on breach horizon alone**. + * + *

Deliberately one dimension. An earlier version mixed band into the last two + * segments, which put every WATCH-band borrower with no forecast breach into + * "Watching" and left "Healthy" reading zero — a funnel that bulged in the middle + * and looked broken. + * + *

Horizon also has to win over score on its own merits. A STANDARD-band + * enterprise whose downside stops covering the instalment in four months is the + * entire point of this product; ranking by current score would bury it under + * borrowers already in arrears, who are already known to everyone. + */ +function bucketOf(e: Enterprise): Bucket { + const h = e.firstBreachHorizon + if (h == null) return 'CLEAR' + if (h <= 1) return 'NOW' + if (h <= 4) return 'WINDOW' + return 'LATER' +} + export function OfficerDashboard() { const navigate = useNavigate() const { block, month } = useScope() - const [selectedId, setSelectedId] = useState(null) + const [filter, setFilter] = useState('ALL') const [scheduling, setScheduling] = useState(null) - const { data } = useQuery({ + const { data, isError, refetch } = useQuery({ queryKey: ['portfolio', block, month], queryFn: () => get('/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 = { 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 void refetch()} /> + + const maxH = Math.max(1, ...horizon) return (

- {/* ── KPI tiles ─────────────────────────────────────── */} -
- = 0 ? '+' : ''}${summary.totalDelta} this month`} - /> - = 0 ? '+' : ''}${summary.redDelta} since last week`} - tone="red" - /> - = 0 ? '+' : ''}${summary.watchDelta} since last week`} - tone="watch" - /> - 0 ? '+' : ''}${summary.standardDelta} since last week`} - tone="good" - /> + +
+ {/* ── triage funnel ───────────────────────────────────── + One strip, four segments, urgency decreasing left to right. A row of + separate tiles counting bands reads as a census; this reads as a queue. */} +
+ {BUCKETS.map((b) => { + const active = filter === b.key + return ( + + ) + })}
-
- {/* ── enterprises by risk ─────────────────────────── */} -
-
-

Enterprises by risk

- updated {summary.updatedMinutesAgo} minutes ago - - Export CSV - +
+ {/* ── lead-time chart ───────────────────────────────── */} +
+
+

When the book breaches

+ Borrowers whose downside first misses the instalment, by month +
+
+ {horizon.map((n, i) => { + const m = monthRef(new Date(new Date().getFullYear(), new Date().getMonth() + i + 1, 1)) + const inWindow = i >= 1 && i <= 3 + return ( +
+ {n || ''} + + + + {m.label} +
+ ) + })}
+

+ + The middle months are where a visit still changes the outcome. {counts.WINDOW} borrowers + sit there now — none of them has missed a payment yet. +

+
-
- - - - - - - - - - - - - {rows.slice(0, 40).map((e) => ( - setSelectedId(e.enterpriseId)} - className={`cursor-pointer ${e.enterpriseId === selected?.enterpriseId ? '!bg-green-soft' : ''}`} - > - - - - - - - - - ))} - -
EnterpriseSectorBlockScoreStatus6-month trend -
-
- - {e.name[0]} - - - {e.name} - {e.enterpriseId} - -
-
{SECTORS[e.sector].short}{e.block} - {e.score} - - - - - - -
-
- - {rows.length > 40 && ( -
- Showing the 40 highest-risk of {rows.length} — the full register is under Enterprises. -
- )} + {/* ── context ───────────────────────────────────────── */} +
+ + +
- - {/* ── risk detail ─────────────────────────────────── */} - {selected && setScheduling(selected)} />}
+ {/* ── the day's work ────────────────────────────────────── */} +
+
+

Today’s list

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

Nothing outstanding in this filter.

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

Risk detail

-
- {enterprise.name} · {enterprise.enterpriseId} +
+
+

Data freshness

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

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

+ + ) +} -
TOP DRIVERS
-
- {DRIVER_META.map((d) => ( - - ))} -
+function MarketCard() { + const marketQ = useQuery({ + queryKey: ['market-signals'], + queryFn: () => get('/admin/ingestion/signals/market', { weeks: 8 }), + }) + const climateQ = useQuery({ + queryKey: ['climate-signals'], + queryFn: () => get('/admin/ingestion/signals/climate', { weeks: 8 }), + }) + const market = marketQ.data ?? demoMarketSignals() + const climate = climateQ.data ?? demoClimateSignals() -
-

- {reason.sentenceEn} - {breachRef && ` Shortfall expected by ${breachRef.fullEn}.`} -

- {breach && ( -

- Projected gap {inr(breach.obligation - breach.p10)} at P10 -

- )} -
+ const latest = new Map() + for (const r of market) { + const held = latest.get(r.commodityName) + if (!held || r.weekStart > held.weekStart) latest.set(r.commodityName, r) + } + const movers = Array.from(latest.values()) + .map((m) => { + const hist = market + .filter((r) => r.commodityName === m.commodityName && r.weekStart < m.weekStart) + .map((r) => r.modalPrice) + .filter((p): p is number => p != null) + .slice(0, 8) + const price = m.modalPrice ?? 0 + const mean = hist.length ? hist.reduce((s, p) => s + p, 0) / hist.length : price + return { name: m.commodityName, price, pct: mean ? ((price - mean) / mean) * 100 : 0 } + }) + .sort((a, b) => Math.abs(b.pct) - Math.abs(a.pct)) + .slice(0, 2) -
- - Schedule visit - - navigate(`/officer/enterprises/${enterprise.enterpriseId}`)}> - Full forecast - -
-
-
+ const c = climate.length ? climate.reduce((a, b) => (a.weekStart > b.weekStart ? a : b)) : null + + return ( +
+
+

Market & climate

+
+
+ {c && ( +
+
Rain vs normal
+
25 ? 'is-stale' : ''}> + {(c.rainfallAnomalyPct ?? 0) > 0 ? '+' : ''} + {Math.round(c.rainfallAnomalyPct ?? 0)}% +
+
+ )} + {movers.map((m) => ( +
+
{m.name.toLowerCase()}
+
12 ? 'is-stale' : ''}> + ₹{Math.round(m.price).toLocaleString('en-IN')} + {m.pct > 0 ? ' +' : ' '}{m.pct.toFixed(0)}% +
+
+ ))} +
+
+ ) +} + +function EngineCard({ scored }: { scored: number }) { + const { data } = useQuery({ + queryKey: ['model-metrics'], + queryFn: () => + get<{ available: boolean; modelVersion?: string; arms?: Record }>( + '/admin/model/metrics', + ), + }) + const auc = data?.arms + ? Object.entries(data.arms) + .filter(([, a]) => !a.baseline) + .map(([, a]) => a.auc_roc ?? 0) + .sort((a, b) => b - a)[0] + : undefined + + return ( +
+
+

Scoring engine

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

Labels are generated history — calibration pending.

+
) } diff --git a/src/features/officer/OfficerDashboardClassic.tsx b/src/features/officer/OfficerDashboardClassic.tsx new file mode 100644 index 0000000..ec24479 --- /dev/null +++ b/src/features/officer/OfficerDashboardClassic.tsx @@ -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(null) + const [scheduling, setScheduling] = useState(null) + + const { data, isError, refetch } = useQuery({ + queryKey: ['portfolio', block, month], + queryFn: () => get('/officer/portfolio', { block, month }), + }) + + // No demo fallback. The demo portfolio 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 void refetch()} /> + } + + return ( +
+ + {/* ── KPI tiles ─────────────────────────────────────── */} +
+ = 0 ? '+' : ''}${summary.totalDelta} this month`} + /> + = 0 ? '+' : ''}${summary.redDelta} since last week`} + tone="red" + /> + = 0 ? '+' : ''}${summary.watchDelta} since last week`} + tone="watch" + /> + 0 ? '+' : ''}${summary.standardDelta} since last week`} + tone="good" + /> +
+ +
+ {/* ── enterprises by risk ─────────────────────────── */} +
+
+

Enterprises by risk

+ + Ranked by Composite Stress Score · updated {summary.updatedMinutesAgo} min ago + + + Export CSV + +
+ +
+ + + + + + + + + + + + + {rows.slice(0, 40).map((e) => ( + setSelectedId(e.enterpriseId)} + className={`cursor-pointer ${e.enterpriseId === selected?.enterpriseId ? 'sel' : ''}`} + > + + + + + + + + + ))} + +
EnterpriseSectorBlockScoreStatus6-month trend +
+
+ + {e.name[0]} + + + {e.name} + {e.enterpriseId} + +
+
{SECTORS[e.sector]?.short ?? e.sector}{e.block} + {e.score} + + + + + + +
+
+ + {rows.length > 40 && ( +
+ Showing the 40 highest-risk of {rows.length} — the full register is under Portfolio → Enterprises. +
+ )} +
+ + {/* ── risk detail ─────────────────────────────────── */} + {selected && ( + setScheduling(selected)} + /> + )} +
+ + {/* ── what happened, what is coming, what to do ─────── */} +
+ + + +
+ + {scheduling && setScheduling(null)} />} +
+ ) +} + +/* ------------------------------------------------------------------ */ +/* The three panels under the register */ +/* ------------------------------------------------------------------ */ + +/** + * What crossed a line today, as opposed to what merely scores badly. + * + *

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 ( + Rule-based, independent of the model} + > +

+ {unavailable && } + {!unavailable && open.length === 0 && } + {open.map((a) => ( + + ))} +
+ + ) +} + +/** + * The outside world, in four lines. + * + *

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('/admin/ingestion/signals/market', { weeks: 8 }), + }) + const climateQ = useQuery({ + queryKey: ['climate-signals'], + queryFn: () => get('/admin/ingestion/signals/climate', { weeks: 8 }), + }) + + const market = marketQ.data ?? demoMarketSignals() + const climate = climateQ.data ?? demoClimateSignals() + + // 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() + 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 ( + The signals behind the shock family} + > +

+ {rows.length === 0 && } + {rows.map((r) => ( +
+
+
{r.name}
+
{r.sub}
+
+ + {r.value} + +
+ ))} +
+ + ) +} + +/** + * The playbook, ranked — the screen's only instruction to a person. + * + *

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 ( + Playbook, ranked by expected impact} + > +

+ {unavailable && } + {!unavailable && visits.length === 0 && } + {visits.map((v, i) => ( + + ))} +
+ + ) +} + +/** 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 ( +
+
+

Risk detail

+ + {enterprise.name} · {enterprise.enterpriseId} + +
+ +
+
+ +
+ + {/* Band, sector and block as one chip row — the three facts that decide + whether this score is even the officer's to act on. */} +
+ + {SECTORS[enterprise.sector].short} + {enterprise.block} +
+ +
+ Top drivers + + {" "} + · {isModel ? "SHAP contribution" : "weighted share"} + +
+
+ {!sub && } + {sub && + DRIVER_META.map((d) => ( + + ))} +
+ + } + title="Reason code" + > + {reason?.sentenceEn ?? "No reason code stored for this run."} + {breachRef && ` Shortfall expected by ${breachRef.fullEn}.`} + {breach && ( + + Projected gap {inr(breach.obligation - breach.p10)} at P10 + + )} + + +
+ + Schedule visit + + navigate(`/officer/enterprises/${enterprise.enterpriseId}`)}> + Full forecast + +
+
+
+ ) +} diff --git a/src/index.css b/src/index.css index da59c1b..c7949c5 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } } diff --git a/src/pages/DemoPage.tsx b/src/pages/DemoPage.tsx new file mode 100644 index 0000000..940cce1 --- /dev/null +++ b/src/pages/DemoPage.tsx @@ -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. + * + *

Why this page exists

+ * + *

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

Everything here is live

+ * + *

The left column is the officer's own tenant-scoped API. The right column is + * a real 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. + * + *

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 = { + 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(null) + const [token, setToken] = useState(null) + const [signInError, setSignInError] = useState(null) + + // Worst first — the borrower with the least lead time is the one worth opening. + const rows = useMemo(() => { + const order: Record = { 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 = { 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 ( +

+
+
+

One borrower, both sides

+

+ The lender’s console and the borrower’s phone, reading the same + record. Pick a row on the left — the phone signs in as that borrower. +

+
+
+
+
Signed in as
+
{session?.fullName ?? '—'}
+
+
+
Branch
+
{session?.branch ?? '—'}
+
+
+
Borrowers
+
{book.rows.length}
+
+
+
Forecast engine
+
{engine ?? '—'}
+
+
+
+ + {book.unavailable && ( +
+ The server could not be reached, so no borrowers are listed. This page never + substitutes demo rows — a book that cannot be read is shown as unread.{' '} + +
+ )} + +
+ {/* ── lender ─────────────────────────────────────────── */} +
+
+ The lender sees +

Officer console

+
+ +
+ {(['NOW', 'WINDOW', 'LATER', 'CLEAR'] as Bucket[]).map((b) => ( +
+ {BUCKET_META[b].label} + {counts[b]} + {BUCKET_META[b].caption} +
+ ))} +
+ +
    + {rows.slice(0, 7).map((e) => { + const b = bucketOf(e) + return ( +
  • + +
  • + ) + })} + {rows.length === 0 && !book.unavailable && ( +
  • + {book.loading ? 'Loading the book…' : 'No borrowers on this book.'} +
  • + )} +
+
+ + {/* ── borrower ───────────────────────────────────────── */} +
+
+ The borrower sees +

Their own phone

+
+ +
+
+ {lender?.name ?? '—'} + + {signInError === 'NOT_ENROLLED' + ? 'no app' + : token + ? 'signed in' + : 'signing in…'} + +
+ +
+ {signInError === 'NOT_ENROLLED' ? ( +
+ This borrower is not on the app. + + 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. + +
+ ) : null} + + {signInError !== 'NOT_ENROLLED' && home?.alert ? ( +
+ {home.alert.title} + + Estimated {inr(home.alert.shortfall ?? 0)} short + {home.alert.breachMonth ? ` · ${monthLabel(home.alert.breachMonth)}` : ''} + +
+ ) : signInError !== 'NOT_ENROLLED' && home ? ( +
No shortfall expected in the next six months.
+ ) : signInError !== 'NOT_ENROLLED' ? ( +
+ ) : 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 ? ( +
+
+ Next six months + worst case vs instalment +
+
+ {(() => { + 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) => ( +
+
+
+
+
+ + {monthRef(new Date(`${p.month}-01T00:00:00`)).label} + +
+ )) + })()} +
+

+ worst case instalment + due +

+
+ ) : null} + + {signInError !== 'NOT_ENROLLED' && reasons.length > 0 && ( +
+
+ Why +
+
    + {reasons.slice(0, 3).map((r: ReasonCodeRow) => ( +
  • {r.sentenceEn}
  • + ))} +
+
+ )} + + {signInError !== 'NOT_ENROLLED' && home && ( +
+ + Today in {inr(home.todayIn ?? 0)} + + + out {inr(home.todayOut ?? 0)} + +
+ )} +
+
+
+
+ + + +
+ + {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'} + + + Officer console · Borrower app + +
+
+ ) +} + +/** + * The claim this page is making, checked rather than asserted. + * + *

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)?.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 ( +

+
+ The point +

Both sides are reading one record

+
+
+ + + + + + + + + + + {checks.map((c) => { + const same = c.lender.toLowerCase() === c.app.toLowerCase() + return ( + + + + + + + ) + })} + +
Officer consoleBorrower’s phone
{c.k}{c.lender}{c.app}{same ? 'agree' : 'differ'}
+
+

+ Fetched under two separate tokens — the officer’s and this + borrower’s own. Neither column is copied from the other. +

+
+ ) +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index cc97954..c52e198 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -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() { NABARD HACKATHON + NABARD { + e.currentTarget.style.display = 'none' + }} + />
@@ -235,11 +251,21 @@ export function LoginPage() { {/* ── right: sign-in ───────────────────────────────────── */}
-
- - - UdyamLinc - +
+
+ + + UdyamLinc + +
+ NABARD { + e.currentTarget.style.display = 'none' + }} + />

{hi ? 'अपने उद्यम में जाएँ' : 'Sign in to the console'}

@@ -398,6 +424,23 @@ export function LoginPage() { ? 'आपकी निजी जानकारी कहीं नहीं रखी जाती।' : 'Consent-first, explainable and auditable by design — no personally identifiable data.'}

+ + {/* 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. */} +
+ NABARD { + e.currentTarget.style.display = 'none' + }} + /> + + Built for the NABARD Hackathon 2025 + +
diff --git a/src/styles/demo.css b/src/styles/demo.css new file mode 100644 index 0000000..418278a --- /dev/null +++ b/src/styles/demo.css @@ -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; +}