chore: initial commit — UdyamLinc UI, phases A-E
Vite/React 19 console and enterprise PWA. Officer console screens, offline ledger and sync, data feeds and trip-wire rules screens with freshness chip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { officerSession } from '../../lib/api'
|
||||
import type { Enterprise } from '../../lib/api'
|
||||
import {
|
||||
ActionChip,
|
||||
Card,
|
||||
DriverBar,
|
||||
Empty,
|
||||
GhostBtn,
|
||||
PrimaryBtn,
|
||||
RiskBadge,
|
||||
ScoreDial,
|
||||
Sparkline,
|
||||
StatusPill,
|
||||
} from '../../components/ui/bits'
|
||||
import { ForecastFan } from '../../components/charts/ForecastFan'
|
||||
import { SubScoreRadar } from '../../components/charts/SubScoreRadar'
|
||||
import {
|
||||
DRIVER_META,
|
||||
SECTORS,
|
||||
breachPoint,
|
||||
findEnterprise,
|
||||
forecastFor,
|
||||
reasonCodesFor,
|
||||
stressScoreFor,
|
||||
subScoresFor,
|
||||
tripWiresFor,
|
||||
} from '../../lib/demo/enterprises'
|
||||
import { inr, inrCompact, monthRef } from '../../lib/demo/seed'
|
||||
import { loadLedger } from '../../lib/ledgerStore'
|
||||
import { CATEGORIES } from '../../lib/ledgerStore'
|
||||
import { useVisits } from '../../lib/visitStore'
|
||||
import { ScheduleVisitModal } from './ScheduleVisitModal'
|
||||
|
||||
type Tab = 'OVERVIEW' | 'FORECAST' | 'DRIVERS' | 'LEDGER' | 'VISITS'
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'OVERVIEW', label: 'Overview' },
|
||||
{ key: 'FORECAST', label: 'Forecast' },
|
||||
{ key: 'DRIVERS', label: 'Drivers' },
|
||||
{ key: 'LEDGER', label: 'Ledger' },
|
||||
{ key: 'VISITS', label: 'Visits' },
|
||||
]
|
||||
|
||||
/** Everything the officer needs about one enterprise, in five tabs. */
|
||||
export function EnterpriseDetailPage() {
|
||||
const { enterpriseId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState<Tab>('OVERVIEW')
|
||||
const [scheduling, setScheduling] = useState(false)
|
||||
|
||||
const e = findEnterprise(enterpriseId)
|
||||
if (!e) return <Empty text={`No enterprise found for ${enterpriseId}.`} />
|
||||
|
||||
const points = forecastFor(e)
|
||||
const breach = breachPoint(points)
|
||||
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── header ─────────────────────────────────────────── */}
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<button onClick={() => navigate(-1)} className="text-[11.5px] text-slate-400 hover:text-forest mt-1">
|
||||
← Back
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-[19px] font-bold text-forest">{e.name}</h1>
|
||||
<RiskBadge band={e.band} />
|
||||
</div>
|
||||
<div className="text-[11.5px] text-slate-500">
|
||||
{e.enterpriseId} · {SECTORS[e.sector].label} · {e.block} block, {e.district} district ·{' '}
|
||||
{e.vintageMonths} months on book
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<PrimaryBtn onClick={() => setScheduling(true)}>Schedule visit</PrimaryBtn>
|
||||
<GhostBtn onClick={() => setTab('FORECAST')}>Full forecast</GhostBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{breach && (
|
||||
<div className="bg-risk-soft border border-risk-red/20 rounded-lg px-4 py-2.5 mb-4 flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-risk-red text-white grid place-items-center text-[12px] font-bold shrink-0">
|
||||
!
|
||||
</span>
|
||||
<p className="text-[12px] text-slate-700">
|
||||
Downside case fails to cover obligations from <b>{breachRef?.fullEn}</b> — projected gap{' '}
|
||||
<b className="text-risk-red">{inr(breach.obligation - breach.p10)}</b> at P10.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1.5 mb-4">
|
||||
{TABS.map((t) => (
|
||||
<ActionChip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</ActionChip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'OVERVIEW' && <OverviewTab e={e} />}
|
||||
{tab === 'FORECAST' && <ForecastTab e={e} />}
|
||||
{tab === 'DRIVERS' && <DriversTab e={e} />}
|
||||
{tab === 'LEDGER' && <LedgerTab e={e} />}
|
||||
{tab === 'VISITS' && <VisitsTab e={e} />}
|
||||
|
||||
{scheduling && <ScheduleVisitModal enterprise={e} onClose={() => setScheduling(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewTab({ e }: { e: Enterprise }) {
|
||||
const score = stressScoreFor(e)
|
||||
const reasons = reasonCodesFor(e)
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card title="Composite Stress Score" badge={score.modelVersion}>
|
||||
<div className="p-4 grid place-items-center">
|
||||
<ScoreDial score={e.score} band={e.band} size={132} />
|
||||
<div className="mt-3 w-full">
|
||||
<Sparkline values={e.trend} band={e.band} width={240} height={44} />
|
||||
<div className="text-[10px] text-slate-400 text-center mt-1">6-month score history</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Institutional record">
|
||||
<dl className="p-4 space-y-2.5 text-[12px]">
|
||||
{[
|
||||
['Loan outstanding', inr(e.loanOutstanding)],
|
||||
['Monthly instalment', inr(e.monthlyInstalment)],
|
||||
['Sector pack', SECTORS[e.sector].label],
|
||||
['Block / district', `${e.block} / ${e.district}`],
|
||||
['Owners on record', String(e.ownerCount)],
|
||||
['Vintage', `${e.vintageMonths} months`],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-3">
|
||||
<dt className="text-slate-500">{k}</dt>
|
||||
<dd className="font-semibold text-slate-700">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card title="Why this score" badge="SHAP">
|
||||
<div className="p-4 space-y-3">
|
||||
{reasons.map((r) => (
|
||||
<div key={r.driver} className="border-l-[3px] border-amber pl-2.5">
|
||||
<div className="text-[11px] font-semibold text-slate-700">
|
||||
{r.driver} <span className="font-mono text-slate-400">· {r.weight}</span>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-slate-600 leading-snug">{r.sentenceEn}</p>
|
||||
<p className="text-[11px] text-slate-400 leading-snug">{r.sentenceHi}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ForecastTab({ e }: { e: Enterprise }) {
|
||||
const points = forecastFor(e)
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card title="Net cash position — 6-month quantile forecast" className="lg:col-span-2">
|
||||
<div className="p-4">
|
||||
<ForecastFan points={points} height={280} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Month by month">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="ul-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th className="text-right">P10</th>
|
||||
<th className="text-right">P50</th>
|
||||
<th className="text-right">Due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{points.map((p) => {
|
||||
const short = p.p10 < p.obligation
|
||||
return (
|
||||
<tr key={p.month} className={short ? '!bg-risk-soft' : ''}>
|
||||
<td className="font-semibold text-slate-700">{p.label}</td>
|
||||
<td className={`text-right ${short ? 'text-risk-red font-bold' : 'text-slate-600'}`}>
|
||||
{inrCompact(p.p10)}
|
||||
</td>
|
||||
<td className="text-right text-slate-600">{inrCompact(p.p50)}</td>
|
||||
<td className="text-right text-slate-500">{inrCompact(p.obligation)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="px-3.5 py-2 text-[10.5px] text-slate-400 border-t border-slate-100">
|
||||
Rows in red are months where the downside case does not cover what is due.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DriversTab({ e }: { e: Enterprise }) {
|
||||
const sub = subScoresFor(e)
|
||||
const wires = tripWiresFor(e)
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card title="Five risk families" badge={`${SECTORS[e.sector].label} weights`}>
|
||||
<div className="p-4">
|
||||
<SubScoreRadar sub={sub} band={e.band} />
|
||||
<div className="space-y-2 mt-4">
|
||||
{DRIVER_META.map((d) => (
|
||||
<DriverBar key={d.key} label={d.label} weight={sub[d.key]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Deterministic trip-wires" badge="rule engine">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="ul-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Rule</th>
|
||||
<th>Threshold</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{wires.map((w) => (
|
||||
<tr key={w.code}>
|
||||
<td className="font-mono text-[10.5px] text-slate-500">{w.code}</td>
|
||||
<td>
|
||||
<span className="block font-semibold text-slate-700">{w.label}</span>
|
||||
<span className="block text-[10.5px] text-slate-400">{w.detail}</span>
|
||||
</td>
|
||||
<td className="font-mono text-[10.5px] text-slate-500">{w.threshold}</td>
|
||||
<td>
|
||||
<span className={`pill ${w.fired ? 'pill-bad' : 'pill-mut'}`}>{w.fired ? 'Fired' : 'Clear'}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="px-3.5 py-2 text-[10.5px] text-slate-400 border-t border-slate-100">
|
||||
Trip-wires fire independently of the model, so every alert stays auditable.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LedgerTab({ e }: { e: Enterprise }) {
|
||||
const entries = loadLedger(e.enterpriseId).slice(0, 40)
|
||||
const label = (kind: string, code: string) =>
|
||||
CATEGORIES[kind as keyof typeof CATEGORIES]?.find((c) => c.code === code)?.en ?? code
|
||||
|
||||
return (
|
||||
<Card title="Three-tap ledger — last 40 entries" badge="owner-reported">
|
||||
<div className="overflow-x-auto max-h-[520px] overflow-y-auto">
|
||||
<table className="ul-table">
|
||||
<thead className="sticky top-0">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Category</th>
|
||||
<th className="text-right">Amount</th>
|
||||
<th>Sync</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="text-slate-600">{l.entryDate}</td>
|
||||
<td>
|
||||
<span className={`pill ${l.kind === 'IN' ? 'pill-ok' : l.kind === 'OUT' ? 'pill-bad' : 'pill-info'}`}>
|
||||
{l.kind === 'IN' ? 'Money in' : l.kind === 'OUT' ? 'Money out' : 'Stock'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-slate-600">{label(l.kind, l.category)}</td>
|
||||
<td className="text-right font-semibold text-slate-700">{inr(l.amount)}</td>
|
||||
<td>
|
||||
<StatusPill status={l.syncState} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!entries.length && <Empty text="This enterprise has not recorded any entries yet." />}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function VisitsTab({ e }: { e: Enterprise }) {
|
||||
const { visits } = useVisits('ALL', officerSession()?.fullName ?? 'Field Officer')
|
||||
const mine = visits.filter((v) => v.enterpriseId === e.enterpriseId)
|
||||
|
||||
return (
|
||||
<Card title="Field visits and interventions">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="ul-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Visit</th>
|
||||
<th>Action</th>
|
||||
<th>Due</th>
|
||||
<th>State</th>
|
||||
<th>Outcome</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mine.map((v) => (
|
||||
<tr key={v.visitId}>
|
||||
<td className="font-mono text-[10.5px] text-slate-500">{v.visitId}</td>
|
||||
<td className="text-slate-700 whitespace-normal">{v.action}</td>
|
||||
<td className="text-slate-600">{v.dueDate}</td>
|
||||
<td>
|
||||
<StatusPill status={v.state} />
|
||||
</td>
|
||||
<td className="text-slate-500 whitespace-normal max-w-[320px]">{v.outcome ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!mine.length && <Empty text="No visit scheduled for this enterprise yet." />}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user