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,152 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { get } from '../../lib/api'
|
||||
import type { Band, Enterprise, Sector } from '../../lib/api'
|
||||
import { ActionChip, Empty, GhostBtn, RiskBadge, Sparkline, inputCls } from '../../components/ui/bits'
|
||||
import { SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises'
|
||||
import { scopedEnterprises } from '../../lib/demo/ops'
|
||||
import { inrCompact } from '../../lib/demo/seed'
|
||||
import { exportCsv } from '../../lib/csv'
|
||||
import { useScope } from './officerScope'
|
||||
|
||||
const BAND_FILTERS: { key: Band | 'ALL'; label: string }[] = [
|
||||
{ key: 'ALL', label: 'All bands' },
|
||||
{ key: 'RED', label: 'High risk' },
|
||||
{ key: 'WATCH', label: 'Watch' },
|
||||
{ key: 'STANDARD', label: 'Standard' },
|
||||
]
|
||||
|
||||
/** The full enterprise register — searchable and filterable, feeding the detail page. */
|
||||
export function EnterprisesPage() {
|
||||
const navigate = useNavigate()
|
||||
const { block } = useScope()
|
||||
const [q, setQ] = useState('')
|
||||
const [band, setBand] = useState<Band | 'ALL'>('ALL')
|
||||
const [sector, setSector] = useState<Sector | 'ALL'>('ALL')
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['enterprises', block],
|
||||
queryFn: () => get<Enterprise[]>('/officer/enterprises', { block }),
|
||||
})
|
||||
|
||||
const all = data ?? scopedEnterprises(block)
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
return all.filter(
|
||||
(e) =>
|
||||
(band === 'ALL' || e.band === band) &&
|
||||
(sector === 'ALL' || e.sector === sector) &&
|
||||
(!needle || e.name.toLowerCase().includes(needle) || e.enterpriseId.toLowerCase().includes(needle)),
|
||||
)
|
||||
}, [all, band, sector, q])
|
||||
|
||||
function handleExport() {
|
||||
exportCsv(
|
||||
`udyamlinc-enterprises-${block}.csv`,
|
||||
['Enterprise ID', 'Name', 'Sector', 'Block', 'District', 'Score', 'Band', 'Vintage (months)', 'Outstanding', 'Instalment'],
|
||||
rows.map((e) => [
|
||||
e.enterpriseId,
|
||||
e.name,
|
||||
SECTORS[e.sector].label,
|
||||
e.block,
|
||||
e.district,
|
||||
e.score,
|
||||
e.band,
|
||||
e.vintageMonths,
|
||||
e.loanOutstanding,
|
||||
e.monthlyInstalment,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<input
|
||||
className={`${inputCls} max-w-[260px]`}
|
||||
placeholder="Search enterprise or ID…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className={`${inputCls} max-w-[170px]`}
|
||||
value={sector}
|
||||
onChange={(e) => setSector(e.target.value as Sector | 'ALL')}
|
||||
>
|
||||
<option value="ALL">All sectors</option>
|
||||
{SECTOR_LIST.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{SECTORS[s].label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-1.5">
|
||||
{BAND_FILTERS.map((b) => (
|
||||
<ActionChip key={b.key} active={band === b.key} onClick={() => setBand(b.key)}>
|
||||
{b.label}
|
||||
</ActionChip>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-500 ml-1">{rows.length} enterprises</span>
|
||||
<GhostBtn className="ml-auto" onClick={handleExport}>
|
||||
Export CSV
|
||||
</GhostBtn>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-[11px] overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="ul-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enterprise</th>
|
||||
<th>Sector</th>
|
||||
<th>Block</th>
|
||||
<th className="text-right">Vintage</th>
|
||||
<th className="text-right">Outstanding</th>
|
||||
<th className="text-right">Instalment</th>
|
||||
<th className="text-right">Score</th>
|
||||
<th>Status</th>
|
||||
<th>Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((e) => (
|
||||
<tr
|
||||
key={e.enterpriseId}
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate(`/officer/enterprises/${e.enterpriseId}`)}
|
||||
>
|
||||
<td>
|
||||
<span className="block font-semibold text-slate-700">{e.name}</span>
|
||||
<span className="block font-mono text-[10px] text-slate-400">{e.enterpriseId}</span>
|
||||
</td>
|
||||
<td className="text-slate-600">{SECTORS[e.sector].short}</td>
|
||||
<td className="text-slate-600">{e.block}</td>
|
||||
<td className="text-right text-slate-600">{e.vintageMonths} mo</td>
|
||||
<td className="text-right text-slate-600">{inrCompact(e.loanOutstanding)}</td>
|
||||
<td className="text-right text-slate-600">{inrCompact(e.monthlyInstalment)}</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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!rows.length && <Empty text="No enterprise matches this filter." />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user