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,248 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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 { exportCsv } from '../../lib/csv'
|
||||
import { useScope } from './officerScope'
|
||||
import { ScheduleVisitModal } from './ScheduleVisitModal'
|
||||
|
||||
/**
|
||||
* 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 OfficerDashboard() {
|
||||
const navigate = useNavigate()
|
||||
const { block, month } = useScope()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [scheduling, setScheduling] = useState<Enterprise | null>(null)
|
||||
|
||||
const { data } = 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
|
||||
|
||||
const selected = useMemo(
|
||||
() => rows.find((e) => e.enterpriseId === selectedId) ?? rows[0],
|
||||
[rows, selectedId],
|
||||
)
|
||||
|
||||
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,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<button
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
navigate(`/officer/enterprises/${e.enterpriseId}`)
|
||||
}}
|
||||
className="text-slate-400 hover:text-forest px-1"
|
||||
aria-label={`Open ${e.name}`}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rows.length > 40 && (
|
||||
<div className="px-4 py-2 border-t border-slate-100 text-[10.5px] text-slate-400">
|
||||
Showing the 40 highest-risk of {rows.length} — the full register is under Enterprises.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── risk detail ─────────────────────────────────── */}
|
||||
{selected && <RiskDetail enterprise={selected} onSchedule={() => setScheduling(selected)} />}
|
||||
</div>
|
||||
|
||||
{scheduling && <ScheduleVisitModal enterprise={scheduling} onClose={() => setScheduling(null)} />}
|
||||
</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
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user