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",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"lucide-react": "^1.28.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
@@ -1669,6 +1670,15 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"lucide-react": "^1.28.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { enterpriseSession, officerSession } from './lib/api'
|
|||||||
import { OfficerShell } from './features/officer/OfficerShell'
|
import { OfficerShell } from './features/officer/OfficerShell'
|
||||||
import { OfficerDashboard } from './features/officer/OfficerDashboard'
|
import { OfficerDashboard } from './features/officer/OfficerDashboard'
|
||||||
import { EnterprisesPage } from './features/officer/EnterprisesPage'
|
import { EnterprisesPage } from './features/officer/EnterprisesPage'
|
||||||
|
import { SectorPortfolioPage } from './features/officer/SectorPortfolioPage'
|
||||||
import { EnterpriseDetailPage } from './features/officer/EnterpriseDetailPage'
|
import { EnterpriseDetailPage } from './features/officer/EnterpriseDetailPage'
|
||||||
import { AlertsPage } from './features/officer/AlertsPage'
|
import { AlertsPage } from './features/officer/AlertsPage'
|
||||||
import { ForecastsPage } from './features/officer/ForecastsPage'
|
import { ForecastsPage } from './features/officer/ForecastsPage'
|
||||||
@@ -71,6 +72,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<OfficerDashboard />} />
|
<Route index element={<OfficerDashboard />} />
|
||||||
|
<Route path="portfolio" element={<SectorPortfolioPage />} />
|
||||||
<Route path="enterprises" element={<EnterprisesPage />} />
|
<Route path="enterprises" element={<EnterprisesPage />} />
|
||||||
<Route path="enterprises/:enterpriseId" element={<EnterpriseDetailPage />} />
|
<Route path="enterprises/:enterpriseId" element={<EnterpriseDetailPage />} />
|
||||||
<Route path="alerts" element={<AlertsPage />} />
|
<Route path="alerts" element={<AlertsPage />} />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { OfficerScopeProvider, useScope } from './officerScope'
|
|||||||
|
|
||||||
const NAV: { label: string; to: string; end?: boolean }[] = [
|
const NAV: { label: string; to: string; end?: boolean }[] = [
|
||||||
{ label: 'Dashboard', to: '/officer', end: true },
|
{ label: 'Dashboard', to: '/officer', end: true },
|
||||||
|
{ label: 'Portfolio', to: '/officer/portfolio' },
|
||||||
{ label: 'Enterprises', to: '/officer/enterprises' },
|
{ label: 'Enterprises', to: '/officer/enterprises' },
|
||||||
{ label: 'Risk alerts', to: '/officer/alerts' },
|
{ label: 'Risk alerts', to: '/officer/alerts' },
|
||||||
{ label: 'Forecasts', to: '/officer/forecasts' },
|
{ 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