feat(sources): show every data source, and fix the navigation

Adds the data-source inventory the problem statement implies but the console
never showed: seventeen sources across core banking, UPI, mandi prices,
climate and manual upload, each badged Live, Simulated or Not connected from
sourceStatus.ts rather than from a static list. A jury — or a bank — can see
at a glance which feeds are real, and the ones that are simulated say so on
their own card instead of in a footnote.

Navigation moves to a fixed header with grouped dropdown submenus. The left
rail had grown past what a field officer could scan, and related screens were
scattered across it. MenuSelect replaces the native <select> in the identity
bar: a native control inherits the OS widget and looked foreign inside the
console, and putting overflow-x on the nav to fit it clipped the dropdowns and
added a scrollbar on Windows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-25 13:46:14 +05:30
co-authored by Claude Opus 5
parent 9d307c2d20
commit b6eea1360f
14 changed files with 2278 additions and 275 deletions
+124
View File
@@ -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 `<select>`.
*
* <p>The scope controls in the console header were the last two OS-rendered
* dropdowns in the chrome: a native `<select>` 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.
*
* <p>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<T extends string>({
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<HTMLDivElement>(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 (
<div className="relative" ref={root}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={label}
style={{ minWidth }}
className={`flex items-center gap-1.5 w-full border rounded-[8px] px-2.5 py-[5px] text-[11.5px] font-medium bg-white transition-colors ${
open ? 'border-lp-blue text-ink' : 'border-line-s text-ink-2 hover:border-lp-blue hover:text-ink'
}`}
>
<span className="truncate">{current?.label ?? value}</span>
<ChevronDown
size={13}
className={`ml-auto shrink-0 text-ink-3 transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div
role="listbox"
aria-label={label}
className={`ul-menu absolute top-[calc(100%+6px)] z-50 py-1 max-h-[280px] overflow-y-auto ${
align === 'right' ? 'right-0' : 'left-0'
}`}
style={{ minWidth: Math.max(minWidth, 170) }}
>
<div className="px-3 pt-1 pb-1.5 text-[9.5px] uppercase tracking-[0.09em] text-ink-3 font-bold">
{label}
</div>
{options.map((o) => {
const selected = o.value === value
return (
<button
key={o.value}
type="button"
role="option"
aria-selected={selected}
onClick={() => {
onChange(o.value)
setOpen(false)
}}
className={`flex items-center gap-2 w-full text-left px-3 py-[7px] border-l-2 transition-colors ${
selected ? 'bg-lp-blue-soft border-lp-blue' : 'border-transparent hover:bg-surface-3'
}`}
>
<Check
size={13}
className={`shrink-0 ${selected ? 'text-lp-blue' : 'text-transparent'}`}
/>
<span className="min-w-0">
<span
className={`block text-[12.5px] truncate ${
selected ? 'text-lp-blue font-semibold' : 'text-ink font-medium'
}`}
>
{o.label}
</span>
{o.hint && <span className="block text-[10.5px] text-ink-3 leading-snug">{o.hint}</span>}
</span>
</button>
)
})}
</div>
)}
</div>
)
}
+164
View File
@@ -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.
*
* <p>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 (
<div
className={`ul-card p-3.5 flex flex-col gap-2.5 h-full ${dim ? 'bg-slate-50/60 border-dashed' : ''}`}
>
{/* identity — tile tinted by status, so a grid reads before it is read */}
<div className="flex items-start gap-2.5">
<span
className={`w-8 h-8 rounded-[9px] grid place-items-center shrink-0 ${STATUS_TILE[s.status]}`}
>
<Icon size={16} strokeWidth={2} />
</span>
<div className="min-w-0">
<div className={`text-[12.5px] font-bold leading-tight ${dim ? 'text-slate-500' : 'text-forest'}`}>
{s.name}
</div>
<div className="text-[10px] text-slate-400 uppercase tracking-wide mt-0.5">{s.kind}</div>
</div>
<span className={`pill ${STATUS_PILL[s.status]} ml-auto shrink-0`} title={STATUS_HELP[s.status]}>
{STATUS_LABEL[s.status]}
</span>
</div>
{/* the four facts, in the same order on every card */}
<dl className="grid grid-cols-[64px_1fr] gap-x-2 gap-y-1 text-[10.5px]">
<dt className="text-slate-400">Arrives</dt>
<dd className="font-mono text-[10px] text-slate-600 break-all leading-snug">{s.mechanism}</dd>
<dt className="text-slate-400">Auth</dt>
<dd className="text-slate-600">{s.auth}</dd>
<dt className="text-slate-400">Writes</dt>
<dd className="font-mono text-[10px] text-slate-600">{s.writes}</dd>
<dt className="text-slate-400">Cadence</dt>
<dd className="text-slate-600">{s.cadence}</dd>
</dl>
{/* what it feeds — the reason the source is on the book at all */}
{s.feeds.length > 0 && (
<div className="flex flex-wrap gap-1">
{s.feeds.map((f) => (
<span key={f} className="font-mono text-[9px] bg-green-soft text-forest px-1.5 py-0.5 rounded-md">
{f}
</span>
))}
</div>
)}
{s.note && <p className="text-[10.5px] text-slate-500 leading-snug">{s.note}</p>}
{/* last activity pinned to the bottom so cards align across a row */}
<div className="mt-auto pt-2 border-t border-slate-100 flex items-center gap-2">
<span className="text-[10.5px] text-slate-500 leading-snug">{s.activity}</span>
{s.link && (
<Link
to={s.link.to}
className="ml-auto shrink-0 text-[10.5px] font-semibold text-green hover:text-forest"
>
{s.link.label} →
</Link>
)}
</div>
</div>
)
}
/** 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 (
<Card
title={`${letter} · ${title}`}
badge={`${live}/${sources.length} live`}
actions={
<span className="flex items-center gap-1.5 text-[10.5px] text-slate-400">
{Icon && <Icon size={13} className="text-slate-300" />}
{blurb}
</span>
}
>
<div className="p-3.5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{sources.map((s) => (
<SourceCard key={s.id} s={s} />
))}
</div>
</Card>
)
}
/** The three words the inventory is written in, defined once where both pages show it. */
export function StatusLegend() {
return (
<div className="ul-card px-3.5 py-2.5 flex flex-wrap items-center gap-x-5 gap-y-1.5">
<span className="text-[10.5px] font-semibold text-slate-500">Status means</span>
{(['LIVE', 'SIMULATED', 'NOT_CONNECTED'] as SourceStatus[]).map((s) => (
<span key={s} className="flex items-center gap-1.5">
<span className={`pill ${STATUS_PILL[s]}`}>{STATUS_LABEL[s]}</span>
<span className="text-[10.5px] text-slate-500">{STATUS_HELP[s]}</span>
</span>
))}
</div>
)
}
/** 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 (
<div className="ul-card p-3 flex flex-wrap items-stretch gap-2">
{steps.map((s, i) => (
<div key={s.n} className="flex items-stretch gap-2 flex-1 min-w-[150px]">
<div className={`flex-1 rounded-md px-2.5 py-2 ${i === 0 ? 'bg-green-soft' : 'bg-slate-50'}`}>
<div className="flex items-center gap-1.5">
<span
className={`w-5 h-5 rounded-[5px] grid place-items-center shrink-0 ${
i === 0 ? 'bg-forest text-white' : 'bg-slate-300 text-white'
}`}
>
<s.icon size={12} strokeWidth={2.2} />
</span>
<span className={`text-[11.5px] font-bold ${i === 0 ? 'text-forest' : 'text-slate-600'}`}>
{s.label}
</span>
<span className="ml-auto font-mono text-[9px] text-slate-400">{s.n}</span>
</div>
<div className="text-[9.5px] text-slate-500 mt-0.5 leading-snug">{s.sub}</div>
</div>
{i < steps.length - 1 && (
<span className="self-center text-slate-300 text-[13px] leading-none">→</span>
)}
</div>
))}
</div>
)
}
+73 -51
View File
@@ -1,5 +1,5 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom' 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' import { clearSessions, isRegionalOfficeRole, officerSession } from '../../lib/api'
const NAV = [ const NAV = [
@@ -8,6 +8,7 @@ const NAV = [
{ label: 'Partner banks', to: '/nabard/banks', icon: Landmark }, { label: 'Partner banks', to: '/nabard/banks', icon: Landmark },
{ label: 'Early warning', to: '/nabard/digest', icon: Siren }, { label: 'Early warning', to: '/nabard/digest', icon: Siren },
{ label: 'Institutions', to: '/nabard/institutions', icon: Building2 }, { 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 * 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 * 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. * assumption that the apex user is simply a bank user with more rights.
*
* <p>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() { export function NabardShell() {
const navigate = useNavigate() const navigate = useNavigate()
const session = officerSession() const session = officerSession()
const tier = isRegionalOfficeRole(session?.role) ? 'NABARD Regional Office' : 'NABARD Head Office'
return ( return (
<div className="min-h-screen flex bg-slate-50"> <div className="min-h-screen flex flex-col bg-page">
<aside className="w-[210px] shrink-0 bg-[#123c2b] text-white flex flex-col px-3 py-5"> <header className="sticky top-0 z-40">
<div className="flex items-center gap-2.5 px-2 pb-5"> <div className="bg-forest-deep text-white flex items-center gap-4 px-4 h-14 shadow-[0_2px_14px_rgba(0,0,0,0.25)]">
<span className="w-9 h-9 rounded-[9px] bg-[#0f6b4c] grid place-items-center shrink-0"> <NavLink to="/nabard" className="flex items-center gap-2.5 shrink-0">
<ShieldCheck size={19} /> <span className="w-[30px] h-[30px] rounded-[9px] bg-forest-mid grid place-items-center shrink-0">
</span> <ShieldCheck size={17} />
<span>
<b className="text-[16px] block leading-tight">UdyamLinc</b>
{/*
Head office and a regional office are both NABARD and see the same
kinds of figures over different territory. Naming which one is
signed in stops a supervisor reading a single office's numbers as
the national picture.
*/}
<span className="text-[10.5px] text-[#bcd6cb]">
{isRegionalOfficeRole(session?.role) ? 'NABARD Regional Office' : 'NABARD Head Office'}
</span> </span>
</span> <span className="leading-tight hidden sm:block">
</div> <span className="block text-[14px] font-bold tracking-[-0.02em]">UdyamLinc</span>
{/*
Head office and a regional office are both NABARD and see the same
kinds of figures over different territory. Naming which one is
signed in stops a supervisor reading a single office's numbers as
the national picture.
*/}
<span className="block text-[10px] text-white/55">{tier}</span>
</span>
</NavLink>
<nav className="flex flex-col gap-0.5"> <span className="w-px h-6 bg-white/20 hidden md:block" />
{NAV.map((n) => (
<NavLink <nav className="flex items-center gap-0.5 min-w-0">
key={n.to} {NAV.map((n) => (
to={n.to} <NavLink
end={n.end} key={n.to}
className={({ isActive }) => to={n.to}
`flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[12.5px] transition-colors ${ end={n.end}
isActive ? 'bg-white/15 text-white font-semibold' : 'text-[#cfe2da] hover:bg-white/10' className={({ isActive }) =>
}` `flex items-center gap-1.5 px-3 h-[34px] rounded-[8px] text-[13px] whitespace-nowrap transition-colors ${
} isActive
? 'bg-white text-forest-deep font-semibold'
: 'text-white/70 font-medium hover:bg-white/10 hover:text-white'
}`
}
>
<n.icon size={14} strokeWidth={2} />
{n.label}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-3 shrink-0">
<span className="hidden lg:block text-[11px] text-white/55">{session?.username}</span>
<button
onClick={() => {
clearSessions()
navigate('/login', { replace: true })
}}
className="flex items-center gap-1.5 text-[11.5px] text-white/70 hover:text-white bg-white/10 hover:bg-white/20 rounded-[8px] px-2.5 py-1.5"
> >
<n.icon size={15} /> <LogOut size={13} />
{n.label} Sign out
</NavLink> </button>
))} </div>
</nav> </div>
{/* {/*
Stated in the chrome, on every screen, rather than buried in a policy Stated in the chrome, on every screen, rather than buried in a policy
page: this tier cannot reach a borrower. A supervisor should be able to page: this tier cannot reach a borrower. A supervisor should be able to
tell a partner bank what this portal can see without checking. tell a partner bank what this portal can see without checking.
*/} */}
<div className="mt-auto pt-4 border-t border-white/15 text-[10px] text-[#9dbfb1] leading-relaxed"> <div className="bg-green-soft border-b border-line px-5 py-2 flex items-center gap-2 text-[11px] text-[#186b3d]">
<b className="text-[#dcebe4] block mb-0.5">Aggregate view only</b> <ShieldCheck size={14} className="shrink-0" />
This portal holds no borrower identity. Figures are counts and rates <span>
across refinanced portfolios; cells below the minimum size are withheld. <b>Aggregate view only.</b> This portal holds no borrower identity. Figures are counts and rates
across refinanced portfolios; cells below the minimum size are withheld.
</span>
</div> </div>
</header>
<button <main className="flex-1 min-w-0 px-5 py-4 w-full max-w-[1400px] ul-pane">
onClick={() => {
clearSessions()
navigate('/login', { replace: true })
}}
className="mt-3 text-[11px] text-[#9dbfb1] hover:text-white text-left px-2"
>
Sign out{session ? ` · ${session.username}` : ''}
</button>
</aside>
<main className="flex-1 min-w-0 px-6 py-5 max-w-[1200px]">
<Outlet /> <Outlet />
</main> </main>
<footer className="text-center text-[10px] text-ink-3 py-3 font-mono">
UdyamLinc · Supervisory view — Team LincFusion, LincPay Solutions Pvt. Ltd.
</footer>
</div> </div>
) )
} }
+300
View File
@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p><b>The tenancy rule is unchanged and needs no exception here.</b> 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 (
<div className="ul-card px-3.5 py-3 flex gap-2.5">
<span
className={`w-8 h-8 rounded-[9px] grid place-items-center shrink-0 ${
warn ? 'bg-amber-soft text-amber' : 'bg-green-soft text-forest'
}`}
>
<Icon size={16} strokeWidth={2} />
</span>
<div className="min-w-0">
<div className="text-[10.5px] text-slate-500 leading-snug">{label}</div>
<div className="text-[22px] font-bold text-forest leading-tight mt-0.5">{value}</div>
<div className="text-[10.5px] text-slate-400 leading-snug">{sub}</div>
</div>
</div>
)
}
export function SourceCoveragePage() {
const freshnessQ = useQuery({
queryKey: ['freshness'],
queryFn: () => get<Freshness[]>('/admin/ingestion/freshness'),
})
const runsQ = useQuery({
queryKey: ['ingestion-runs'],
queryFn: () => get<IngestionRun[]>('/admin/ingestion/runs', { limit: 20 }),
})
const profilesQ = useQuery({
queryKey: ['connector-profiles'],
queryFn: () => get<ConnectorProfileRow[]>('/admin/connectors/profiles'),
})
const connectorRunsQ = useQuery({
queryKey: ['connector-runs'],
queryFn: () => get<ConnectorSyncRun[]>('/admin/connectors/runs', { limit: 30 }),
})
const batchesQ = useQuery({
queryKey: ['manual-batches'],
queryFn: () => get<ManualEntryBatch[]>('/admin/manual/batches', { limit: 20 }),
})
const institutionsQ = useQuery({
queryKey: ['apex-institutions'],
queryFn: () => get<InstitutionRow[]>('/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<string, LucideIcon> = {
'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 (
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<h1 className="text-[15px] font-bold text-forest leading-tight">Data source coverage</h1>
<p className="text-[11px] text-slate-500 max-w-[780px]">
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.
</p>
</div>
{demo && <StatusPill status="Demo data" />}
</div>
<PipelineStrip />
<div className="flex flex-wrap gap-3">
<KpiTile label="Sources on the inventory" value={inv.all.length} delta="across five families" />
<KpiTile label="Live today" value={count('LIVE')} delta="real data flowing" tone="good" />
<KpiTile
label="Simulated"
value={count('SIMULATED')}
delta="stamped, never mistakable for real"
tone="watch"
/>
<KpiTile label="Not connected" value={count('NOT_CONNECTED')} delta="seam built, source pending" />
<KpiTile
label="Lenders on a live extract"
value={`${lendersConnected}/${institutions.length}`}
delta="the rest deliver by upload"
tone={lendersConnected === institutions.length ? 'good' : 'watch'}
/>
</div>
{/* ── the mandate, family by family ─────────────────────────── */}
<Card
title="Against the mandate"
badge="four families"
actions={
<span className="text-[10.5px] text-slate-400">
Financial records · transaction proxies · market intelligence · climate
</span>
}
>
<table className="ul-table">
<thead>
<tr>
<th>Data family</th>
<th>What stands behind it</th>
<th className="text-right">Live</th>
<th>Coverage</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={m.family}>
<td className="font-semibold text-slate-700">
<span className="flex items-center gap-2">
{Icon && (
<span className={`w-6 h-6 rounded-[7px] grid place-items-center shrink-0 ${tile}`}>
<Icon size={13} strokeWidth={2} />
</span>
)}
{m.family}
</span>
</td>
<td className="text-slate-500 whitespace-normal">{m.detail}</td>
<td className="text-right font-mono text-[11.5px]">
{m.live}/{m.total}
</td>
<td className="w-[190px]">
<span className="flex items-center gap-2">
<span className="flex-1 h-[7px] rounded-full bg-slate-100 overflow-hidden">
<span className={`block h-full rounded-full ${tone}`} style={{ width: `${pct}%` }} />
</span>
<span className="font-mono text-[10.5px] text-slate-500 w-8 text-right">{pct}%</span>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
<p className="px-3.5 py-2.5 text-[10.5px] text-slate-500 border-t border-slate-100 leading-snug">
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{' '}
<span className="font-mono text-[10px]">SKIPPED_MISSING</span> rather than quietly passing.
</p>
</Card>
{/* ── observation reach ─────────────────────────────────────── */}
<Card title="Observation reach" badge="districts & lenders">
<div className="p-3.5 grid gap-3 sm:grid-cols-3">
<ReachTile
icon={MapPinned}
label="Districts with a commodity basket"
value={inv.districtsCovered}
sub="39 commodities tracked nightly"
/>
<ReachTile
icon={CalendarClock}
label="Districts fresh within 10 days"
value={
<>
{freshDistricts}
<span className="text-[13px] text-slate-400 font-semibold">/{inv.districtsCovered}</span>
</>
}
sub={
inv.worstMarketAge != null
? `oldest observation ${inv.worstMarketAge} days`
: 'nothing ingested yet'
}
warn={freshDistricts < inv.districtsCovered}
/>
<ReachTile
icon={Thermometer}
label="Climate series"
value="30 yr"
sub="baseline behind every rainfall and heat anomaly"
/>
</div>
<p className="px-3.5 pb-3 text-[10.5px] text-slate-500 leading-snug">
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.
</p>
</Card>
<StatusLegend />
{inv.groups.map((g) => (
<SourceGroup key={g.letter} {...g} />
))}
<p className="text-[10.5px] text-slate-400 leading-snug">
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.
</p>
</div>
)
}
+116
View File
@@ -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.
*
* <p>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.
*
* <p>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<Freshness[]>('/admin/ingestion/freshness'),
})
const runsQ = useQuery({
queryKey: ['ingestion-runs'],
queryFn: () => get<IngestionRun[]>('/admin/ingestion/runs', { limit: 20 }),
})
const profilesQ = useQuery({
queryKey: ['connector-profiles'],
queryFn: () => get<ConnectorProfileRow[]>('/admin/connectors/profiles'),
})
const connectorRunsQ = useQuery({
queryKey: ['connector-runs'],
queryFn: () => get<ConnectorSyncRun[]>('/admin/connectors/runs', { limit: 30 }),
})
const batchesQ = useQuery({
queryKey: ['manual-batches'],
queryFn: () => get<ManualEntryBatch[]>('/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 (
<div className="space-y-4">
<div className="flex items-start gap-3">
<div>
<h1 className="text-[15px] font-bold text-forest leading-tight">Data sources</h1>
<p className="text-[11px] text-slate-500 max-w-[760px]">
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.
</p>
</div>
{demo && <span className="pill pill-pend ml-auto shrink-0">Demo data</span>}
</div>
<PipelineStrip />
<div className="flex flex-wrap gap-3">
<KpiTile label="Sources on the inventory" value={inv.all.length} delta="across five families" />
<KpiTile label="Live today" value={count('LIVE')} delta="real data flowing" tone="good" />
<KpiTile
label="Simulated"
value={count('SIMULATED')}
delta="stamped, never mistakable for real"
tone="watch"
/>
<KpiTile label="Not connected" value={count('NOT_CONNECTED')} delta="seam built, source pending" />
<KpiTile
label="Districts covered"
value={inv.districtsCovered}
delta="commodity baskets ingested"
/>
</div>
<StatusLegend />
{inv.groups.map((g) => (
<SourceGroup key={g.letter} {...g} />
))}
<p className="text-[10.5px] text-slate-400 leading-snug">
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.
</p>
</div>
)
}
+11 -4
View File
@@ -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 { ForecastFan } from '../../components/charts/ForecastFan'
import { BandMigrationBar } from '../../components/charts/BandMigrationBar' import { BandMigrationBar } from '../../components/charts/BandMigrationBar'
import { bandMigration, portfolioForecast, sectorRollup } from '../../lib/demo/ops' import { bandMigration, portfolioForecast, sectorRollup } from '../../lib/demo/ops'
@@ -12,9 +13,15 @@ import { useScope } from './officerScope'
*/ */
export function ForecastsPage() { export function ForecastsPage() {
const { block } = useScope() const { block } = useScope()
const aggregate = portfolioForecast(block) // Aggregates are computed over this bank's own borrowers. They used to be
const sectors = sectorRollup(block) // derived from the unscoped demo book, so a portfolio chart mixed every bank.
const migration = bandMigration(block) 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 <Unavailable what="Portfolio forecast" onRetry={retry} />
if (!aggregate.length) return <Empty text="No forecast rows for this scope yet." />
const worst = aggregate.reduce((a, p) => (p.p10 - p.obligation < a.p10 - a.obligation ? p : a), aggregate[0]) 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 const covered = aggregate.filter((p) => p.p10 >= p.obligation).length
+6 -4
View File
@@ -210,10 +210,12 @@ export function IntegrationPage() {
<p className="text-[11px] text-slate-700 leading-relaxed"> <p className="text-[11px] text-slate-700 leading-relaxed">
<b>What is real and what is not.</b> The file-drop adapter reads an actual extract from a <b>What is real and what is not.</b> 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. 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 <b>simulated</b>: they generate an The CBSNexora adapter is <b>simulated</b>: no CBSNexora instance feeds this deployment, so it
extract in each core's own column dialect so the mapping layer can be exercised end to end. generates an extract in that platform's own field names to exercise the mapping layer end to
A live link to any of them is agreed per bank during onboarding — the schema conversation is end. Where a lender runs a different core, no product is named here — the per-profile mapping
the work, and this screen is what that conversation configures. 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.
</p> </p>
</div> </div>
+567 -135
View File
@@ -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 { 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 { clearSessions, get, officerSession } from '../../lib/api'
import type { Freshness } from '../../lib/api' import type { Freshness } from '../../lib/api'
import { demoFreshness, worstStaleness } from '../../lib/demo/feeds' import { demoFreshness, worstStaleness } from '../../lib/demo/feeds'
import { UdyamMark } from '../../components/ui/bits' import { UdyamMark } from '../../components/ui/bits'
import { MenuSelect } from '../../components/ui/MenuSelect'
import { BLOCKS } from '../../lib/demo/enterprises' 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 { monthRef, monthWindow } from '../../lib/demo/seed'
import { OfficerScopeProvider, useScope } from './officerScope' import { OfficerScopeProvider, useScope } from './officerScope'
/* /*
* Field officer console shell — dark forest sidebar on the left, identity bar * Field officer console shell — fixed header, grouped dropdown navigation.
* 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 * The console had thirteen flat items down a rail, which is more than anyone
* because the console is navigated by module, not by tab. * 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 }, * Icon-tile colours.
{ label: 'Portfolio', to: '/officer/portfolio' }, *
{ label: 'Enterprises', to: '/officer/enterprises' }, * <p>Written as literal class pairs rather than composed at runtime, because
{ label: 'Risk alerts', to: '/officer/alerts' }, * Tailwind scans source text — a class built from a variable is a class that
{ label: 'Forecasts', to: '/officer/forecasts' }, * never reaches the stylesheet.
{ label: 'Field visits', to: '/officer/visits' }, *
{ label: 'Trip-wires', to: '/officer/rules' }, * <p>Colour here is an aid to recognition, not information: the palette repeats
{ label: 'Data feeds', to: '/officer/feeds' }, * across menus and carries no meaning, so nobody can read a hue as a severity.
{ label: 'Data entry', to: '/officer/data-entry' }, * The risk scale stays reserved for bands, and only the alerts item borrows from
{ label: 'Core banking', to: '/officer/integration' }, * it — because there it <em>is</em> the meaning.
{ label: 'Reports', to: '/officer/reports' }, */
{ label: 'Settings', to: '/officer/settings' }, 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. * 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 (
<div className="relative">
<button
onClick={onToggle}
aria-expanded={open}
aria-haspopup="menu"
className={`flex items-center gap-1.5 px-3 h-[34px] rounded-[8px] text-[13px] whitespace-nowrap transition-colors ${
active
? 'bg-white text-forest font-semibold'
: open
? 'bg-white/15 text-white font-medium'
: 'text-white/70 font-medium hover:bg-white/10 hover:text-white'
}`}
>
<Icon size={14} strokeWidth={2} />
{group.label}
{groupAlerts > 0 && (
<span className="bg-risk-red text-white text-[9.5px] font-bold px-1.5 rounded-full leading-4 min-w-[17px] text-center">
{groupAlerts}
</span>
)}
<ChevronDown size={13} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{/*
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 && (
<div
role="menu"
className="ul-menu absolute left-0 top-[calc(100%+8px)] z-50 p-2.5 w-[min(620px,calc(100vw-2rem))]"
>
<div className="grid gap-1 sm:grid-cols-2">
{group.items.map((item) => {
const ItemIcon = item.icon
return (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={onClose}
role="menuitem"
className={({ isActive }) =>
`flex items-start gap-3 p-2.5 rounded-[9px] transition-colors ${
isActive ? 'bg-lp-blue-soft' : 'hover:bg-surface-3'
}`
}
>
{({ isActive }) => (
<>
<span
className={`w-10 h-10 rounded-[10px] grid place-items-center shrink-0 border transition-colors ${
isActive ? 'bg-lp-blue border-lp-blue text-white' : TONES[item.tone]
}`}
>
<ItemIcon size={18} strokeWidth={1.9} />
</span>
<span className="min-w-0">
<span className="flex items-center gap-1.5">
<span
className={`text-[13px] font-bold leading-tight ${
isActive ? 'text-lp-blue' : 'text-ink'
}`}
>
{item.label}
</span>
{item.badge === 'alerts' && openAlerts > 0 && (
<span className="bg-risk-red text-white text-[9px] font-bold px-1.5 rounded-[4px] leading-[15px] uppercase tracking-wide">
{openAlerts} open
</span>
)}
</span>
<span className="block text-[11.5px] text-ink-3 leading-snug mt-0.5">
{item.hint}
</span>
</span>
</>
)}
</NavLink>
)
})}
</div>
</div>
)}
</div>
)
}
/** 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 (
<div className="relative">
<button
onClick={onToggle}
aria-expanded={open}
aria-haspopup="menu"
className="flex items-center gap-2 pl-3 ml-1 border-l border-white/20 py-1"
>
<span className="w-[29px] h-[29px] rounded-full bg-lp-blue text-white grid place-items-center text-[10.5px] font-bold shrink-0">
{initials}
</span>
<span className="leading-tight text-left hidden lg:block">
<span className="block text-[12px] font-semibold text-white">{name}</span>
<span className="block text-[10px] text-white/55">{role}</span>
</span>
<ChevronDown size={13} className={`text-white/60 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div role="menu" className="ul-menu absolute right-0 top-[calc(100%+6px)] z-50 w-[210px] py-1.5">
<div className="px-3 py-2 border-b border-line lg:hidden">
<div className="text-[12.5px] font-semibold text-ink">{name}</div>
<div className="text-[10.5px] text-ink-3">{role}</div>
</div>
<NavLink
to="/officer/settings"
onClick={onClose}
role="menuitem"
className="flex items-center gap-2.5 px-3 py-2 text-[12.5px] text-ink hover:bg-surface-3"
>
<Settings size={14} className="text-ink-3" />
Settings
</NavLink>
<button
onClick={onSignOut}
role="menuitem"
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12.5px] text-risk-red hover:bg-risk-soft text-left"
>
<LogOut size={14} />
Sign out
</button>
</div>
)}
</div>
)
}
/* ------------------------------------------------------------------ */
export function OfficerShell() { export function OfficerShell() {
return ( return (
<OfficerScopeProvider> <OfficerScopeProvider>
@@ -72,13 +451,44 @@ export function OfficerShell() {
function ShellBody() { function ShellBody() {
const navigate = useNavigate() const navigate = useNavigate()
const { pathname } = useLocation()
const session = officerSession() const session = officerSession()
const { block, month, setBlock, setMonth } = useScope() const { block, month, setBlock, setMonth } = useScope()
const openAlerts = alertsFor(block).filter((a) => a.state === 'OPEN').length // Scoped counts. These drove a badge and a progress bar off the unscoped demo
const progress = weeklyVisitProgress(block) // 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 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<string | null>(null)
const headerRef = useRef<HTMLElement>(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(' ') .split(' ')
.map((w) => w[0]) .map((w) => w[0])
.join('') .join('')
@@ -90,134 +500,156 @@ function ShellBody() {
navigate('/login', { replace: true }) 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 (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={close}
className={({ isActive }) =>
`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'
}`
}
>
<Icon size={14} strokeWidth={2} />
{item.label}
</NavLink>
)
}
return ( return (
<div className="min-h-screen flex bg-[#f4f7f5]"> <div className="min-h-screen flex flex-col bg-page">
{/* ── sidebar ─────────────────────────────────────────── */} {/* ── fixed header: brand bar with grouped menus ─────────── */}
<aside className="w-[212px] shrink-0 bg-forest-deep text-white flex flex-col sticky top-0 h-screen"> <header ref={headerRef} className="sticky top-0 z-40">
<div className="px-4 py-4 flex items-center gap-2.5 border-b border-white/10"> <div className="bg-forest text-white flex items-center gap-4 px-4 h-14 shadow-[0_2px_14px_rgba(0,0,0,0.25)]">
<UdyamMark size={24} onDark /> <NavLink to="/officer" onClick={close} className="flex items-center gap-2.5 shrink-0">
<div className="leading-tight"> <UdyamMark size={26} onDark />
<div className="text-[14px] font-bold"> <span className="leading-tight hidden sm:block">
Udyam<span className="text-amber-hi">Linc</span> <span className="block text-[14px] font-bold tracking-[-0.02em]">UdyamLinc</span>
</div> <span className="block text-[10px] text-white/55">Field Officer Console</span>
<div className="text-[9px] text-[#8fae9c] font-mono tracking-[0.1em]">FIELD OFFICER CONSOLE</div> </span>
</div> </NavLink>
</div>
<nav className="flex-1 py-3 overflow-y-auto"> <span className="w-px h-6 bg-white/20 hidden md:block" />
{NAV.map((n) => (
<NavLink
key={n.to}
to={n.to}
end={n.end}
className={({ isActive }) =>
[
'flex items-center gap-2.5 px-4 py-[9px] text-[12px] font-medium border-l-[3px] transition-colors',
isActive
? 'bg-white/[0.08] text-white border-amber font-semibold'
: 'text-[#a9c4b5] border-transparent hover:bg-white/[0.05] hover:text-white',
].join(' ')
}
>
<span className="w-2.5 h-2.5 rounded-[3px] bg-current opacity-70" />
{n.label}
{n.to === '/officer/alerts' && openAlerts > 0 && (
<span className="ml-auto bg-risk-red text-white text-[9.5px] font-bold px-1.5 rounded-full">
{openAlerts}
</span>
)}
</NavLink>
))}
</nav>
{/* weekly visit progress — the card pinned to the sidebar on the deck */} {/* No overflow on this element. An `overflow-x: auto` here scrolls the row
<div className="m-3 rounded-lg bg-white/[0.07] border border-white/10 px-3 py-2.5"> on a narrow window, but it also establishes a clipping context that cuts
<div className="text-[9.5px] text-[#8fae9c] font-mono tracking-[0.1em]">THIS WEEK</div> off the dropdown panels — and on Windows the always-visible scrollbars
<div className="text-[22px] font-bold leading-tight"> eat into a 56px bar. Labels collapse at small widths instead. */}
{progress.done} <span className="text-[13px] text-[#8fae9c]">/ {progress.target}</span> <nav className="flex items-center gap-0.5 min-w-0">
</div> {directLink(HOME)}
<div className="text-[9.5px] text-[#8fae9c] mb-1.5">high-risk visits done</div> {GROUPS.map((g) => (
<div className="h-[5px] rounded-full bg-white/15 overflow-hidden"> <NavMenu
<div key={g.label}
className="h-full rounded-full bg-green-hi" group={g}
style={{ width: `${Math.min(100, (progress.done / progress.target) * 100)}%` }} open={openMenu === g.label}
/> onToggle={() => toggle(g.label)}
</div> onClose={close}
</div> openAlerts={openAlerts}
</aside> />
))}
{directLink(REPORTS)}
</nav>
{/* ── work area ───────────────────────────────────────── */} <div className="ml-auto flex items-center gap-2 shrink-0">
<div className="flex-1 min-w-0 flex flex-col"> <span className="hidden xl:flex items-center gap-2 bg-white/10 rounded-[8px] px-3 py-1.5 text-[12px] text-white/55 w-[210px]">
<header className="bg-white border-b border-slate-200 px-6 py-2.5 flex items-center gap-3 sticky top-0 z-30"> <Search size={13} />
<div className="min-w-0"> Search enterprise or ID…
<div className="text-[13.5px] font-bold text-forest leading-tight">Portfolio overview</div> </span>
<div className="text-[10.5px] text-slate-500 truncate">
{block === ALL_BLOCKS ? (session?.branch ?? 'All blocks') : `${block} block`} · {monthRef(new Date(`${month}-01T00:00:00`)).fullEn}
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<FreshnessChip />
<select
value={block}
onChange={(e) => setBlock(e.target.value)}
className="border border-slate-300 rounded-md px-2 py-1 text-[11.5px] bg-white outline-none focus:border-green"
aria-label="Block scope"
>
<option value={ALL_BLOCKS}>All blocks</option>
{BLOCKS.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
<select
value={month}
onChange={(e) => setMonth(e.target.value)}
className="border border-slate-300 rounded-md px-2 py-1 text-[11.5px] bg-white outline-none focus:border-green"
aria-label="Month scope"
>
{months.map((m) => (
<option key={m.key} value={m.key}>
{m.fullEn}
</option>
))}
</select>
<NavLink <NavLink
to="/officer/alerts" to="/officer/alerts"
className="relative w-8 h-8 grid place-items-center rounded-full hover:bg-slate-100" onClick={close}
className="relative w-8 h-8 grid place-items-center rounded-[8px] bg-white/10 hover:bg-white/20 text-white"
aria-label={`${openAlerts} open alerts`} aria-label={`${openAlerts} open alerts`}
> >
<span className="text-[14px]">🔔</span> <Bell size={15} />
{openAlerts > 0 && <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-risk-red" />} {openAlerts > 0 && (
<span className="absolute top-1 right-1 w-[6px] h-[6px] rounded-full bg-risk-red" />
)}
</NavLink> </NavLink>
<div className="flex items-center gap-2 pl-2 border-l border-slate-200"> <UserMenu
<span className="w-7 h-7 rounded-full bg-forest text-white grid place-items-center text-[10.5px] font-bold"> name={name}
{initials} role={(session?.role ?? 'FIELD_OFFICER').replace(/_/g, ' ').toLowerCase().replace(/^./, (c) => c.toUpperCase())}
</span> initials={initials}
<div className="leading-tight"> open={openMenu === '__user'}
<div className="text-[11.5px] font-semibold text-slate-700">{session?.fullName ?? 'Field Officer'}</div> onToggle={() => toggle('__user')}
<div className="text-[9.5px] text-slate-400">Field Officer</div> onClose={close}
</div> onSignOut={signOut}
<button onClick={signOut} className="text-[10.5px] text-slate-400 hover:text-risk-red ml-1"> />
Sign out </div>
</button> </div>
{/* ── scope row: what everything below is about ─────────── */}
<div className="bg-white border-b border-line px-5 h-[54px] flex items-center gap-3">
<div className="min-w-0">
<div className="text-[14.5px] font-bold text-ink leading-tight tracking-[-0.015em]">
{crumb.title}
</div>
<div className="text-[11px] text-ink-3 truncate">
{block === ALL_BLOCKS ? (session?.branch ?? 'All blocks') : `${block} block`} ·{' '}
{monthRef(new Date(`${month}-01T00:00:00`)).fullEn} · {crumb.sub}
</div> </div>
</div> </div>
</header>
<main className="flex-1 px-6 py-4 max-w-[1560px] w-full"> <div className="ml-auto flex items-center gap-2 shrink-0">
<Outlet /> {/* weekly visit progress, which used to live in the sidebar */}
</main> <span
className="hidden lg:flex items-center gap-2 text-[10.5px] text-ink-3"
title="High-risk visits done this week"
>
<span className="font-semibold text-ink-2 num">
{progress.done}/{progress.target}
</span>
<span className="w-16 h-[5px] rounded-full bg-surface-3 overflow-hidden">
<span
className="block h-full rounded-full bg-green"
style={{ width: `${Math.min(100, (progress.done / progress.target) * 100)}%` }}
/>
</span>
visits
</span>
<footer className="text-center text-[10px] text-slate-400 py-3 font-mono"> <FreshnessChip />
UdyamLinc · Predicting stress. Protecting enterprises. — Team LincFusion, LincPay Solutions Pvt. Ltd.
</footer> <MenuSelect
</div> label="Block"
value={block}
onChange={setBlock}
minWidth={124}
options={[
{ value: ALL_BLOCKS, label: 'All blocks', hint: `${BLOCKS.length} blocks in scope` },
...BLOCKS.map((b) => ({ value: b as string, label: b, hint: `${b} block only` })),
]}
/>
<MenuSelect
label="Month"
value={month}
onChange={setMonth}
minWidth={130}
options={months.map((m) => ({ value: m.key, label: m.fullEn }))}
/>
</div>
</div>
</header>
<main className="flex-1 px-5 py-4 w-full ul-pane">
<Outlet />
</main>
<footer className="text-center text-[10px] text-ink-3 py-3 font-mono">
UdyamLinc · Predicting stress. Protecting enterprises. — Team LincFusion, LincPay Solutions Pvt. Ltd.
</footer>
</div> </div>
) )
} }
+4 -2
View File
@@ -4,7 +4,7 @@ import { get } from '../../lib/api'
import type { FeatureValueRow, RuleOutcome, RuleStatus, TripWireRule } from '../../lib/api' import type { FeatureValueRow, RuleOutcome, RuleStatus, TripWireRule } from '../../lib/api'
import { Card, Empty } from '../../components/ui/bits' import { Card, Empty } from '../../components/ui/bits'
import { DEMO_FEATURES, DEMO_RULES, demoFeatureValues, demoRuleOutcomes } from '../../lib/demo/feeds' 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' import { useScope } from './officerScope'
/** /**
@@ -19,7 +19,9 @@ import { useScope } from './officerScope'
*/ */
export function RulesPage() { export function RulesPage() {
const { block } = useScope() 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 [enterpriseId, setEnterpriseId] = useState(enterprises[0]?.enterpriseId ?? 'MSME-4417')
const rulesQ = useQuery({ queryKey: ['rules'], queryFn: () => get<TripWireRule[]>('/admin/rules') }) const rulesQ = useQuery({ queryKey: ['rules'], queryFn: () => get<TripWireRule[]>('/admin/rules') })
+1 -1
View File
@@ -93,7 +93,7 @@ export function SettingsPage() {
type="checkbox" type="checkbox"
checked={packs[s]} checked={packs[s]}
onChange={(e) => setPacks({ ...packs, [s]: e.target.checked })} onChange={(e) => setPacks({ ...packs, [s]: e.target.checked })}
className="accent-[#0b5d3b]" className="accent-[#2a71b8]"
/> />
<span className="text-[12px] text-slate-700">{SECTORS[s].label}</span> <span className="text-[12px] text-slate-700">{SECTORS[s].label}</span>
<span className="text-[11px] text-slate-400 ml-auto">{SECTORS[s].labelHi}</span> <span className="text-[11px] text-slate-400 ml-auto">{SECTORS[s].labelHi}</span>
+379
View File
@@ -204,6 +204,11 @@ export interface Enterprise {
band: Band band: Band
trend: number[] // 6-month score history, oldest first trend: number[] // 6-month score history, oldest first
lastUpdated: string 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 ₹. */ /** One month of the quantile forecast. Amounts are net cash position in ₹. */
@@ -815,3 +820,377 @@ export interface BranchCell {
stressRatePct?: number stressRatePct?: number
avgScore?: 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.
*
* <p>`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<EnterpriseDetail> {
const wire = await get<EnterpriseDetail>(`/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<string, unknown>)),
}
}
/**
* Did the backend answer, and answer "no"?
*
* <p>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.
*
* <p>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.
*
* <p>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<RiskAlertRow[]>("/officer/alerts", { block, state })
}
export function fetchVisits(block: string) {
return get<FieldVisit[]>('/officer/visits', { block })
}
/** Ids on this officer's book that have a phone-app login. */
export function fetchAppEnrolled(block: string) {
return get<string[]>('/officer/app-enrolled', { block })
}
export function fetchEnterprises(block: string) {
return get<Enterprise[]>('/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.
*
* <p>`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.
*
* <p>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.
*
* <p>`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.
*
* <p>`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<string, MetricArm>
}
export function fetchModelMetrics() {
return get<ModelMetrics>('/admin/model/metrics')
}
/** Human labels for the arms the backtest writes. */
export const ARM_LABEL: Record<string, string> = {
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.
*
* <p>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<string, unknown>
alert: RiskAlertRow | null
forecast: ForecastPointRow[]
todayIn: number
todayOut: number
adviceEn: string | null
adviceHi: string | null
lastSyncedAt: string | null
}
export function fetchAppHome() {
return get<AppHomeWire>('/app/home')
}
export function fetchAppLedger() {
return get<Record<string, unknown>[]>('/app/ledger')
}
/** The owner's own reason codes, newest run only. Scoped by the token. */
export function fetchAppReasons() {
return get<ReasonCodeRow[]>('/app/reasons')
}
export function fetchAppAlerts() {
return get<RiskAlertRow[]>('/app/alerts')
}
/**
* Push locally queued entries.
*
* <p>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<SyncResult>(
'/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.
*
* <p>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<Record<string, unknown>>('/app/consent', { active })
}
/**
* Server ledger row → the shape the app is written against.
*
* <p>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<string, unknown>): 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<T>(token: string, path: string): Promise<T> {
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<EnterpriseSession> {
const res = await rawApi.post('/auth/app-login', { enterpriseId, pin })
return res.data.data as EnterpriseSession
}
export const borrower = {
home: (t: string) => asBorrower<AppHomeWire>(t, '/app/home'),
reasons: (t: string) => asBorrower<ReasonCodeRow[]>(t, '/app/reasons'),
ledger: (t: string) => asBorrower<Record<string, unknown>[]>(t, '/app/ledger'),
}
+31 -78
View File
@@ -41,22 +41,13 @@ export const CANONICAL: CanonicalColumns = {
], ],
} }
const FINACLE: Record<string, string> = { /**
enterprise_ref: 'ENTITY_REF', * CBSNexora's field names. LincPay's own core banking platform, and the only
loan_account_ref: 'CUST_ACCT_ID', * core named anywhere in this system — where a lender runs someone else's, the
product: 'SCHM_CODE', * per-profile mapping absorbs their column names without the platform ever
sanctioned_amount: 'SANCT_AMT', * needing the product's name.
outstanding: 'PRIN_OS', */
emi_amount: 'INSTL_AMT', const CBSNEXORA: Record<string, string> = {
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<string, string> = {
enterprise_ref: 'borrowerRef', enterprise_ref: 'borrowerRef',
loan_account_ref: 'loanId', loan_account_ref: 'loanId',
product: 'productCode', product: 'productCode',
@@ -86,52 +77,14 @@ export function demoCatalog(): ConnectorCatalogEntry[] {
suggestedMapping: IDENTITY, suggestedMapping: IDENTITY,
}, },
{ {
vendor: 'FINACLE', vendor: 'CBSNEXORA',
displayName: 'Infosys Finacle (simulated)', displayName: 'CBSNexora (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)',
transport: 'REST', transport: 'REST',
simulated: true, simulated: true,
nativeColumns: [...Object.values(CUSTOM), 'borrowerName', 'borrowerMobile', 'branchCode'], nativeColumns: [...Object.values(CBSNEXORA), 'borrowerName', 'borrowerMobile', 'branchCode'],
activationNote: activationNote:
'For a lender whose loan management system is their own. One paged read-only endpoint; the mapping absorbs whatever they call the fields.', '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: CUSTOM, suggestedMapping: CBSNEXORA,
}, },
] ]
} }
@@ -139,21 +92,21 @@ export function demoCatalog(): ConnectorCatalogEntry[] {
export function demoProfiles(): ConnectorProfileRow[] { export function demoProfiles(): ConnectorProfileRow[] {
return [ return [
{ {
code: 'MPGB-FINACLE', code: 'MPGB-CBSNEXORA',
displayName: 'MP Gramin Bank — Finacle nightly extract', displayName: 'MP Gramin Bank — CBSNexora loan API',
vendor: 'FINACLE', vendor: 'CBSNEXORA',
vendorName: 'Infosys Finacle (simulated)', vendorName: 'CBSNexora (simulated)',
transport: 'FILE', transport: 'REST',
simulated: true, simulated: true,
activationNote: demoCatalog()[1].activationNote, activationNote: demoCatalog()[1].activationNote,
institutionCode: 'MPGB', institutionCode: 'MPGB',
endpoint: 'loanbook-*.csv', endpoint: 'https://cbsnexora.mpgb.example/api/loans',
scheduleCron: '0 30 1 * * *', scheduleCron: '0 30 1 * * *',
autoCommit: false, autoCommit: false,
active: true, active: true,
lastSyncAt: '2026-08-07T01:30:00', lastSyncAt: '2026-08-07T01:30:00',
lastStatus: 'PARTIAL', lastStatus: 'PARTIAL',
mapping: FINACLE, mapping: CBSNEXORA,
sourceColumns: demoCatalog()[1].nativeColumns, sourceColumns: demoCatalog()[1].nativeColumns,
}, },
{ {
@@ -175,22 +128,22 @@ export function demoProfiles(): ConnectorProfileRow[] {
sourceColumns: [], sourceColumns: [],
}, },
{ {
code: 'VSFB-LMS', code: 'VSFB-CBSNEXORA',
displayName: 'Vindhya SFB — in-house LMS over REST', displayName: 'Vindhya SFB — CBSNexora loan API',
vendor: 'CUSTOM_API', vendor: 'CBSNEXORA',
vendorName: 'In-house LMS over REST (simulated)', vendorName: 'CBSNexora (simulated)',
transport: 'REST', transport: 'REST',
simulated: true, simulated: true,
activationNote: demoCatalog()[4].activationNote, activationNote: demoCatalog()[1].activationNote,
institutionCode: 'VSFB', institutionCode: 'VSFB',
endpoint: 'https://lms.vindhyasfb.example/api/loans', endpoint: 'https://cbsnexora.vindhyasfb.example/api/loans',
scheduleCron: '0 15 1 * * *', scheduleCron: '0 15 1 * * *',
autoCommit: false, autoCommit: false,
active: true, active: true,
lastSyncAt: null, lastSyncAt: null,
lastStatus: 'NEVER_RUN', lastStatus: 'NEVER_RUN',
mapping: CUSTOM, mapping: CBSNEXORA,
sourceColumns: demoCatalog()[4].nativeColumns, sourceColumns: demoCatalog()[1].nativeColumns,
}, },
] ]
} }
@@ -244,8 +197,8 @@ export function demoRuns(): ConnectorSyncRun[] {
}, },
{ {
id: 13, id: 13,
profileCode: 'MPGB-FINACLE', profileCode: 'MPGB-CBSNEXORA',
vendor: 'FINACLE', vendor: 'CBSNEXORA',
institutionCode: 'MPGB', institutionCode: 'MPGB',
trigger: 'SCHEDULED', trigger: 'SCHEDULED',
triggeredBy: 'scheduler', triggeredBy: 'scheduler',
@@ -266,8 +219,8 @@ export function demoRuns(): ConnectorSyncRun[] {
}, },
{ {
id: 12, id: 12,
profileCode: 'MPGB-FINACLE', profileCode: 'MPGB-CBSNEXORA',
vendor: 'FINACLE', vendor: 'CBSNEXORA',
institutionCode: 'MPGB', institutionCode: 'MPGB',
trigger: 'MANUAL', trigger: 'MANUAL',
triggeredBy: 'supervisor', triggeredBy: 'supervisor',
+424
View File
@@ -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.
*
* <p>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.
*
* <p>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<SourceSpec>((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,
},
]
}
+78
View File
@@ -0,0 +1,78 @@
import type { LucideIcon } from 'lucide-react'
/**
* The vocabulary the data-source inventory is written in.
*
* <p>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<SourceStatus, string> = {
LIVE: 'Live',
SIMULATED: 'Simulated',
NOT_CONNECTED: 'Not connected',
}
export const STATUS_PILL: Record<SourceStatus, string> = {
LIVE: 'pill-ok',
SIMULATED: 'pill-pend',
NOT_CONNECTED: 'pill-mut',
}
export const STATUS_HELP: Record<SourceStatus, string> = {
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<SourceStatus, string> = {
LIVE: 'bg-green-soft text-forest',
SIMULATED: 'bg-amber-soft text-amber',
NOT_CONNECTED: 'bg-slate-100 text-slate-400',
}