From 98d3793bdfa48f6bf09c5c54c70842b090a55acc Mon Sep 17 00:00:00 2001 From: LincPay Date: Thu, 6 Aug 2026 15:52:12 +0530 Subject: [PATCH] feat(console): portfolio-by-sector screen with real category icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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) --- package-lock.json | 10 + package.json | 1 + src/App.tsx | 2 + src/features/officer/OfficerShell.tsx | 1 + src/features/officer/SectorPortfolioPage.tsx | 410 +++++++++++++++++++ 5 files changed, 424 insertions(+) create mode 100644 src/features/officer/SectorPortfolioPage.tsx diff --git a/package-lock.json b/package-lock.json index d6c5beb..242836c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b454ade..0339ce2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index b3aa3ed..4bfe52b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } > } /> + } /> } /> } /> } /> diff --git a/src/features/officer/OfficerShell.tsx b/src/features/officer/OfficerShell.tsx index ac1e38e..a9e63ef 100644 --- a/src/features/officer/OfficerShell.tsx +++ b/src/features/officer/OfficerShell.tsx @@ -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' }, diff --git a/src/features/officer/SectorPortfolioPage.tsx b/src/features/officer/SectorPortfolioPage.tsx new file mode 100644 index 0000000..b01e975 --- /dev/null +++ b/src/features/officer/SectorPortfolioPage.tsx @@ -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. + * + *

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. + * + *

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 = { + 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 = { + 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(null) + const [filter, setFilter] = useState('all') + const [term, setTerm] = useState('') + const [selected, setSelected] = useState(null) + + const { data, isError } = useQuery({ + queryKey: ['portfolio-enterprises'], + queryFn: () => get('/officer/enterprises'), + }) + const demo = isError + const all = data ?? ENTERPRISES + + const bySector = useMemo(() => { + const map = new Map() + 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 ( +

+ {/* ── header ─────────────────────────────────────── */} +
+
+

+ {sector ? SECTORS[sector].label : 'Portfolio by sector'} +

+

+ {sector + ? 'Enterprises ranked by composite stress score' + : 'A shock arrives in a sector before it arrives in a borrower — start there.'} +

+
+ + {demo && } +
+ + {/* ── KPI tiles ──────────────────────────────────── */} +
+ + + + +
+ + {/* ── sector grid ────────────────────────────────── */} + {!sector && ( + +
+ {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 ( + + ) + })} +
+
+ )} + + {/* ── drill-down ─────────────────────────────────── */} + {sector && ( + <> +
+ + / + {SECTORS[sector].label} +
+ +
+ {(['all', 'RED', 'WATCH', 'STANDARD'] as Filter[]).map((f) => ( + + ))} + + {rows.length} of {inSector.length} shown + +
+ +
+ +
+ {rows.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {rows.map((e) => ( + setSelected(e.enterpriseId)} + className={`cursor-pointer ${selected === e.enterpriseId ? 'bg-green-soft' : ''}`} + > + + + + + + + ))} + +
EnterpriseBlockScoreStatus6-month trend
+
+ + {e.name.charAt(0)} + + + {e.name} + {e.enterpriseId} + +
+
{e.block}{e.score} + + + +
+ )} +
+
+ + +
+ + )} +
+ ) +} + +// ── pieces ───────────────────────────────────────────────────── + +function Tile({ label, value, dot, note }: { label: string; value: number; dot: string; note: string }) { + return ( +
+
+ + {label} +
+
{value}
+
{note}
+
+ ) +} + +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 ( + + {Icon && } + {BANDS[band].label} + + ) +} + +/** + * 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 — + 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 ( + + {`Stress score over six months, now ${trend[trend.length - 1]}`} + + + + + ) +} + +function DetailPanel({ enterprise }: { enterprise: Enterprise | null }) { + if (!enterprise) { + return ( + +
+ Select a row to see its score, the drivers behind it, and what to do next. +
+
+ ) + } + + const e = enterprise + const dot = e.band === 'RED' ? '#c0392b' : e.band === 'WATCH' ? '#d98324' : '#1f7a4d' + const R = 32 + const C = 2 * Math.PI * R + + return ( + +
+

{e.name}

+

+ {e.block} block · {e.district} district +

+ +
+ + + + +
+
{e.score}
+
Composite Stress Score
+
+ +
+
+
+ +
+ + + + +
+ + + Open full risk file + +
+
+ ) +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +}