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:
LincPay
2026-08-06 13:30:24 +05:30
co-authored by Claude Opus 5
commit f430f85202
55 changed files with 8691 additions and 0 deletions
+248
View File
@@ -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>
)
}