diff --git a/src/components/ui/MenuSelect.tsx b/src/components/ui/MenuSelect.tsx new file mode 100644 index 0000000..807438a --- /dev/null +++ b/src/components/ui/MenuSelect.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, useState } from 'react' +import { Check, ChevronDown } from 'lucide-react' + +/** + * A select that is a menu, not a native `` takes its type, its row height and + * its open panel from the operating system, so it sits beside styled menus + * looking like a different application. This renders the same panel the header + * navigation uses, so "choose a block" and "choose a screen" look like the same + * gesture — which they are. + * + *

Behaviour follows the header menus deliberately: opens on click rather than + * hover, closes on outside click, Escape and selection, and keeps focus on the + * trigger so the keyboard path is unchanged. A native select is still the right + * answer on the phone-sized PWA, and this is not used there. + */ +export function MenuSelect({ + value, + options, + onChange, + label, + align = 'right', + minWidth = 132, +}: { + value: T + options: { value: T; label: string; hint?: string }[] + onChange: (value: T) => void + /** Accessible name — the control shows the current value, not this. */ + label: string + align?: 'left' | 'right' + minWidth?: number +}) { + const [open, setOpen] = useState(false) + const root = useRef(null) + const current = options.find((o) => o.value === value) + + useEffect(() => { + if (!open) return + const onDown = (e: MouseEvent) => { + if (!root.current?.contains(e.target as Node)) setOpen(false) + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + return ( +

+ + + {open && ( +
+
+ {label} +
+ {options.map((o) => { + const selected = o.value === value + return ( + + ) + })} +
+ )} +
+ ) +} diff --git a/src/components/ui/SourceCard.tsx b/src/components/ui/SourceCard.tsx new file mode 100644 index 0000000..0b924cc --- /dev/null +++ b/src/components/ui/SourceCard.tsx @@ -0,0 +1,164 @@ +import { Link } from 'react-router-dom' +import { CircleDashed, Database, Download, Gauge, Layers, Siren } from 'lucide-react' +import { Card } from './bits' +import { STATUS_HELP, STATUS_LABEL, STATUS_PILL, STATUS_TILE } from '../../lib/sourceStatus' +import type { SourceGroupSpec, SourceSpec, SourceStatus } from '../../lib/sourceStatus' + +/** + * One visual language for every way data enters the platform. + * + *

Lives here rather than beside either page because both consoles render the + * same inventory and must render it identically. A bank officer and a NABARD + * supervisor looking at the same source have to see the same card — if the two + * drifted, the apex tier would eventually be told a source was live that the + * bank tier knew was not. + */ + +/* ------------------------------------------------------------------ */ + +export function SourceCard({ s }: { s: SourceSpec }) { + const dim = s.status === 'NOT_CONNECTED' + // Fall back to a dashed ring rather than an arbitrary glyph: a source with no + // icon assigned should look unfinished, not look like something it is not. + const Icon = s.icon ?? CircleDashed + return ( +

+ {/* identity — tile tinted by status, so a grid reads before it is read */} +
+ + + +
+
+ {s.name} +
+
{s.kind}
+
+ + {STATUS_LABEL[s.status]} + +
+ + {/* the four facts, in the same order on every card */} +
+
Arrives
+
{s.mechanism}
+ +
Auth
+
{s.auth}
+ +
Writes
+
{s.writes}
+ +
Cadence
+
{s.cadence}
+
+ + {/* what it feeds — the reason the source is on the book at all */} + {s.feeds.length > 0 && ( +
+ {s.feeds.map((f) => ( + + {f} + + ))} +
+ )} + + {s.note &&

{s.note}

} + + {/* last activity pinned to the bottom so cards align across a row */} +
+ {s.activity} + {s.link && ( + + {s.link.label} → + + )} +
+
+ ) +} + +/** A titled band of cards. Groups follow the problem statement's data families. */ +export function SourceGroup({ icon: Icon, letter, title, blurb, sources }: SourceGroupSpec) { + const live = sources.filter((s) => s.status === 'LIVE').length + return ( + + {Icon && } + {blurb} + + } + > +
+ {sources.map((s) => ( + + ))} +
+
+ ) +} + +/** The three words the inventory is written in, defined once where both pages show it. */ +export function StatusLegend() { + return ( +
+ Status means + {(['LIVE', 'SIMULATED', 'NOT_CONNECTED'] as SourceStatus[]).map((s) => ( + + {STATUS_LABEL[s]} + {STATUS_HELP[s]} + + ))} +
+ ) +} + +/** Where the sources land, in one strip. Step 1 is the subject of both pages. */ +export function PipelineStrip() { + const steps = [ + { n: '1', label: 'Sources', sub: 'API · CBS · UPI · upload · ledger', icon: Database }, + { n: '2', label: 'Ingestion', sub: 'validated, deduped, stamped', icon: Download }, + { n: '3', label: 'Feature store', sub: '15 features, point-in-time', icon: Layers }, + { n: '4', label: 'Score & forecast', sub: 'stress score · P10/P50/P90', icon: Gauge }, + { n: '5', label: 'Alerts', sub: 'trip-wires · officer playbook', icon: Siren }, + ] + return ( +
+ {steps.map((s, i) => ( +
+
+
+ + + + + {s.label} + + {s.n} +
+
{s.sub}
+
+ {i < steps.length - 1 && ( + → + )} +
+ ))} +
+ ) +} diff --git a/src/features/nabard/NabardShell.tsx b/src/features/nabard/NabardShell.tsx index b8f6e2a..5953eb8 100644 --- a/src/features/nabard/NabardShell.tsx +++ b/src/features/nabard/NabardShell.tsx @@ -1,5 +1,5 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom' -import { Building2, Gauge, Landmark, MapPinned, ShieldCheck, Siren } from 'lucide-react' +import { Building2, Database, Gauge, Landmark, LogOut, MapPinned, ShieldCheck, Siren } from 'lucide-react' import { clearSessions, isRegionalOfficeRole, officerSession } from '../../lib/api' const NAV = [ @@ -8,6 +8,7 @@ const NAV = [ { label: 'Partner banks', to: '/nabard/banks', icon: Landmark }, { label: 'Early warning', to: '/nabard/digest', icon: Siren }, { label: 'Institutions', to: '/nabard/institutions', icon: Building2 }, + { label: 'Data coverage', to: '/nabard/sources', icon: Database }, ] /** @@ -17,75 +18,96 @@ const NAV = [ * branch officer are not two ranks of one job — they see different things and * may do different things, and a portal that looked identical would invite the * assumption that the apex user is simply a bank user with more rights. + * + *

Both now sit under a fixed header, because the design system has one, but + * the apex tier keeps a flat row of six rather than the console's grouped + * dropdowns. Six is under the count where grouping helps, and a supervisor + * reading across offices should see the whole of their own vocabulary at once + * rather than open a menu to remember what is in it. */ export function NabardShell() { const navigate = useNavigate() const session = officerSession() + const tier = isRegionalOfficeRole(session?.role) ? 'NABARD Regional Office' : 'NABARD Head Office' return ( -

- - -
+
+ +
) } diff --git a/src/features/nabard/SourceCoveragePage.tsx b/src/features/nabard/SourceCoveragePage.tsx new file mode 100644 index 0000000..6628a0a --- /dev/null +++ b/src/features/nabard/SourceCoveragePage.tsx @@ -0,0 +1,300 @@ +import { useQuery } from '@tanstack/react-query' +import { + CalendarClock, + CloudRain, + IndianRupee, + Landmark, + MapPinned, + Thermometer, + Wheat, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { get } from '../../lib/api' +import type { + ConnectorProfileRow, + ConnectorSyncRun, + Freshness, + IngestionRun, + InstitutionRow, + ManualEntryBatch, +} from '../../lib/api' +import { Card, KpiTile, StatusPill } from '../../components/ui/bits' +import { PipelineStrip, SourceGroup, StatusLegend } from '../../components/ui/SourceCard' +import type { SourceStatus } from '../../lib/sourceStatus' +import { buildInventory, mandateCoverage } from '../../lib/sourceInventory' +import { demoFreshness, demoIngestionRuns } from '../../lib/demo/feeds' +import { demoProfiles, demoRuns } from '../../lib/demo/connectors' +import { demoBatches } from '../../lib/demo/manualEntry' +import { demoInstitutions } from '../../lib/demo/apex' + +/** + * What the platform is reading, seen from the apex tier. + * + *

Supervision is a question about coverage, not about operation. NABARD does + * not run an ingestion job or map a bank's columns, so this page carries no + * controls and no drill-through into the operational screens — it answers "is + * the network observing what it claims to observe", and stops there. + * + *

It is also the one apex screen where showing a gap is the entire purpose. + * A supervisor who cannot see that consented transaction data has never been + * connected will read an alert count as complete when it is one family short. + * + *

The tenancy rule is unchanged and needs no exception here. Every + * figure on this page describes a feed, a table or an institution's + * connection — never a borrower. There is no row to suppress because there is + * no borrower row in the question. + */ +/** One observation-reach figure. Amber tile when the number is short of its own target. */ +function ReachTile({ + icon: Icon, + label, + value, + sub, + warn = false, +}: { + icon: LucideIcon + label: string + value: React.ReactNode + sub: string + warn?: boolean +}) { + return ( +

+ + + +
+
{label}
+
{value}
+
{sub}
+
+
+ ) +} + +export function SourceCoveragePage() { + const freshnessQ = useQuery({ + queryKey: ['freshness'], + queryFn: () => get('/admin/ingestion/freshness'), + }) + const runsQ = useQuery({ + queryKey: ['ingestion-runs'], + queryFn: () => get('/admin/ingestion/runs', { limit: 20 }), + }) + const profilesQ = useQuery({ + queryKey: ['connector-profiles'], + queryFn: () => get('/admin/connectors/profiles'), + }) + const connectorRunsQ = useQuery({ + queryKey: ['connector-runs'], + queryFn: () => get('/admin/connectors/runs', { limit: 30 }), + }) + const batchesQ = useQuery({ + queryKey: ['manual-batches'], + queryFn: () => get('/admin/manual/batches', { limit: 20 }), + }) + const institutionsQ = useQuery({ + queryKey: ['apex-institutions'], + queryFn: () => get('/nabard/institutions'), + }) + + const demo = + freshnessQ.isError || + runsQ.isError || + profilesQ.isError || + connectorRunsQ.isError || + batchesQ.isError || + institutionsQ.isError + + const freshness = freshnessQ.data ?? demoFreshness() + const profiles = profilesQ.data ?? demoProfiles() + const institutions = (institutionsQ.data ?? demoInstitutions()).filter((i) => i.type !== 'APEX') + + const inv = buildInventory({ + freshness, + runs: runsQ.data ?? demoIngestionRuns(), + profiles, + connectorRuns: connectorRunsQ.data ?? demoRuns(), + batches: batchesQ.data ?? demoBatches(), + // The apex tier gets the inventory, not the controls. + withLinks: false, + }) + + const count = (s: SourceStatus) => inv.all.filter((x) => x.status === s).length + const mandate = mandateCoverage(inv) + + // Keyed by the family name the builder returns, so a renamed family loses its + // glyph rather than silently wearing the wrong one. + const FAMILY_ICON: Record = { + 'Financial records': Landmark, + 'Digital transaction proxies': IndianRupee, + 'Market intelligence': Wheat, + 'Climate data': CloudRain, + } + + // A lender counts as connected when it has a profile that is not a simulated + // dialect. Anything else — a simulated adapter, no profile at all — is a bank + // whose book reaches the platform by upload, and a supervisor should see that. + const connected = new Set( + profiles.filter((p) => !p.simulated && p.institutionCode).map((p) => p.institutionCode as string), + ) + const lendersConnected = institutions.filter((i) => connected.has(i.code)).length + + const marketAges = freshness + .map((f) => f.marketStalenessDays) + .filter((d): d is number => d != null) + const freshDistricts = marketAges.filter((d) => d <= 10).length + + return ( +
+
+
+

Data source coverage

+

+ What the network is reading, and what it is not. Read-only: the apex tier supervises whether a + source is connected and never operates one. Every figure here describes a feed or an + institution’s connection — no borrower appears in this question, so none is suppressed from it. +

+
+ {demo && } +
+ + + +
+ + + + + +
+ + {/* ── the mandate, family by family ─────────────────────────── */} + + Financial records · transaction proxies · market intelligence · climate + + } + > + + + + + + + + + + + {mandate.map((m) => { + const pct = m.total === 0 ? 0 : Math.round((m.live / m.total) * 100) + const tone = m.live === 0 ? 'bg-risk-red' : pct >= 60 ? 'bg-green' : 'bg-amber' + const Icon = FAMILY_ICON[m.family] + const tile = + m.live === 0 ? 'bg-risk-soft text-risk-red' : 'bg-green-soft text-forest' + return ( + + + + + + + ) + })} + +
Data familyWhat stands behind itLiveCoverage
+ + {Icon && ( + + + + )} + {m.family} + + {m.detail} + {m.live}/{m.total} + + + + + + {pct}% + +
+

+ Consented transaction data is the one family with nothing live behind it. Account Aggregator + registration under Sahamati is an institutional agreement rather than an engineering task, so the + table, the query and the feature are built and the trip-wire that reads them reports{' '} + SKIPPED_MISSING rather than quietly passing. +

+
+ + {/* ── observation reach ─────────────────────────────────────── */} + +
+ + + {freshDistricts} + /{inv.districtsCovered} + + } + sub={ + inv.worstMarketAge != null + ? `oldest observation ${inv.worstMarketAge} days` + : 'nothing ingested yet' + } + warn={freshDistricts < inv.districtsCovered} + /> + +
+

+ A trip-wire refuses to fire on data older than its tolerance, so a district falling behind + withholds alerts rather than issuing stale ones. That is why staleness is a supervisory figure and + not an operational footnote. +

+
+ + + + {inv.groups.map((g) => ( + + ))} + +

+ Every stored row carries its origin, and a simulated row can never overwrite a real one. Coverage is + reported in three buckets — real, simulated and unknown — and unknown is never folded into real. +

+
+ ) +} diff --git a/src/features/officer/DataSourcesPage.tsx b/src/features/officer/DataSourcesPage.tsx new file mode 100644 index 0000000..79fc43c --- /dev/null +++ b/src/features/officer/DataSourcesPage.tsx @@ -0,0 +1,116 @@ +import { useQuery } from '@tanstack/react-query' +import { get } from '../../lib/api' +import type { + ConnectorProfileRow, + ConnectorSyncRun, + Freshness, + IngestionRun, + ManualEntryBatch, +} from '../../lib/api' +import { KpiTile } from '../../components/ui/bits' +import { PipelineStrip, SourceGroup, StatusLegend } from '../../components/ui/SourceCard' +import type { SourceStatus } from '../../lib/sourceStatus' +import { buildInventory } from '../../lib/sourceInventory' +import { demoFreshness, demoIngestionRuns } from '../../lib/demo/feeds' +import { demoProfiles, demoRuns } from '../../lib/demo/connectors' +import { demoBatches } from '../../lib/demo/manualEntry' + +/** + * Every way data enters this system, on one screen, in one visual language. + * + *

The console already had three screens that each answer part of the + * question — `/officer/feeds` for the public APIs, `/officer/integration` for + * core banking, `/officer/data-entry` for uploads. Nobody arriving at the + * platform for the first time can hold those three together, and the honest + * answer to "where does your data come from" is a single inventory or it is not + * an answer. + * + *

So every source is rendered through one card with the same four facts in + * the same four places: how it arrives, what it writes, how often, and what it + * last did. The only thing that varies between a live government API and an + * unbuilt consent feed is the status pill — which is the point. A source that is + * not connected occupies the same space as one that is, rather than being absent + * and unnoticed. + */ +export function DataSourcesPage() { + const freshnessQ = useQuery({ + queryKey: ['freshness'], + queryFn: () => get('/admin/ingestion/freshness'), + }) + const runsQ = useQuery({ + queryKey: ['ingestion-runs'], + queryFn: () => get('/admin/ingestion/runs', { limit: 20 }), + }) + const profilesQ = useQuery({ + queryKey: ['connector-profiles'], + queryFn: () => get('/admin/connectors/profiles'), + }) + const connectorRunsQ = useQuery({ + queryKey: ['connector-runs'], + queryFn: () => get('/admin/connectors/runs', { limit: 30 }), + }) + const batchesQ = useQuery({ + queryKey: ['manual-batches'], + queryFn: () => get('/admin/manual/batches', { limit: 20 }), + }) + + const demo = + freshnessQ.isError || runsQ.isError || profilesQ.isError || connectorRunsQ.isError || batchesQ.isError + + const inv = buildInventory({ + freshness: freshnessQ.data ?? demoFreshness(), + runs: runsQ.data ?? demoIngestionRuns(), + profiles: profilesQ.data ?? demoProfiles(), + connectorRuns: connectorRunsQ.data ?? demoRuns(), + batches: batchesQ.data ?? demoBatches(), + withLinks: true, + }) + + const count = (s: SourceStatus) => inv.all.filter((x) => x.status === s).length + + return ( +

+
+
+

Data sources

+

+ Every route by which data enters UdyamLinc — public APIs, core banking extracts, consented + transaction aggregates, manual uploads and the borrower’s own ledger. A source that is not yet + connected is shown here rather than left out, because the gap is part of the answer. +

+
+ {demo && Demo data} +
+ + + +
+ + + + + +
+ + + + {inv.groups.map((g) => ( + + ))} + +

+ Every stored row carries its origin, and a simulated row can never overwrite a real one. Coverage is + reported in three buckets — real, simulated and unknown — and unknown is never folded into real. +

+
+ ) +} diff --git a/src/features/officer/ForecastsPage.tsx b/src/features/officer/ForecastsPage.tsx index 789c5f6..826612e 100644 --- a/src/features/officer/ForecastsPage.tsx +++ b/src/features/officer/ForecastsPage.tsx @@ -1,4 +1,5 @@ -import { Card, KpiTile } from '../../components/ui/bits' +import { Card, Empty, KpiTile, Unavailable } from '../../components/ui/bits' +import { useTenantEnterprises } from './useTenantData' import { ForecastFan } from '../../components/charts/ForecastFan' import { BandMigrationBar } from '../../components/charts/BandMigrationBar' import { bandMigration, portfolioForecast, sectorRollup } from '../../lib/demo/ops' @@ -12,9 +13,15 @@ import { useScope } from './officerScope' */ export function ForecastsPage() { const { block } = useScope() - const aggregate = portfolioForecast(block) - const sectors = sectorRollup(block) - const migration = bandMigration(block) + // Aggregates are computed over this bank's own borrowers. They used to be + // derived from the unscoped demo book, so a portfolio chart mixed every bank. + const { rows: book, unavailable, retry } = useTenantEnterprises(block) + const aggregate = portfolioForecast(block, book) + const sectors = sectorRollup(block, book) + const migration = bandMigration(block, book) + + if (unavailable) return + if (!aggregate.length) return const worst = aggregate.reduce((a, p) => (p.p10 - p.obligation < a.p10 - a.obligation ? p : a), aggregate[0]) const covered = aggregate.filter((p) => p.p10 >= p.obligation).length diff --git a/src/features/officer/IntegrationPage.tsx b/src/features/officer/IntegrationPage.tsx index b7d9259..dd12e93 100644 --- a/src/features/officer/IntegrationPage.tsx +++ b/src/features/officer/IntegrationPage.tsx @@ -210,10 +210,12 @@ export function IntegrationPage() {

What is real and what is not. The file-drop adapter reads an actual extract from a directory a bank delivers to, and its rows are validated, staged and written like any other. - The Finacle, FLEXCUBE, BaNCS and in-house-LMS adapters are simulated: they generate an - extract in each core's own column dialect so the mapping layer can be exercised end to end. - A live link to any of them is agreed per bank during onboarding — the schema conversation is - the work, and this screen is what that conversation configures. + The CBSNexora adapter is simulated: no CBSNexora instance feeds this deployment, so it + generates an extract in that platform's own field names to exercise the mapping layer end to + end. Where a lender runs a different core, no product is named here — the per-profile mapping + absorbs their column names, which is precisely why the platform never needs to know. A live + link is agreed per bank during onboarding; the schema conversation is the work, and this + screen is what that conversation configures.

diff --git a/src/features/officer/OfficerShell.tsx b/src/features/officer/OfficerShell.tsx index 1bfcf64..a3cd784 100644 --- a/src/features/officer/OfficerShell.tsx +++ b/src/features/officer/OfficerShell.tsx @@ -1,36 +1,224 @@ -import { NavLink, Outlet, useNavigate } from 'react-router-dom' +import { useEffect, useRef, useState } from 'react' +import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' +import { + Bell, + Building2, + ChevronDown, + Database, + FileSpreadsheet, + FolderInput, + Gauge, + LineChart, + LogOut, + MapPin, + PencilRuler, + Search, + Settings, + Siren, + Store, + Table2, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' import { clearSessions, get, officerSession } from '../../lib/api' import type { Freshness } from '../../lib/api' import { demoFreshness, worstStaleness } from '../../lib/demo/feeds' import { UdyamMark } from '../../components/ui/bits' +import { MenuSelect } from '../../components/ui/MenuSelect' import { BLOCKS } from '../../lib/demo/enterprises' -import { ALL_BLOCKS, alertsFor, weeklyVisitProgress } from '../../lib/demo/ops' +import { ALL_BLOCKS } from '../../lib/demo/ops' +import { useTenantAlerts, useTenantVisits } from './useTenantData' import { monthRef, monthWindow } from '../../lib/demo/seed' import { OfficerScopeProvider, useScope } from './officerScope' /* - * Field officer console shell — dark forest sidebar on the left, identity bar - * across the top, work area on the right. This is the layout on the deck's - * wireframe slide; it deliberately differs from BharatLinc's horizontal MegaNav - * because the console is navigated by module, not by tab. + * Field officer console shell — fixed header, grouped dropdown navigation. + * + * The console had thirteen flat items down a rail, which is more than anyone + * scans: "Data feeds", "Data entry", "Data sources" and "Core banking" are one + * subject wearing four labels, and nothing in a flat list said so. They are now + * four menus that name the *question* a screen answers — where does the book + * stand, what is going wrong, what are we reading — with the screens under them. + * + * Fixed rather than sticky-on-scroll: an officer moves between screens far more + * often than they scroll one, and a header that comes and goes costs a re-aim + * every time. The scope row underneath it is part of the same furniture, because + * block and month change what every screen below means. */ -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' }, - { label: 'Field visits', to: '/officer/visits' }, - { label: 'Trip-wires', to: '/officer/rules' }, - { label: 'Data feeds', to: '/officer/feeds' }, - { label: 'Data entry', to: '/officer/data-entry' }, - { label: 'Core banking', to: '/officer/integration' }, - { label: 'Reports', to: '/officer/reports' }, - { label: 'Settings', to: '/officer/settings' }, +/** + * Icon-tile colours. + * + *

Written as literal class pairs rather than composed at runtime, because + * Tailwind scans source text — a class built from a variable is a class that + * never reaches the stylesheet. + * + *

Colour here is an aid to recognition, not information: the palette repeats + * across menus and carries no meaning, so nobody can read a hue as a severity. + * The risk scale stays reserved for bands, and only the alerts item borrows from + * it — because there it is the meaning. + */ +const TONES = { + blue: 'bg-[#e7f0f9] text-[#2a71b8] border-[#cfe1f3]', + green: 'bg-[#e7f6ee] text-[#1f8b50] border-[#cfeadd]', + teal: 'bg-[#e3f3f6] text-[#0f7d92] border-[#c9e7ec]', + indigo: 'bg-[#eceefa] text-[#4d5bb0] border-[#d9ddf3]', + violet: 'bg-[#f1ecf8] text-[#6f5ba0] border-[#e2d9f0]', + amber: 'bg-[#fdf3e6] text-[#b8710f] border-[#f7e3c6]', + rose: 'bg-[#fbeae8] text-[#b5342a] border-[#f5d4d0]', +} as const + +type Tone = keyof typeof TONES + +interface NavItem { + label: string + to: string + icon: LucideIcon + tone: Tone + hint: string + end?: boolean + /** Renders the open-alert count. Only one item claims it. */ + badge?: 'alerts' +} + +interface NavGroup { + label: string + icon: LucideIcon + items: NavItem[] +} + +/** Direct links sit beside the menus rather than inside a menu of one. */ +const HOME: NavItem = { + label: 'Dashboard', + to: '/officer', + icon: Gauge, + tone: 'blue', + hint: 'Where the book stands today', + end: true, +} + +const REPORTS: NavItem = { + label: 'Reports', + to: '/officer/reports', + icon: Table2, + tone: 'violet', + hint: 'Supervisory pack and exports', +} + +const GROUPS: NavGroup[] = [ + { + label: 'Portfolio', + icon: Store, + items: [ + { + label: 'Sector packs', + to: '/officer/portfolio', + icon: Store, + tone: 'green', + hint: 'Dairy, poultry, food processing, handicrafts and rural retail — each modelled separately.', + }, + { + label: 'Enterprises', + to: '/officer/enterprises', + icon: Building2, + tone: 'indigo', + hint: 'The full register, searchable, with score, band and trend per borrower.', + }, + { + label: 'Forecasts', + to: '/officer/forecasts', + icon: LineChart, + tone: 'teal', + hint: 'Net cash position projected 1–6 months at P10 / P50 / P90, against the instalment due.', + }, + ], + }, + { + label: 'Risk', + icon: Siren, + items: [ + { + label: 'Risk alerts', + to: '/officer/alerts', + icon: Siren, + tone: 'rose', + hint: 'Open, acknowledged, escalated and snoozed, ranked by what is closest.', + badge: 'alerts', + }, + { + label: 'Trip-wires', + to: '/officer/rules', + icon: PencilRuler, + tone: 'amber', + hint: 'Every rule with its expression, and why it fired, skipped or stayed silent.', + }, + { + label: 'Field visits', + to: '/officer/visits', + icon: MapPin, + tone: 'violet', + hint: 'Ranked playbook, scheduling, and the outcome log that closes the loop.', + }, + ], + }, + { + label: 'Data', + icon: Database, + items: [ + { + label: 'Data sources', + to: '/officer/sources', + icon: Database, + tone: 'blue', + hint: 'Every route data enters by — APIs, core banking, uploads and the ledger.', + }, + { + label: 'Data feeds', + to: '/officer/feeds', + icon: LineChart, + tone: 'teal', + hint: 'Mandi prices and climate signals, with the age of every observation.', + }, + { + label: 'Data entry', + to: '/officer/data-entry', + icon: FileSpreadsheet, + tone: 'green', + hint: 'Upload a rate sheet or a loan book — staged, previewed, then committed.', + }, + { + label: 'Core banking', + to: '/officer/integration', + icon: FolderInput, + tone: 'indigo', + hint: 'CBSNexora connections, per-bank field mapping and sync history.', + }, + ], + }, ] +/** Page title for the breadcrumb, resolved longest-prefix-first. */ +const TITLES: { to: string; title: string; sub: string }[] = [ + { to: '/officer/portfolio', title: 'Sector risk packs', sub: 'Five rural trades modelled separately' }, + { to: '/officer/enterprises', title: 'Enterprises', sub: 'Every borrower on your book' }, + { to: '/officer/alerts', title: 'Risk alerts', sub: 'Early warning, ranked by what is closest' }, + { to: '/officer/forecasts', title: 'Forecasts', sub: 'Multi-horizon cash projection with explainability' }, + { to: '/officer/visits', title: 'Field visits', sub: 'Playbook, log and feedback loop' }, + { to: '/officer/rules', title: 'Trip-wires', sub: 'Every rule, its expression and its outcome' }, + { to: '/officer/sources', title: 'Data sources', sub: 'Every route by which data enters the platform' }, + { to: '/officer/feeds', title: 'Data feeds', sub: 'Where the numbers came from, and how old they are' }, + { to: '/officer/data-entry', title: 'Data entry', sub: 'Staged, previewed, then committed' }, + { to: '/officer/integration', title: 'Core banking', sub: 'Connections, field mapping and sync history' }, + { to: '/officer/reports', title: 'Reports', sub: 'Supervisory pack and model monitoring' }, + { to: '/officer/settings', title: 'Settings', sub: 'Console preferences' }, + { to: '/officer/early-warning', title: 'Early warning', sub: 'Ranked by when the downside stops covering the instalment' }, + { to: '/officer', title: 'Portfolio overview', sub: 'Where the book stands today' }, +] + +function titleFor(pathname: string) { + return TITLES.find((t) => pathname === t.to || pathname.startsWith(`${t.to}/`)) ?? TITLES[TITLES.length - 1] +} + /** * Data age, in the chrome rather than on a screen someone has to go and find. * @@ -62,6 +250,197 @@ function FreshnessChip() { ) } +/* ------------------------------------------------------------------ */ +/* Dropdown */ +/* ------------------------------------------------------------------ */ + +/** + * One header menu. Opens on click rather than on hover — a hover menu over a + * dense console opens itself while someone is travelling to something else, and + * on a touch screen it has no open gesture at all. + */ +function NavMenu({ + group, + open, + onToggle, + onClose, + openAlerts, +}: { + group: NavGroup + open: boolean + onToggle: () => void + onClose: () => void + openAlerts: number +}) { + const { pathname } = useLocation() + const active = group.items.some((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) + const Icon = group.icon + const groupAlerts = group.items.some((i) => i.badge === 'alerts') ? openAlerts : 0 + + return ( +

+ + + {/* + A mega-panel rather than a list. Each destination gets an icon tile, a + title and a line saying what the screen answers — the four Data screens + are the reason: "Data feeds" and "Data sources" are indistinguishable as + bare labels and obvious as soon as each carries its sentence. + + One width for every menu. A three-item panel rendered narrow and tall + while the four-item one was wide and short, so each menu opened into a + different shape and the eye had to re-find the first item every time. + Three items now fill two columns and leave a gap, which is the cheaper + cost. + */} + {open && ( +
+
+ {group.items.map((item) => { + const ItemIcon = item.icon + return ( + + `flex items-start gap-3 p-2.5 rounded-[9px] transition-colors ${ + isActive ? 'bg-lp-blue-soft' : 'hover:bg-surface-3' + }` + } + > + {({ isActive }) => ( + <> + + + + + + + {item.label} + + {item.badge === 'alerts' && openAlerts > 0 && ( + + {openAlerts} open + + )} + + + {item.hint} + + + + )} + + ) + })} +
+
+ )} +
+ ) +} + +/** The avatar menu — identity, then the two things you do to a session. */ +function UserMenu({ + name, + role, + initials, + open, + onToggle, + onClose, + onSignOut, +}: { + name: string + role: string + initials: string + open: boolean + onToggle: () => void + onClose: () => void + onSignOut: () => void +}) { + return ( +
+ + + {open && ( +
+
+
{name}
+
{role}
+
+ + + Settings + + +
+ )} +
+ ) +} + +/* ------------------------------------------------------------------ */ + export function OfficerShell() { return ( @@ -72,13 +451,44 @@ export function OfficerShell() { function ShellBody() { const navigate = useNavigate() + const { pathname } = useLocation() const session = officerSession() const { block, month, setBlock, setMonth } = useScope() - const openAlerts = alertsFor(block).filter((a) => a.state === 'OPEN').length - const progress = weeklyVisitProgress(block) + // Scoped counts. These drove a badge and a progress bar off the unscoped demo + // portfolio, so the header told an MPGB officer how many alerts existed across + // every bank on the platform. + const { rows: scopedAlerts } = useTenantAlerts(block) + const { rows: scopedVisits } = useTenantVisits(block) + const openAlerts = scopedAlerts.filter((a) => a.state === 'OPEN').length + const doneThisWeek = scopedVisits.filter((v) => v.state === 'DONE').length + const progress = { done: doneThisWeek, target: Math.max(3, scopedVisits.length) } const months = monthWindow(4, -3).reverse() // current month first, three back + const crumb = titleFor(pathname) - const initials = (session?.fullName ?? 'Field Officer') + // One open menu at a time, addressed by label. Null means everything closed. + const [openMenu, setOpenMenu] = useState(null) + const headerRef = useRef(null) + + // Close on outside click and on Escape. Route changes close it through the + // item's own onClick, which also covers navigating to the page you are on. + useEffect(() => { + if (!openMenu) return + const onDown = (e: MouseEvent) => { + if (!headerRef.current?.contains(e.target as Node)) setOpenMenu(null) + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpenMenu(null) + } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [openMenu]) + + const name = session?.fullName ?? 'Field Officer' + const initials = name .split(' ') .map((w) => w[0]) .join('') @@ -90,134 +500,156 @@ function ShellBody() { navigate('/login', { replace: true }) } + const toggle = (id: string) => setOpenMenu((cur) => (cur === id ? null : id)) + const close = () => setOpenMenu(null) + + const directLink = (item: NavItem) => { + const Icon = item.icon + return ( + + `flex items-center gap-1.5 px-3 h-[34px] rounded-[8px] text-[13px] whitespace-nowrap transition-colors ${ + isActive + ? 'bg-white text-forest font-semibold' + : 'text-white/70 font-medium hover:bg-white/10 hover:text-white' + }` + } + > + + {item.label} + + ) + } + return ( -
- {/* ── sidebar ─────────────────────────────────────────── */} - + {/* No overflow on this element. An `overflow-x: auto` here scrolls the row + on a narrow window, but it also establishes a clipping context that cuts + off the dropdown panels — and on Windows the always-visible scrollbars + eat into a 56px bar. Labels collapse at small widths instead. */} + - {/* ── work area ───────────────────────────────────────── */} -
-
-
-
Portfolio overview
-
- {block === ALL_BLOCKS ? (session?.branch ?? 'All blocks') : `${block} block`} · {monthRef(new Date(`${month}-01T00:00:00`)).fullEn} -
-
- -
- - - - - +
+ + + Search enterprise or ID… + - 🔔 - {openAlerts > 0 && } + + {openAlerts > 0 && ( + + )} -
- - {initials} - -
-
{session?.fullName ?? 'Field Officer'}
-
Field Officer
-
- + c.toUpperCase())} + initials={initials} + open={openMenu === '__user'} + onToggle={() => toggle('__user')} + onClose={close} + onSignOut={signOut} + /> +
+
+ + {/* ── scope row: what everything below is about ─────────── */} +
+
+
+ {crumb.title} +
+
+ {block === ALL_BLOCKS ? (session?.branch ?? 'All blocks') : `${block} block`} ·{' '} + {monthRef(new Date(`${month}-01T00:00:00`)).fullEn} · {crumb.sub}
-
-
- -
+
+ {/* weekly visit progress, which used to live in the sidebar */} + + + {progress.done}/{progress.target} + + + + + visits + -
- UdyamLinc · Predicting stress. Protecting enterprises. — Team LincFusion, LincPay Solutions Pvt. Ltd. -
-
+ + + ({ value: b as string, label: b, hint: `${b} block only` })), + ]} + /> + + ({ value: m.key, label: m.fullEn }))} + /> +
+
+ + +
+ +
+ +
+ UdyamLinc · Predicting stress. Protecting enterprises. — Team LincFusion, LincPay Solutions Pvt. Ltd. +
) } diff --git a/src/features/officer/RulesPage.tsx b/src/features/officer/RulesPage.tsx index a8e6f14..413ab9f 100644 --- a/src/features/officer/RulesPage.tsx +++ b/src/features/officer/RulesPage.tsx @@ -4,7 +4,7 @@ import { get } from '../../lib/api' import type { FeatureValueRow, RuleOutcome, RuleStatus, TripWireRule } from '../../lib/api' import { Card, Empty } from '../../components/ui/bits' import { DEMO_FEATURES, DEMO_RULES, demoFeatureValues, demoRuleOutcomes } from '../../lib/demo/feeds' -import { scopedEnterprises } from '../../lib/demo/ops' +import { useTenantEnterprises } from './useTenantData' import { useScope } from './officerScope' /** @@ -19,7 +19,9 @@ import { useScope } from './officerScope' */ export function RulesPage() { const { block } = useScope() - const enterprises = scopedEnterprises(block) + // The picker lists borrowers, so it comes from the scoped API like every + // other borrower list in the console. + const { rows: enterprises } = useTenantEnterprises(block) const [enterpriseId, setEnterpriseId] = useState(enterprises[0]?.enterpriseId ?? 'MSME-4417') const rulesQ = useQuery({ queryKey: ['rules'], queryFn: () => get('/admin/rules') }) diff --git a/src/features/officer/SettingsPage.tsx b/src/features/officer/SettingsPage.tsx index 97087fd..c16ec68 100644 --- a/src/features/officer/SettingsPage.tsx +++ b/src/features/officer/SettingsPage.tsx @@ -93,7 +93,7 @@ export function SettingsPage() { type="checkbox" checked={packs[s]} onChange={(e) => setPacks({ ...packs, [s]: e.target.checked })} - className="accent-[#0b5d3b]" + className="accent-[#2a71b8]" /> {SECTORS[s].label} {SECTORS[s].labelHi} diff --git a/src/lib/api.ts b/src/lib/api.ts index 616ec3d..dd2deb4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -204,6 +204,11 @@ export interface Enterprise { band: Band trend: number[] // 6-month score history, oldest first lastUpdated: string + /** Months until the downside case first misses the instalment; null if never. */ + firstBreachHorizon?: number | null + firstBreachMonth?: string | null + topDriver?: string | null + topDriverHi?: string | null } /** One month of the quantile forecast. Amounts are net cash position in ₹. */ @@ -815,3 +820,377 @@ export interface BranchCell { stressRatePct?: number avgScore?: number } + +/* ------------------------------------------------------------------ */ +/* Enterprise detail — the live shape of GET /officer/enterprises/{id} */ +/* ------------------------------------------------------------------ */ + +/** + * The stress score as the backend stores it: five flat sub-score columns rather + * than a nested object, plus the two fields that say where the number came from. + * + *

`engine` is the one to read carefully. `MODEL` means the gradient-boosted + * head produced it; `RULE` means the rule engine did; `RULE_FALLBACK` means the + * run was configured for the model and the inference service could not be + * reached, so this particular score is rule-derived. A screen that showed all + * three as "score" would be hiding exactly the distinction an auditor needs. + */ +export interface StressScoreRow { + enterpriseId: string + score: number + band: Band + engine: string + confidence: number + liquidity: number | null + behavioural: number | null + trendVolatility: number | null + obligationLoad: number | null + externalShock: number | null + computedAt: string | null + runId: number | null +} + +/** One SHAP driver as the backend stores it, sentence already rendered. */ +export interface ReasonCodeRow { + id: number + runId: number + enterpriseId: string + driver: string + driverHi: string + weight: number + rank: number + sentenceEn: string + sentenceHi: string +} + +export interface ForecastPointRow { + horizon: number + month: string + p10: number + p50: number + p90: number + obligation: number + breach: boolean + engine: string + modelVersion: string + missingInputs: number +} + +export interface EnterpriseDetail { + enterprise: Enterprise + sector: string + district: string + score: StressScoreRow | null + reasons: ReasonCodeRow[] + forecast: ForecastPointRow[] + alerts: RiskAlert[] + visits: FieldVisit[] + ledger: LedgerEntry[] +} + +export async function enterpriseDetail(enterpriseId: string): Promise { + const wire = await get(`/officer/enterprises/${enterpriseId}`) + // The server stores `syncedAt` (a timestamp) and has no `syncState` column, so + // these rows never satisfied the type they were declared as. Anything on the + // server is by definition synced; mapping here keeps that in one place rather + // than leaving each screen to discover the mismatch by crashing. + return { + ...wire, + ledger: (wire.ledger ?? []).map((row) => toLedgerEntry(row as unknown as Record)), + } +} + +/** + * Did the backend answer, and answer "no"? + * + *

The console's house pattern is to fall back to in-file demo data when a + * request fails, so a screen still reads with the backend down. That is right + * for a connection error and **wrong for a 404**: cross-tenant reads return 404 + * deliberately, indistinguishable from a fabricated id, and falling back to demo + * data there would render a borrower the caller is forbidden to see. A refusal + * has to be honoured, not papered over. + */ +export function isNotFound(e: unknown): boolean { + return (e as { response?: { status?: number } })?.response?.status === 404 +} + +/* ------------------------------------------------------------------ */ +/* Tenant-scoped reads */ +/* ------------------------------------------------------------------ */ + +/** + * Borrower-bearing endpoints. + * + *

Every one of these is filtered server-side through the caller's own + * institution before a row is returned. **There is no demo fallback for any of + * them, deliberately.** The in-file demo portfolio has no owner on it — the ids + * are generated from a formula — so a screen falling back to it renders + * borrowers from every bank at once. That happened, and it is the one failure + * this platform cannot afford: the whole apex tier rests on a bank's book being + * unreachable from another bank's console. + * + *

When these fail the screen says the portfolio is unavailable. A console + * that invents borrowers when the server is down is worse than one that admits + * it cannot reach the server. + */ +export function fetchAlerts(block: string, state?: string) { + return get("/officer/alerts", { block, state }) +} + +export function fetchVisits(block: string) { + return get('/officer/visits', { block }) +} + +/** Ids on this officer's book that have a phone-app login. */ +export function fetchAppEnrolled(block: string) { + return get('/officer/app-enrolled', { block }) +} + +export function fetchEnterprises(block: string) { + return get('/officer/enterprises', { block }) +} + +/** The alert exactly as the backend stores it — flat reason, no borrower join. */ +export interface RiskAlertRow { + id: number + alertId: string + enterpriseId: string + runId: number | null + band: Band + score: number + origin: AlertOrigin + ruleId: number | null + title: string + titleHi: string | null + reasonEn: string | null + reasonHi: string | null + shortfall: number | null + breachMonth: string | null + state: AlertState + raisedAt: string +} + +/** + * Give an alert the borrower fields the console reads. + * + *

`risk_alert` stores an enterprise id and nothing else about the borrower — + * correct, because a row should not duplicate a join — and it renders the reason + * as two flat strings rather than a nested object. The alert screen wants a name, + * a sector and `reason.sentenceEn`. Reading the wire record directly gave + * `undefined.sentenceEn` and `SECTORS[undefined]`, which threw. + * + *

The join source is `/officer/enterprises`, which is scoped to the caller's + * own institution — so an alert whose borrower is not on this book resolves to + * nothing and is dropped rather than rendered with a blank name. Two scoped + * lists intersected stay scoped. + */ +export function joinAlerts(rows: RiskAlertRow[], enterprises: Enterprise[]): RiskAlert[] { + const byId = new Map(enterprises.map((e) => [e.enterpriseId, e])) + return rows + .map((a) => { + const e = byId.get(a.enterpriseId) + if (!e) return null + return { + id: a.id, + alertId: a.alertId, + enterpriseId: a.enterpriseId, + enterpriseName: e.name, + sector: e.sector, + block: e.block, + band: a.band, + score: a.score, + origin: a.origin, + title: a.title, + titleHi: a.titleHi ?? a.title, + reason: { + driver: '', + driverHi: '', + weight: 0, + sentenceEn: a.reasonEn ?? '', + sentenceHi: a.reasonHi ?? '', + }, + shortfall: a.shortfall ?? 0, + breachMonth: a.breachMonth ?? '', + state: a.state, + raisedAt: a.raisedAt, + } satisfies RiskAlert + }) + .filter((a): a is RiskAlert => a !== null) +} + +/* ------------------------------------------------------------------ */ +/* Model metrics — what the backtest actually measured */ +/* ------------------------------------------------------------------ */ + +/** One arm of the comparison: a trained model, or a single-feature baseline. */ +export interface MetricArm { + baseline: boolean + auc_roc?: number + pr_auc?: number + precision_red?: number + recall_red?: number + brier?: number +} + +/** + * The latest backtest, as `model_metric` stored it. + * + *

`available: false` when no backtest has been run — the console says so in + * words rather than falling back to a number, which is how a hard-coded 0.87 + * survived on this page for as long as it did. + * + *

`syntheticLabelShare` is not a footnote. Every DPD label on this book is + * generated, so a metric measured against it measures the generator as much as + * the borrowers. It arrives on the same payload as the values so the page cannot + * render one without the other. + */ +export interface ModelMetrics { + available: boolean + message?: string + modelVersion?: string + computedAt?: string + validation?: string + datasetRows?: number + datasetPositives?: number + syntheticLabelShare?: number + arms?: Record +} + +export function fetchModelMetrics() { + return get('/admin/model/metrics') +} + +/** Human labels for the arms the backtest writes. */ +export const ARM_LABEL: Record = { + lightgbm: 'UdyamLinc — LightGBM, full signal set', + xgboost: 'UdyamLinc — XGBoost, full signal set', + obligation_ratio_only: 'Baseline — instalment ÷ inflow alone', + repayment_history_only: 'Baseline — current DPD alone', +} + +/* ------------------------------------------------------------------ */ +/* Enterprise app — the borrower's own surface */ +/* ------------------------------------------------------------------ */ + +/** + * `/app/**` is scoped to one enterprise by the token itself: the backend reads + * the enterprise id from the JWT subject, not from a parameter, so a borrower + * cannot ask for anyone else's record even by editing a request. + * + *

The wire shape differs from the console's in the same way it does + * everywhere: `enterprise` carries foreign keys rather than resolved names, and + * `advice` arrives as two flat strings. + */ +export interface AppHomeWire { + enterprise: Record + alert: RiskAlertRow | null + forecast: ForecastPointRow[] + todayIn: number + todayOut: number + adviceEn: string | null + adviceHi: string | null + lastSyncedAt: string | null +} + +export function fetchAppHome() { + return get('/app/home') +} + +export function fetchAppLedger() { + return get[]>('/app/ledger') +} + +/** The owner's own reason codes, newest run only. Scoped by the token. */ +export function fetchAppReasons() { + return get('/app/reasons') +} + +export function fetchAppAlerts() { + return get('/app/alerts') +} + +/** + * Push locally queued entries. + * + *

The offline queue is the point of this app: an owner records a sale with no + * signal and it reaches the server when one returns. `clientId` is minted on the + * phone so an entry keeps its identity across the sync and cannot be duplicated + * by a retry. + */ +export interface SyncResult { accepted: number; duplicates: number; syncedAt: string } + +export function syncAppLedger(entries: LedgerEntry[]) { + return post( + '/app/ledger/sync', + entries.map((e) => ({ + clientId: e.id, + kind: e.kind, + amount: e.amount, + category: e.category, + note: e.note, + entryDate: e.entryDate, + })), + ) +} + +/** + * Grant or withdraw consent for transaction aggregates. + * + *

A real write, not a UI toggle. The consent artefact is the thing that makes + * reading a borrower's inflows lawful, so it has to live on the server and be + * revocable from the borrower's own device. + */ +export function setAppConsent(active: boolean) { + return put>('/app/consent', { active }) +} + +/** + * Server ledger row → the shape the app is written against. + * + *

The two differ where it matters: the server's `id` is a database key and + * its `clientId` is the identity the phone minted, which is the one the app + * dedupes on. Mapping `clientId` onto `id` is what lets a synced entry replace + * its local copy instead of appearing twice. + */ +export function toLedgerEntry(wire: Record): LedgerEntry { + return { + id: String(wire.clientId ?? wire.id), + enterpriseId: String(wire.enterpriseId ?? ''), + kind: (wire.kind ?? 'IN') as LedgerKind, + amount: Number(wire.amount ?? 0), + category: String(wire.category ?? ''), + note: (wire.note as string | null) ?? null, + entryDate: String(wire.entryDate ?? ''), + syncState: 'SYNCED', + createdAt: String(wire.createdAt ?? wire.syncedAt ?? ''), + } +} + +// ── Borrower calls with an explicit token ──────────────────────────── +// +// The shared `api` instance picks its JWT from `window.location.pathname`, which +// is right for the two portals and wrong for the demo page: it lives at /demo and +// would send the officer's token to /app/*. Its 401 handler would then clear both +// sessions and bounce to the sign-in page — during a live demo. +// +// So borrower calls made from outside /app go through their own instance: no +// interceptors, token passed in, failures handled by the caller. + +const rawApi = axios.create({ baseURL: API_BASE, timeout: 6_000 }) + +async function asBorrower(token: string, path: string): Promise { + const res = await rawApi.get(path, { headers: { Authorization: `Bearer ${token}` } }) + return res.data.data as T +} + +/** A real borrower sign-in. The demo page holds the token in memory only. */ +export async function borrowerLogin(enterpriseId: string, pin: string): Promise { + const res = await rawApi.post('/auth/app-login', { enterpriseId, pin }) + return res.data.data as EnterpriseSession +} + +export const borrower = { + home: (t: string) => asBorrower(t, '/app/home'), + reasons: (t: string) => asBorrower(t, '/app/reasons'), + ledger: (t: string) => asBorrower[]>(t, '/app/ledger'), +} diff --git a/src/lib/demo/connectors.ts b/src/lib/demo/connectors.ts index d0fe2c4..b5b5604 100644 --- a/src/lib/demo/connectors.ts +++ b/src/lib/demo/connectors.ts @@ -41,22 +41,13 @@ export const CANONICAL: CanonicalColumns = { ], } -const FINACLE: Record = { - enterprise_ref: 'ENTITY_REF', - loan_account_ref: 'CUST_ACCT_ID', - product: 'SCHM_CODE', - sanctioned_amount: 'SANCT_AMT', - outstanding: 'PRIN_OS', - emi_amount: 'INSTL_AMT', - due_date: 'NEXT_DUE_DT', - last_paid_date: 'LAST_PAID_DT', - dpd: 'DPD_DAYS', - bucket: 'ASSET_CLASS', - closed: 'ACCT_CLS_FLG', - as_of_date: 'EXTRACT_DT', -} - -const CUSTOM: Record = { +/** + * CBSNexora's field names. LincPay's own core banking platform, and the only + * core named anywhere in this system — where a lender runs someone else's, the + * per-profile mapping absorbs their column names without the platform ever + * needing the product's name. + */ +const CBSNEXORA: Record = { enterprise_ref: 'borrowerRef', loan_account_ref: 'loanId', product: 'productCode', @@ -86,52 +77,14 @@ export function demoCatalog(): ConnectorCatalogEntry[] { suggestedMapping: IDENTITY, }, { - vendor: 'FINACLE', - displayName: 'Infosys Finacle (simulated)', - transport: 'FILE', - simulated: true, - nativeColumns: [...Object.values(FINACLE), 'CUST_NAME', 'MOBILE_NO', 'BRANCH_CODE', 'CCY'], - activationNote: - 'Finacle exports through a scheduled CRM/Reports extract. A bank enables it, agrees the column list, and the file lands on the drop path.', - suggestedMapping: FINACLE, - }, - { - vendor: 'FLEXCUBE', - displayName: 'Oracle FLEXCUBE (simulated)', - transport: 'JDBC', - simulated: true, - nativeColumns: [ - 'CUSTOMER_REF', 'ACCOUNT_NUMBER', 'PRODUCT_CODE', 'AMOUNT_FINANCED', 'PRINCIPAL_OUTSTANDING', - 'INSTALMENT_AMOUNT', 'NEXT_DUE_DATE', 'LAST_PAYMENT_DATE', 'OVERDUE_DAYS', 'NPA_STAGE', - 'ACCOUNT_STATUS', 'VALUE_DATE', 'CUSTOMER_NAME', 'CONTACT_MOBILE', 'BRANCH_CODE', - ], - activationNote: - 'Read through a reporting schema or extract view rather than the transaction tables. Needs a read-only account scoped to the loan module.', - suggestedMapping: {}, - }, - { - vendor: 'BANCS', - displayName: 'TCS BaNCS (simulated)', - transport: 'FILE', - simulated: true, - nativeColumns: [ - 'PARTY_REF', 'ARRANGEMENT_ID', 'PRODUCT_GROUP', 'LIMIT_AMT', 'OUTSTANDING_BAL', 'EMI', - 'DUE_DATE', 'PAID_DATE', 'DAYS_OVERDUE', 'CLASSIFICATION', 'STATUS', 'POSITION_DATE', - 'PARTY_NAME', 'MOBILE', 'BRANCH', - ], - activationNote: - 'Cooperative-bank hosted here. A nightly position file is the realistic ask; a live API rarely is in year one.', - suggestedMapping: {}, - }, - { - vendor: 'CUSTOM_API', - displayName: 'In-house LMS over REST (simulated)', + vendor: 'CBSNEXORA', + displayName: 'CBSNexora (simulated)', transport: 'REST', simulated: true, - nativeColumns: [...Object.values(CUSTOM), 'borrowerName', 'borrowerMobile', 'branchCode'], + nativeColumns: [...Object.values(CBSNEXORA), 'borrowerName', 'borrowerMobile', 'branchCode'], activationNote: - 'For a lender whose loan management system is their own. One paged read-only endpoint; the mapping absorbs whatever they call the fields.', - suggestedMapping: CUSTOM, + 'CBSNexora exposes one paged, read-only loan endpoint. A lender enables it for the platform’s service account and agrees the field list; the mapping absorbs whatever their deployment calls them.', + suggestedMapping: CBSNEXORA, }, ] } @@ -139,21 +92,21 @@ export function demoCatalog(): ConnectorCatalogEntry[] { export function demoProfiles(): ConnectorProfileRow[] { return [ { - code: 'MPGB-FINACLE', - displayName: 'MP Gramin Bank — Finacle nightly extract', - vendor: 'FINACLE', - vendorName: 'Infosys Finacle (simulated)', - transport: 'FILE', + code: 'MPGB-CBSNEXORA', + displayName: 'MP Gramin Bank — CBSNexora loan API', + vendor: 'CBSNEXORA', + vendorName: 'CBSNexora (simulated)', + transport: 'REST', simulated: true, activationNote: demoCatalog()[1].activationNote, institutionCode: 'MPGB', - endpoint: 'loanbook-*.csv', + endpoint: 'https://cbsnexora.mpgb.example/api/loans', scheduleCron: '0 30 1 * * *', autoCommit: false, active: true, lastSyncAt: '2026-08-07T01:30:00', lastStatus: 'PARTIAL', - mapping: FINACLE, + mapping: CBSNEXORA, sourceColumns: demoCatalog()[1].nativeColumns, }, { @@ -175,22 +128,22 @@ export function demoProfiles(): ConnectorProfileRow[] { sourceColumns: [], }, { - code: 'VSFB-LMS', - displayName: 'Vindhya SFB — in-house LMS over REST', - vendor: 'CUSTOM_API', - vendorName: 'In-house LMS over REST (simulated)', + code: 'VSFB-CBSNEXORA', + displayName: 'Vindhya SFB — CBSNexora loan API', + vendor: 'CBSNEXORA', + vendorName: 'CBSNexora (simulated)', transport: 'REST', simulated: true, - activationNote: demoCatalog()[4].activationNote, + activationNote: demoCatalog()[1].activationNote, institutionCode: 'VSFB', - endpoint: 'https://lms.vindhyasfb.example/api/loans', + endpoint: 'https://cbsnexora.vindhyasfb.example/api/loans', scheduleCron: '0 15 1 * * *', autoCommit: false, active: true, lastSyncAt: null, lastStatus: 'NEVER_RUN', - mapping: CUSTOM, - sourceColumns: demoCatalog()[4].nativeColumns, + mapping: CBSNEXORA, + sourceColumns: demoCatalog()[1].nativeColumns, }, ] } @@ -244,8 +197,8 @@ export function demoRuns(): ConnectorSyncRun[] { }, { id: 13, - profileCode: 'MPGB-FINACLE', - vendor: 'FINACLE', + profileCode: 'MPGB-CBSNEXORA', + vendor: 'CBSNEXORA', institutionCode: 'MPGB', trigger: 'SCHEDULED', triggeredBy: 'scheduler', @@ -266,8 +219,8 @@ export function demoRuns(): ConnectorSyncRun[] { }, { id: 12, - profileCode: 'MPGB-FINACLE', - vendor: 'FINACLE', + profileCode: 'MPGB-CBSNEXORA', + vendor: 'CBSNEXORA', institutionCode: 'MPGB', trigger: 'MANUAL', triggeredBy: 'supervisor', diff --git a/src/lib/sourceInventory.ts b/src/lib/sourceInventory.ts new file mode 100644 index 0000000..18bba90 --- /dev/null +++ b/src/lib/sourceInventory.ts @@ -0,0 +1,424 @@ +import { + CloudRain, + CloudSun, + Egg, + FileSpreadsheet, + FileUp, + FolderInput, + Globe, + IndianRupee, + Landmark, + MapPin, + Milk, + NotebookPen, + PencilLine, + ShieldCheck, + Store, + Upload, + Wheat, +} from 'lucide-react' +import type { + ConnectorProfileRow, + ConnectorSyncRun, + Freshness, + IngestionRun, + ManualEntryBatch, +} from './api' +import type { SourceGroupSpec, SourceSpec } from './sourceStatus' + +/** + * The single description of every route by which data enters UdyamLinc. + * + *

Both consoles render this. The officer console adds drill-through links to + * the operational screens; the apex console does not, because NABARD supervises + * whether a source is connected and never operates it. That is the only + * difference, and it is one boolean — anything more and the two tiers would + * eventually be shown different facts about the same feed. + * + *

Nothing here is hard-coded that can be read: run status, row counts and + * staleness all come from the same endpoints the detail screens use. What is + * hard-coded is what cannot be discovered at runtime — the endpoint a feed is + * fetched from, the table it lands in, and why an unconnected one is not + * connected yet. + */ + +export interface InventoryInput { + freshness: Freshness[] + runs: IngestionRun[] + profiles: ConnectorProfileRow[] + connectorRuns: ConnectorSyncRun[] + batches: ManualEntryBatch[] + /** Officer console only. The apex tier gets the inventory, not the controls. */ + withLinks: boolean +} + +export interface Inventory { + groups: SourceGroupSpec[] + all: SourceSpec[] + districtsCovered: number + /** Oldest market observation across districts, in days. Null when nothing ingested. */ + worstMarketAge: number | null + worstClimateAge: number | null +} + +export function buildInventory(input: InventoryInput): Inventory { + const { freshness, runs, profiles, connectorRuns, batches, withLinks } = input + + const link = (to: string, label: string) => (withLinks ? { to, label } : undefined) + + const districtsCovered = freshness.length + const marketAges = freshness.map((f) => f.marketStalenessDays).filter((d): d is number => d != null) + const climateAges = freshness.map((f) => f.climateStalenessDays).filter((d): d is number => d != null) + const worstMarketAge = marketAges.length ? Math.max(...marketAges) : null + const worstClimateAge = climateAges.length ? Math.max(...climateAges) : null + + const lastRun = (source: string): string => { + const run = runs.find((r) => r.source === source) + if (!run) return 'No run recorded yet' + const rows = run.rowsWritten ?? run.rowsFetched ?? 0 + const when = run.runDate ?? run.startedAt?.slice(0, 10) ?? '—' + return `${run.status} · ${rows.toLocaleString('en-IN')} rows · ${when}` + } + + const lastConnectorRun = (code: string): string => { + const run = connectorRuns.find((r) => r.profileCode === code) + if (!run) return 'Never run' + const rows = run.rowsAccepted ?? run.rowsFetched ?? 0 + return `${run.status} · ${rows.toLocaleString('en-IN')} rows · ${run.startedAt.slice(0, 10)}` + } + + const batchActivity = (type: ManualEntryBatch['entryType']): string => { + const b = batches.find((x) => x.entryType === type) + if (!b) return 'No upload yet' + const accepted = (b.rowsAccepted ?? 0).toLocaleString('en-IN') + const rejected = (b.rowsRejected ?? 0).toLocaleString('en-IN') + return `${b.status} · ${accepted} accepted, ${rejected} rejected · ${b.uploadedAt.slice(0, 10)}` + } + + /* ── A · public data APIs ─────────────────────────────────────── */ + + const apiSources: SourceSpec[] = [ + { + icon: Wheat, + id: 'agmarknet', + name: 'Agmarknet mandi prices', + kind: 'Public API · data.gov.in', + status: 'LIVE', + mechanism: 'GET api.data.gov.in/resource/9ef84268-…', + auth: 'API key — DATAGOV_API_KEY', + writes: 'market_signal', + cadence: 'Nightly 02:00, or on demand', + feeds: ['feed_cost_change', 'output_price_change', 'margin_squeeze', 'price_z_score'], + note: `39 commodities across ${districtsCovered} district baskets. Daily arrivals folded into one modal price per ISO week.`, + activity: `${lastRun('AGMARKNET')}${worstMarketAge != null ? ` · oldest district ${worstMarketAge}d` : ''}`, + link: link('/officer/feeds', 'Prices'), + }, + { + icon: CloudRain, + id: 'open-meteo', + name: 'Rainfall & temperature', + kind: 'Public API · Open-Meteo archive', + status: 'LIVE', + mechanism: 'GET archive-api.open-meteo.com/v1/archive', + auth: 'None required', + writes: 'climate_signal, climate_normal', + cadence: 'Nightly 02:00, or on demand', + feeds: ['rainfall_anomaly', 'heat_anomaly'], + note: 'Reanalysis rather than a live weather endpoint, because an anomaly needs a 30-year baseline to be measured against.', + activity: `${lastRun('OPEN_METEO')}${worstClimateAge != null ? ` · oldest district ${worstClimateAge}d` : ''}`, + link: link('/officer/feeds', 'Climate'), + }, + { + icon: CloudSun, + id: 'imd', + name: 'IMD Agromet advisories', + kind: 'Public API · district advisory', + status: 'NOT_CONNECTED', + mechanism: 'District advisory bulletin — no client written', + auth: 'Open', + writes: '—', + cadence: 'Tuesday / Friday', + feeds: [], + note: 'Adds the agronomic advisory text beside the numbers. One client class; slots into the existing scheduler.', + activity: 'Nothing ingested', + }, + { + icon: Egg, + id: 'necc', + name: 'NECC egg realisation', + kind: 'HTML page · no API', + status: 'NOT_CONNECTED', + mechanism: 'e2necc.com daily city rate table — needs a scrape', + auth: 'Open, terms-of-use review pending', + writes: 'market_signal (source NECC)', + cadence: 'Daily', + feeds: ['output_price_change'], + note: 'The poultry pack’s output price. Until it lands, egg is filled by the simulator and by manual entry.', + activity: 'Nothing ingested — commodity seeded, source reserved', + }, + { + icon: Milk, + id: 'dairy', + name: 'Dairy federation milk rate', + kind: 'Institutional · no API', + status: 'NOT_CONNECTED', + mechanism: 'State federation procurement rate — MoU or admin entry', + auth: 'Relationship, not an endpoint', + writes: 'market_signal (source DAIRY_FEDERATION)', + cadence: 'Monthly', + feeds: ['output_price_change', 'margin_squeeze'], + note: 'The dairy pack’s output price. No public API exists anywhere in India; manual entry exists because of this one.', + activity: 'Nothing ingested — commodity seeded, source reserved', + }, + ] + + /* ── B · core banking ─────────────────────────────────────────── */ + + const fileDrop = profiles.find((p) => !p.simulated) + const simulatedProfiles = profiles.filter((p) => p.simulated) + + const cbsSources: SourceSpec[] = [ + { + icon: FolderInput, + id: 'file-drop', + name: fileDrop?.displayName ?? 'Bank file drop', + kind: 'CBS extract · file drop', + status: 'LIVE', + mechanism: fileDrop?.endpoint ?? 'Newest matching file on a mounted drop path', + auth: 'Filesystem, mounted by the bank', + writes: 'loan_record (via staged preview)', + cadence: fileDrop?.scheduleCron ?? 'On demand', + feeds: ['emi_delay', 'emi_to_inflow'], + note: 'The real adapter. Reads and never writes, and leaves the file where it found it so a failed run is repeatable. A bank on any core can deliver to it.', + activity: fileDrop ? lastConnectorRun(fileDrop.code) : 'No profile configured', + link: link('/officer/integration', 'Connections'), + }, + ...simulatedProfiles.map((p) => ({ + icon: Landmark, + id: p.code, + name: p.vendorName ?? p.displayName, + kind: `CBS dialect · ${p.transport ?? 'FILE'}`, + status: 'SIMULATED' as const, + mechanism: `${p.vendor} column dialect — ${Object.keys(p.mapping ?? {}).length} columns mapped`, + auth: 'None — emitted locally', + writes: 'loan_record (stamped simulated per run)', + cadence: 'On demand', + feeds: ['emi_delay', 'emi_to_inflow'], + note: + p.activationNote ?? + 'Emits the real portfolio in CBSNexora’s own field names, so the mapping is agreed and exercised before a live instance is connected.', + activity: lastConnectorRun(p.code), + link: link('/officer/integration', 'Mapping'), + })), + ] + + /* ── C · digital transaction proxies ──────────────────────────── */ + + const upiSources: SourceSpec[] = [ + { + icon: ShieldCheck, + id: 'aa', + name: 'Account Aggregator (Sahamati)', + kind: 'Consent framework · RBI', + status: 'NOT_CONNECTED', + mechanism: 'FIU registration + consent artefact — no client written', + auth: 'Consent artefact per borrower', + writes: 'upi_summary', + cadence: 'Weekly', + feeds: ['upi_inflow_change'], + note: 'The mandate’s "digital transaction proxies". Registration is a relationship with a long lead time, not a sprint — so the table, the query and the feature all exist and wait for it.', + activity: 'No consented rows · TW-02 reports SKIPPED_MISSING, never fires', + }, + { + icon: IndianRupee, + id: 'upi-sim', + name: 'UPI weekly aggregates', + kind: 'Simulator · stands in for AA', + status: 'SIMULATED', + mechanism: 'UpiSimulationService → the same upi_summary table', + auth: 'Written only for enterprises with consentActive', + writes: 'upi_summary (source SIMULATED)', + cadence: 'On demand', + feeds: ['upi_inflow_change'], + note: 'Goes through the identical table and derivation an Account Aggregator would, so the seam is exercised rather than described. An enterprise that has not consented stays null with NO_CONSENT.', + activity: 'Populated for consented demo enterprises only', + }, + ] + + /* ── D · manual upload and entry ──────────────────────────────── */ + + const uploadSources: SourceSpec[] = [ + { + icon: FileSpreadsheet, + id: 'upload-market', + name: 'Market price upload', + kind: 'File upload · CSV / XLSX', + status: 'LIVE', + mechanism: 'POST /admin/manual/upload?type=MARKET_SIGNAL', + auth: 'Data steward or above', + writes: 'market_signal (source MANUAL)', + cadence: 'Whenever a rate sheet arrives', + feeds: ['output_price_change', 'margin_squeeze', 'price_z_score'], + note: 'Exists for milk, egg, fodder and cane — the commodities no API publishes. Nothing is written until the preview has shown what will land, what was rejected and what will overwrite.', + activity: batchActivity('MARKET_SIGNAL'), + link: link('/officer/data-entry', 'Upload'), + }, + { + icon: FileUp, + id: 'upload-loans', + name: 'Loan book upload', + kind: 'File upload · CSV / XLSX', + status: 'LIVE', + mechanism: 'POST /admin/manual/upload?type=LOAN_RECORD', + auth: 'Data steward or above', + writes: 'loan_record', + cadence: 'Monthly, or ahead of a CBS connection', + feeds: ['emi_delay', 'emi_to_inflow'], + note: 'A file whose headers carry a name, phone or account number is refused whole — the no-PII rule is enforced at the boundary, not cleaned up afterwards.', + activity: batchActivity('LOAN_RECORD'), + link: link('/officer/data-entry', 'Upload'), + }, + { + icon: PencilLine, + id: 'entry-single', + name: 'Single market entry', + kind: 'Form · one rate at a time', + status: 'LIVE', + mechanism: 'POST /admin/manual/entry', + auth: 'Data steward or above', + writes: 'market_signal (source MANUAL)', + cadence: 'Ad hoc', + feeds: ['output_price_change'], + note: 'For the officer who has one number from the dairy union and no file to upload.', + activity: 'Available to every data steward', + link: link('/officer/data-entry', 'Entry'), + }, + ] + + /* ── E · enterprise-generated ─────────────────────────────────── */ + + const ledgerSources: SourceSpec[] = [ + { + icon: NotebookPen, + id: 'ledger', + name: 'Three-tap enterprise ledger', + kind: 'Borrower PWA · offline-first', + status: 'LIVE', + mechanism: 'Enterprise app — in, out, stock. Queued offline, synced on reconnect', + auth: 'Enterprise PIN', + writes: 'ledger_entry', + cadence: 'Daily, ~15 seconds of owner effort', + feeds: ['cash_flow_change', 'days_since_last_entry', 'inventory_days', 'dscr_p10'], + note: 'The only signal that reaches the enterprise’s own trading position. client_id is minted on the phone so an entry keeps its identity through a sync.', + activity: 'Captured per enterprise in the borrower app', + }, + { + icon: MapPin, + id: 'visits', + name: 'Field visit outcomes', + kind: 'Officer console · feedback edge', + status: 'LIVE', + mechanism: 'Visit logged against an alert, with the intervention and its result', + auth: 'Field officer or above', + writes: 'field_visit', + cadence: 'Per visit', + feeds: [], + note: 'Closes the loop: what the officer did and whether it worked is the label a future retraining cycle reads.', + activity: 'Logged from the alerts and visits screens', + link: link('/officer/visits', 'Visits'), + }, + ] + + const groups: SourceGroupSpec[] = [ + { + icon: Globe, + letter: 'A', + title: 'Public data APIs', + blurb: 'Market intelligence and climate — pulled nightly, never in the request path', + sources: apiSources, + }, + { + icon: Landmark, + letter: 'B', + title: 'Core banking / LMS (CBS)', + blurb: 'The loan book — a real file drop beside the CBSNexora loan API', + sources: cbsSources, + }, + { + icon: IndianRupee, + letter: 'C', + title: 'Digital transaction proxies (UPI)', + blurb: 'Consent-bound inflows through the Account Aggregator framework', + sources: upiSources, + }, + { + icon: Upload, + letter: 'D', + title: 'Manual upload & entry', + blurb: 'For every feed no API serves — staged, previewed, then committed', + sources: uploadSources, + }, + { + icon: Store, + letter: 'E', + title: 'Enterprise-generated', + blurb: 'What the borrower and the officer record themselves', + sources: ledgerSources, + }, + ] + + return { + groups, + all: groups.flatMap((g) => g.sources), + districtsCovered, + worstMarketAge, + worstClimateAge, + } +} + +/** The four families the problem statement names, and what is live behind each. */ +export interface MandateRow { + family: string + detail: string + live: number + total: number +} + +export function mandateCoverage(inv: Inventory): MandateRow[] { + const group = (letter: string) => inv.groups.find((g) => g.letter === letter)?.sources ?? [] + const live = (s: SourceSpec[]) => s.filter((x) => x.status === 'LIVE').length + + const api = group('A') + const market = api.filter((s) => s.id !== 'open-meteo' && s.id !== 'imd') + const climate = api.filter((s) => s.id === 'open-meteo' || s.id === 'imd') + const cbs = group('B') + const upi = group('C') + const upload = group('D') + + return [ + { + family: 'Financial records', + detail: 'Core banking extract, loan-book upload, borrower ledger', + live: live(cbs) + live(upload) + 1, + total: cbs.length + upload.length + 1, + }, + { + family: 'Digital transaction proxies', + detail: 'Consented UPI and bank inflows', + live: live(upi), + total: upi.length, + }, + { + family: 'Market intelligence', + detail: 'Mandi prices, egg and milk realisation', + live: live(market), + total: market.length, + }, + { + family: 'Climate data', + detail: 'Rainfall, heat and district advisories', + live: live(climate), + total: climate.length, + }, + ] +} diff --git a/src/lib/sourceStatus.ts b/src/lib/sourceStatus.ts new file mode 100644 index 0000000..356ee6e --- /dev/null +++ b/src/lib/sourceStatus.ts @@ -0,0 +1,78 @@ +import type { LucideIcon } from 'lucide-react' + +/** + * The vocabulary the data-source inventory is written in. + * + *

Split out from the card that renders it so both consoles and the inventory + * builder can share the words without importing a component, and so the card + * file exports components only. + */ + +/** + * Three states, and deliberately no fourth. {@code SIMULATED} exists because the + * alternative — leaving generated rows looking real — is the single failure this + * console is built to prevent. + */ +export type SourceStatus = 'LIVE' | 'SIMULATED' | 'NOT_CONNECTED' + +export const STATUS_LABEL: Record = { + LIVE: 'Live', + SIMULATED: 'Simulated', + NOT_CONNECTED: 'Not connected', +} + +export const STATUS_PILL: Record = { + LIVE: 'pill-ok', + SIMULATED: 'pill-pend', + NOT_CONNECTED: 'pill-mut', +} + +export const STATUS_HELP: Record = { + LIVE: 'Real data, fetched or uploaded. Every row carries its origin.', + SIMULATED: 'Generated because no feed exists. Stamped SIMULATED in the database, permanently.', + NOT_CONNECTED: 'The seam is built; the source needs an agreement, a key or a scrape.', +} + +export interface SourceSpec { + /** Lucide glyph for the card's tile — what the source *is*, at a glance. */ + icon?: LucideIcon + id: string + /** What a person would call it. */ + name: string + /** The mechanism class — Public API, CBS extract, File upload… */ + kind: string + status: SourceStatus + /** Endpoint, path or template — rendered monospace, because it is literal. */ + mechanism: string + auth: string + /** The table it lands in. */ + writes: string + cadence: string + /** Feature codes this source ultimately feeds. */ + feeds: string[] + /** Last run, row count, or why there is nothing to report. */ + activity: string + /** One sentence a reader can take without asking a follow-up. */ + note?: string + /** Officer console only — the apex tier gets no operational drill-through. */ + link?: { to: string; label: string } +} + +export interface SourceGroupSpec { + icon?: LucideIcon + letter: string + title: string + blurb: string + sources: SourceSpec[] +} + +/** + * Tint for a card's icon tile. The pill states the status in words; the tile + * repeats it in colour, so a reader scanning a grid of twelve cards sees which + * are live before reading any of them. + */ +export const STATUS_TILE: Record = { + LIVE: 'bg-green-soft text-forest', + SIMULATED: 'bg-amber-soft text-amber', + NOT_CONNECTED: 'bg-slate-100 text-slate-400', +}