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 (
+