feat(console): portfolio-by-sector screen with real category icons
/officer/portfolio — sectors first, then the enterprises inside one. The existing enterprises screen is a flat ranked list. It answers 'who is worst' but not 'where is the problem', and a sector is the unit a shock actually arrives in: a feed price moves against poultry, a milk rate against dairy. So the count of high-risk enterprises per sector is the first thing on the page. Icons come from lucide-react rather than hand-drawn paths — a supervisor scans this grid before reading it, so the shape is doing the work. One literal icon per sector: milk, egg, wheat, scissors, storefront. Drill-down keeps the same shape as the rest of the console: band filter chips, a ranked table with a six-month trend line, and a detail panel with the score donut, the loan position and a link to the full risk file. Only the trend's endpoint dot is coloured; a fully coloured line would read as a severity gradient it does not mean. Reads /officer/enterprises with the house demo fallback and a Demo data pill. Verified against the live backend: 142 enterprises across five sectors, bands and six-month trends all present. tsc, oxlint and npm run build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+10
@@ -11,6 +11,7 @@
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"axios": "^1.18.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"lucide-react": "^1.28.0",
|
||||
"react": "^19.2.7",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.7",
|
||||
@@ -1669,6 +1670,15 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz",
|
||||
"integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"axios": "^1.18.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"lucide-react": "^1.28.0",
|
||||
"react": "^19.2.7",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.7",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { enterpriseSession, officerSession } from './lib/api'
|
||||
import { OfficerShell } from './features/officer/OfficerShell'
|
||||
import { OfficerDashboard } from './features/officer/OfficerDashboard'
|
||||
import { EnterprisesPage } from './features/officer/EnterprisesPage'
|
||||
import { SectorPortfolioPage } from './features/officer/SectorPortfolioPage'
|
||||
import { EnterpriseDetailPage } from './features/officer/EnterpriseDetailPage'
|
||||
import { AlertsPage } from './features/officer/AlertsPage'
|
||||
import { ForecastsPage } from './features/officer/ForecastsPage'
|
||||
@@ -71,6 +72,7 @@ export default function App() {
|
||||
}
|
||||
>
|
||||
<Route index element={<OfficerDashboard />} />
|
||||
<Route path="portfolio" element={<SectorPortfolioPage />} />
|
||||
<Route path="enterprises" element={<EnterprisesPage />} />
|
||||
<Route path="enterprises/:enterpriseId" element={<EnterpriseDetailPage />} />
|
||||
<Route path="alerts" element={<AlertsPage />} />
|
||||
|
||||
@@ -18,6 +18,7 @@ import { OfficerScopeProvider, useScope } from './officerScope'
|
||||
|
||||
const NAV: { label: string; to: string; end?: boolean }[] = [
|
||||
{ label: 'Dashboard', to: '/officer', end: true },
|
||||
{ label: 'Portfolio', to: '/officer/portfolio' },
|
||||
{ label: 'Enterprises', to: '/officer/enterprises' },
|
||||
{ label: 'Risk alerts', to: '/officer/alerts' },
|
||||
{ label: 'Forecasts', to: '/officer/forecasts' },
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ChevronLeft,
|
||||
Egg,
|
||||
Milk,
|
||||
Scissors,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Store,
|
||||
TriangleAlert,
|
||||
Wheat,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { get } from '../../lib/api'
|
||||
import type { Band, Enterprise, Sector } from '../../lib/api'
|
||||
import { Card, Empty, StatusPill } from '../../components/ui/bits'
|
||||
import { BANDS, ENTERPRISES, SECTORS, SECTOR_LIST } from '../../lib/demo/enterprises'
|
||||
|
||||
/**
|
||||
* The portfolio as a bank supervisor thinks about it: sectors first, then the
|
||||
* enterprises inside one.
|
||||
*
|
||||
* <p>The existing enterprises screen is a flat ranked list, which answers "who is
|
||||
* worst" but not "where is the problem". A sector is the unit a shock actually
|
||||
* arrives in — a feed price moves against poultry, a milk rate against dairy —
|
||||
* so the count of high-risk enterprises per sector is the first thing worth
|
||||
* seeing.
|
||||
*
|
||||
* <p>Icons are the real thing rather than hand-drawn paths, because a supervisor
|
||||
* scans this grid before reading it and a recognisable shape is doing the work.
|
||||
*/
|
||||
|
||||
/** One icon per sector. Deliberately literal — this is wayfinding, not decoration. */
|
||||
const SECTOR_ICON: Record<Sector, LucideIcon> = {
|
||||
DAIRY: Milk,
|
||||
POULTRY: Egg,
|
||||
FOOD_PROC: Wheat,
|
||||
HANDICRAFTS: Scissors,
|
||||
RURAL_RETAIL: Store,
|
||||
}
|
||||
|
||||
/** What drives risk in each sector — the sentence under the name. */
|
||||
const SECTOR_HINT: Record<Sector, string> = {
|
||||
DAIRY: 'milk procurement rate · fodder and feed cost',
|
||||
POULTRY: 'egg realisation · maize and soya feed shocks',
|
||||
FOOD_PROC: 'mandi linkage · wheat and gram input prices',
|
||||
HANDICRAFTS: 'buyer concentration · seasonal order flow',
|
||||
RURAL_RETAIL: 'UPI inflow signals · agri-input demand',
|
||||
}
|
||||
|
||||
type Filter = 'all' | Band
|
||||
|
||||
export function SectorPortfolioPage() {
|
||||
const [sector, setSector] = useState<Sector | null>(null)
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [term, setTerm] = useState('')
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ['portfolio-enterprises'],
|
||||
queryFn: () => get<Enterprise[]>('/officer/enterprises'),
|
||||
})
|
||||
const demo = isError
|
||||
const all = data ?? ENTERPRISES
|
||||
|
||||
const bySector = useMemo(() => {
|
||||
const map = new Map<Sector, Enterprise[]>()
|
||||
SECTOR_LIST.forEach((s) => map.set(s, []))
|
||||
all.forEach((e) => map.get(e.sector)?.push(e))
|
||||
map.forEach((list) => list.sort((a, b) => b.score - a.score))
|
||||
return map
|
||||
}, [all])
|
||||
|
||||
// Memoised, or the filter below re-runs on every keystroke against a list
|
||||
// identity that changed for no reason.
|
||||
const inSector = useMemo(
|
||||
() => (sector ? (bySector.get(sector) ?? []) : []),
|
||||
[sector, bySector],
|
||||
)
|
||||
const rows = useMemo(() => {
|
||||
let list = inSector
|
||||
if (filter !== 'all') list = list.filter((e) => e.band === filter)
|
||||
if (term) {
|
||||
const q = term.toLowerCase()
|
||||
list = list.filter((e) => (e.name + ' ' + e.enterpriseId).toLowerCase().includes(q))
|
||||
}
|
||||
return list
|
||||
}, [inSector, filter, term])
|
||||
|
||||
const scope = sector ? inSector : all
|
||||
const counts = {
|
||||
total: scope.length,
|
||||
red: scope.filter((e) => e.band === 'RED').length,
|
||||
watch: scope.filter((e) => e.band === 'WATCH').length,
|
||||
standard: scope.filter((e) => e.band === 'STANDARD').length,
|
||||
}
|
||||
|
||||
const chosen = rows.find((e) => e.enterpriseId === selected) ?? null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── header ─────────────────────────────────────── */}
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<h1 className="text-[15px] font-bold text-forest leading-tight">
|
||||
{sector ? SECTORS[sector].label : 'Portfolio by sector'}
|
||||
</h1>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{sector
|
||||
? 'Enterprises ranked by composite stress score'
|
||||
: 'A shock arrives in a sector before it arrives in a borrower — start there.'}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 bg-white border border-slate-200 rounded-md px-2.5 py-1.5 min-w-[220px]">
|
||||
<Search size={13} className="text-slate-400 shrink-0" />
|
||||
<input
|
||||
className="flex-1 text-[12px] outline-none bg-transparent"
|
||||
placeholder="Search enterprise or ID…"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{demo && <StatusPill status="Demo data" />}
|
||||
</div>
|
||||
|
||||
{/* ── KPI tiles ──────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Tile label="Enterprises" value={counts.total} dot="bg-forest" note={sector ? SECTORS[sector].label : 'across 5 sectors'} />
|
||||
<Tile label="High risk" value={counts.red} dot="bg-risk-red" note="score ≥ 65 · needs action" />
|
||||
<Tile label="Watch" value={counts.watch} dot="bg-amber" note="score 35–64 · monitor weekly" />
|
||||
<Tile label="Standard" value={counts.standard} dot="bg-green" note="score < 35 · stable" />
|
||||
</div>
|
||||
|
||||
{/* ── sector grid ────────────────────────────────── */}
|
||||
{!sector && (
|
||||
<Card title="Sectors" badge="choose one to see its enterprises">
|
||||
<div className="p-4 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{SECTOR_LIST.map((key) => {
|
||||
const list = bySector.get(key) ?? []
|
||||
const red = list.filter((e) => e.band === 'RED').length
|
||||
const watch = list.filter((e) => e.band === 'WATCH').length
|
||||
const Icon = SECTOR_ICON[key]
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setSector(key)
|
||||
setFilter('all')
|
||||
setSelected(null)
|
||||
}}
|
||||
className="text-left bg-white border border-slate-200 rounded-[11px] p-4 flex flex-col gap-2.5
|
||||
hover:border-forest hover:shadow-md hover:-translate-y-0.5 transition-all
|
||||
focus:outline-none focus:ring-2 focus:ring-forest"
|
||||
>
|
||||
<span className="w-11 h-11 rounded-[10px] bg-green-soft text-forest grid place-items-center">
|
||||
<Icon size={24} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span>
|
||||
<b className="text-[13.5px] text-slate-800 block">{SECTORS[key].label}</b>
|
||||
<span className="text-[11px] text-slate-500">
|
||||
{list.length} enterprises · {SECTOR_HINT[key]}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex gap-1.5 flex-wrap">
|
||||
{red > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold text-rose-700 border border-rose-200 bg-rose-50 px-2 py-0.5 rounded-full">
|
||||
<TriangleAlert size={11} /> {red} high risk
|
||||
</span>
|
||||
)}
|
||||
{watch > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[10.5px] font-semibold text-amber-700 border border-amber-200 bg-amber-50 px-2 py-0.5 rounded-full">
|
||||
<ShieldAlert size={11} /> {watch} watch
|
||||
</span>
|
||||
)}
|
||||
{red === 0 && watch === 0 && (
|
||||
<span className="text-[10.5px] text-slate-500 border border-slate-200 px-2 py-0.5 rounded-full">
|
||||
all standard
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── drill-down ─────────────────────────────────── */}
|
||||
{sector && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-[12px] text-slate-500">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSector(null)
|
||||
setSelected(null)
|
||||
setTerm('')
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-forest font-semibold hover:underline"
|
||||
>
|
||||
<ChevronLeft size={13} /> All sectors
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-slate-800 font-semibold">{SECTORS[sector].label}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
{(['all', 'RED', 'WATCH', 'STANDARD'] as Filter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`text-[11.5px] px-3 py-1 rounded-full border transition-colors ${
|
||||
filter === f
|
||||
? 'bg-forest text-white border-forest font-semibold'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:bg-green-soft'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'All' : BANDS[f as Band].label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto text-[11px] text-slate-400">
|
||||
{rows.length} of {inSector.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-3 items-start">
|
||||
<Card title={`${SECTORS[sector].label} enterprises`} badge="ranked by stress score">
|
||||
<div className="p-4 overflow-x-auto">
|
||||
{rows.length === 0 ? (
|
||||
<Empty text="No enterprises match this filter." />
|
||||
) : (
|
||||
<table className="ul-table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enterprise</th>
|
||||
<th>Block</th>
|
||||
<th className="text-right">Score</th>
|
||||
<th>Status</th>
|
||||
<th>6-month trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((e) => (
|
||||
<tr
|
||||
key={e.enterpriseId}
|
||||
onClick={() => setSelected(e.enterpriseId)}
|
||||
className={`cursor-pointer ${selected === e.enterpriseId ? 'bg-green-soft' : ''}`}
|
||||
>
|
||||
<td>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-7 h-7 rounded-lg bg-green-soft text-forest grid place-items-center text-[11px] font-bold shrink-0">
|
||||
{e.name.charAt(0)}
|
||||
</span>
|
||||
<span>
|
||||
<b className="text-[12.5px] text-slate-800 block leading-tight">{e.name}</b>
|
||||
<small className="text-[10.5px] text-slate-400">{e.enterpriseId}</small>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-slate-600">{e.block}</td>
|
||||
<td className="text-right font-bold tabular-nums">{e.score}</td>
|
||||
<td>
|
||||
<BandChip band={e.band} />
|
||||
</td>
|
||||
<td>
|
||||
<TrendLine trend={e.trend} band={e.band} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<DetailPanel enterprise={chosen} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── pieces ─────────────────────────────────────────────────────
|
||||
|
||||
function Tile({ label, value, dot, note }: { label: string; value: number; dot: string; note: string }) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-[11px] px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-[11px] text-slate-500">
|
||||
<span className={`w-2 h-2 rounded-full ${dot}`} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[26px] font-bold text-slate-800 leading-none mt-1">{value}</div>
|
||||
<div className="text-[10.5px] text-slate-400 mt-1">{note}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BandChip({ band }: { band: Band }) {
|
||||
const tone =
|
||||
band === 'RED' ? 'text-rose-700' : band === 'WATCH' ? 'text-amber-700' : 'text-emerald-700'
|
||||
const Icon = band === 'STANDARD' ? null : band === 'RED' ? TriangleAlert : ShieldAlert
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 text-[11.5px] font-semibold ${tone}`}>
|
||||
{Icon && <Icon size={12} />}
|
||||
{BANDS[band].label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Six months of score as a line. The endpoint dot is the only colour — a fully
|
||||
* coloured line would read as a severity gradient it does not mean.
|
||||
*/
|
||||
function TrendLine({ trend, band }: { trend: number[]; band: Band }) {
|
||||
if (!trend || trend.length < 2) return <span className="text-[11px] text-slate-400">—</span>
|
||||
const W = 104
|
||||
const H = 28
|
||||
const P = 4
|
||||
const lo = Math.min(...trend)
|
||||
const hi = Math.max(...trend)
|
||||
const span = Math.max(hi - lo, 8)
|
||||
const x = (i: number) => P + (i * (W - 2 * P)) / (trend.length - 1)
|
||||
const y = (v: number) => H - P - ((v - lo) / span) * (H - 2 * P)
|
||||
const d = 'M' + trend.map((v, i) => `${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(' L')
|
||||
const dot = band === 'RED' ? '#c0392b' : band === 'WATCH' ? '#d98324' : '#1f7a4d'
|
||||
return (
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="block">
|
||||
<title>{`Stress score over six months, now ${trend[trend.length - 1]}`}</title>
|
||||
<path d={d} fill="none" stroke="#c3c2b7" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx={x(trend.length - 1)} cy={y(trend[trend.length - 1])} r={4.5} fill="#fff" />
|
||||
<circle cx={x(trend.length - 1)} cy={y(trend[trend.length - 1])} r={3} fill={dot} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailPanel({ enterprise }: { enterprise: Enterprise | null }) {
|
||||
if (!enterprise) {
|
||||
return (
|
||||
<Card title="Enterprise detail">
|
||||
<div className="p-6 text-center text-[11.5px] text-slate-400">
|
||||
Select a row to see its score, the drivers behind it, and what to do next.
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const e = enterprise
|
||||
const dot = e.band === 'RED' ? '#c0392b' : e.band === 'WATCH' ? '#d98324' : '#1f7a4d'
|
||||
const R = 32
|
||||
const C = 2 * Math.PI * R
|
||||
|
||||
return (
|
||||
<Card title="Enterprise detail" badge={e.enterpriseId}>
|
||||
<div className="p-4">
|
||||
<h3 className="text-[13.5px] font-bold text-slate-800">{e.name}</h3>
|
||||
<p className="text-[11px] text-slate-400 mb-3">
|
||||
{e.block} block · {e.district} district
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<svg width={82} height={82} viewBox="0 0 82 82" role="img" aria-label={`Stress score ${e.score} of 100`}>
|
||||
<circle cx="41" cy="41" r={R} fill="none" stroke="#e2e8f0" strokeWidth={8} />
|
||||
<circle
|
||||
cx="41"
|
||||
cy="41"
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke={dot}
|
||||
strokeWidth={8}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${((C * e.score) / 100).toFixed(1)} ${C.toFixed(1)}`}
|
||||
transform="rotate(-90 41 41)"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-[30px] font-bold text-slate-800 leading-none">{e.score}</div>
|
||||
<div className="text-[10.5px] text-slate-500 mt-0.5">Composite Stress Score</div>
|
||||
<div className="mt-1">
|
||||
<BandChip band={e.band} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="text-[11.5px] space-y-1.5">
|
||||
<Row label="Loan outstanding" value={`₹${e.loanOutstanding.toLocaleString('en-IN')}`} />
|
||||
<Row label="Monthly instalment" value={`₹${e.monthlyInstalment.toLocaleString('en-IN')}`} />
|
||||
<Row label="Vintage" value={`${e.vintageMonths} months`} />
|
||||
<Row label="Owners" value={String(e.ownerCount)} />
|
||||
</dl>
|
||||
|
||||
<a
|
||||
href={`/officer/enterprises/${e.enterpriseId}`}
|
||||
className="mt-4 block text-center bg-forest hover:bg-forest-mid text-white text-[12px] font-semibold py-2 rounded-md transition-colors"
|
||||
>
|
||||
Open full risk file
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-slate-500">{label}</dt>
|
||||
<dd className="text-slate-800 font-semibold tabular-nums">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user