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>
153 lines
5.4 KiB
TypeScript
153 lines
5.4 KiB
TypeScript
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>
|
|
)
|
|
}
|