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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { Building2, Gauge, Landmark, MapPinned, ShieldCheck, Siren } from 'lucide-react'
|
||||
import { Building2, Database, Gauge, Landmark, LogOut, MapPinned, ShieldCheck, Siren } from 'lucide-react'
|
||||
import { clearSessions, isRegionalOfficeRole, officerSession } from '../../lib/api'
|
||||
|
||||
const NAV = [
|
||||
@@ -8,6 +8,7 @@ const NAV = [
|
||||
{ label: 'Partner banks', to: '/nabard/banks', icon: Landmark },
|
||||
{ label: 'Early warning', to: '/nabard/digest', icon: Siren },
|
||||
{ label: 'Institutions', to: '/nabard/institutions', icon: Building2 },
|
||||
{ label: 'Data coverage', to: '/nabard/sources', icon: Database },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -17,75 +18,96 @@ const NAV = [
|
||||
* branch officer are not two ranks of one job — they see different things and
|
||||
* may do different things, and a portal that looked identical would invite the
|
||||
* assumption that the apex user is simply a bank user with more rights.
|
||||
*
|
||||
* <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() {
|
||||
const navigate = useNavigate()
|
||||
const session = officerSession()
|
||||
const tier = isRegionalOfficeRole(session?.role) ? 'NABARD Regional Office' : 'NABARD Head Office'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-slate-50">
|
||||
<aside className="w-[210px] shrink-0 bg-[#123c2b] text-white flex flex-col px-3 py-5">
|
||||
<div className="flex items-center gap-2.5 px-2 pb-5">
|
||||
<span className="w-9 h-9 rounded-[9px] bg-[#0f6b4c] grid place-items-center shrink-0">
|
||||
<ShieldCheck size={19} />
|
||||
</span>
|
||||
<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'}
|
||||
<div className="min-h-screen flex flex-col bg-page">
|
||||
<header className="sticky top-0 z-40">
|
||||
<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)]">
|
||||
<NavLink to="/nabard" className="flex items-center gap-2.5 shrink-0">
|
||||
<span className="w-[30px] h-[30px] rounded-[9px] bg-forest-mid grid place-items-center shrink-0">
|
||||
<ShieldCheck size={17} />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="leading-tight hidden sm:block">
|
||||
<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">
|
||||
{NAV.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
end={n.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[12.5px] transition-colors ${
|
||||
isActive ? 'bg-white/15 text-white font-semibold' : 'text-[#cfe2da] hover:bg-white/10'
|
||||
}`
|
||||
}
|
||||
<span className="w-px h-6 bg-white/20 hidden md:block" />
|
||||
|
||||
<nav className="flex items-center gap-0.5 min-w-0">
|
||||
{NAV.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
end={n.end}
|
||||
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} />
|
||||
{n.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<LogOut size={13} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
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
|
||||
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">
|
||||
<b className="text-[#dcebe4] block mb-0.5">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.
|
||||
<div className="bg-green-soft border-b border-line px-5 py-2 flex items-center gap-2 text-[11px] text-[#186b3d]">
|
||||
<ShieldCheck size={14} className="shrink-0" />
|
||||
<span>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<button
|
||||
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]">
|
||||
<main className="flex-1 min-w-0 px-5 py-4 w-full max-w-[1400px] ul-pane">
|
||||
<Outlet />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Card, KpiTile } from '../../components/ui/bits'
|
||||
import { Card, Empty, KpiTile, Unavailable } from '../../components/ui/bits'
|
||||
import { useTenantEnterprises } from './useTenantData'
|
||||
import { ForecastFan } from '../../components/charts/ForecastFan'
|
||||
import { BandMigrationBar } from '../../components/charts/BandMigrationBar'
|
||||
import { bandMigration, portfolioForecast, sectorRollup } from '../../lib/demo/ops'
|
||||
@@ -12,9 +13,15 @@ import { useScope } from './officerScope'
|
||||
*/
|
||||
export function ForecastsPage() {
|
||||
const { block } = useScope()
|
||||
const aggregate = portfolioForecast(block)
|
||||
const sectors = sectorRollup(block)
|
||||
const migration = bandMigration(block)
|
||||
// Aggregates are computed over this bank's own borrowers. They used to be
|
||||
// derived from the unscoped demo book, so a portfolio chart mixed every bank.
|
||||
const { rows: book, unavailable, retry } = useTenantEnterprises(block)
|
||||
const aggregate = portfolioForecast(block, book)
|
||||
const sectors = sectorRollup(block, book)
|
||||
const migration = bandMigration(block, book)
|
||||
|
||||
if (unavailable) return <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 covered = aggregate.filter((p) => p.p10 >= p.obligation).length
|
||||
|
||||
@@ -210,10 +210,12 @@ export function IntegrationPage() {
|
||||
<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
|
||||
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
|
||||
extract in each core's own column dialect so the mapping layer can be exercised end to end.
|
||||
A live link to any of them is agreed per bank during onboarding — the schema conversation is
|
||||
the work, and this screen is what that conversation configures.
|
||||
The CBSNexora adapter is <b>simulated</b>: no CBSNexora instance feeds this deployment, so it
|
||||
generates an extract in that platform's own field names to exercise the mapping layer end to
|
||||
end. Where a lender runs a different core, no product is named here — the per-profile mapping
|
||||
absorbs their column names, which is precisely why the platform never needs to know. A live
|
||||
link is agreed per bank during onboarding; the schema conversation is the work, and this
|
||||
screen is what that conversation configures.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,36 +1,224 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Bell,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
Database,
|
||||
FileSpreadsheet,
|
||||
FolderInput,
|
||||
Gauge,
|
||||
LineChart,
|
||||
LogOut,
|
||||
MapPin,
|
||||
PencilRuler,
|
||||
Search,
|
||||
Settings,
|
||||
Siren,
|
||||
Store,
|
||||
Table2,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { clearSessions, get, officerSession } from '../../lib/api'
|
||||
import type { Freshness } from '../../lib/api'
|
||||
import { demoFreshness, worstStaleness } from '../../lib/demo/feeds'
|
||||
import { UdyamMark } from '../../components/ui/bits'
|
||||
import { MenuSelect } from '../../components/ui/MenuSelect'
|
||||
import { BLOCKS } from '../../lib/demo/enterprises'
|
||||
import { ALL_BLOCKS, alertsFor, weeklyVisitProgress } from '../../lib/demo/ops'
|
||||
import { ALL_BLOCKS } from '../../lib/demo/ops'
|
||||
import { useTenantAlerts, useTenantVisits } from './useTenantData'
|
||||
import { monthRef, monthWindow } from '../../lib/demo/seed'
|
||||
import { OfficerScopeProvider, useScope } from './officerScope'
|
||||
|
||||
/*
|
||||
* Field officer console shell — dark forest sidebar on the left, identity bar
|
||||
* across the top, work area on the right. This is the layout on the deck's
|
||||
* wireframe slide; it deliberately differs from BharatLinc's horizontal MegaNav
|
||||
* because the console is navigated by module, not by tab.
|
||||
* Field officer console shell — fixed header, grouped dropdown navigation.
|
||||
*
|
||||
* The console had thirteen flat items down a rail, which is more than anyone
|
||||
* scans: "Data feeds", "Data entry", "Data sources" and "Core banking" are one
|
||||
* subject wearing four labels, and nothing in a flat list said so. They are now
|
||||
* four menus that name the *question* a screen answers — where does the book
|
||||
* stand, what is going wrong, what are we reading — with the screens under them.
|
||||
*
|
||||
* Fixed rather than sticky-on-scroll: an officer moves between screens far more
|
||||
* often than they scroll one, and a header that comes and goes costs a re-aim
|
||||
* every time. The scope row underneath it is part of the same furniture, because
|
||||
* block and month change what every screen below means.
|
||||
*/
|
||||
|
||||
const NAV: { label: string; to: string; end?: boolean }[] = [
|
||||
{ label: 'Dashboard', to: '/officer', end: true },
|
||||
{ label: 'Portfolio', to: '/officer/portfolio' },
|
||||
{ label: 'Enterprises', to: '/officer/enterprises' },
|
||||
{ label: 'Risk alerts', to: '/officer/alerts' },
|
||||
{ label: 'Forecasts', to: '/officer/forecasts' },
|
||||
{ label: 'Field visits', to: '/officer/visits' },
|
||||
{ label: 'Trip-wires', to: '/officer/rules' },
|
||||
{ label: 'Data feeds', to: '/officer/feeds' },
|
||||
{ label: 'Data entry', to: '/officer/data-entry' },
|
||||
{ label: 'Core banking', to: '/officer/integration' },
|
||||
{ label: 'Reports', to: '/officer/reports' },
|
||||
{ label: 'Settings', to: '/officer/settings' },
|
||||
/**
|
||||
* Icon-tile colours.
|
||||
*
|
||||
* <p>Written as literal class pairs rather than composed at runtime, because
|
||||
* Tailwind scans source text — a class built from a variable is a class that
|
||||
* never reaches the stylesheet.
|
||||
*
|
||||
* <p>Colour here is an aid to recognition, not information: the palette repeats
|
||||
* across menus and carries no meaning, so nobody can read a hue as a severity.
|
||||
* The risk scale stays reserved for bands, and only the alerts item borrows from
|
||||
* it — because there it <em>is</em> the meaning.
|
||||
*/
|
||||
const TONES = {
|
||||
blue: 'bg-[#e7f0f9] text-[#2a71b8] border-[#cfe1f3]',
|
||||
green: 'bg-[#e7f6ee] text-[#1f8b50] border-[#cfeadd]',
|
||||
teal: 'bg-[#e3f3f6] text-[#0f7d92] border-[#c9e7ec]',
|
||||
indigo: 'bg-[#eceefa] text-[#4d5bb0] border-[#d9ddf3]',
|
||||
violet: 'bg-[#f1ecf8] text-[#6f5ba0] border-[#e2d9f0]',
|
||||
amber: 'bg-[#fdf3e6] text-[#b8710f] border-[#f7e3c6]',
|
||||
rose: 'bg-[#fbeae8] text-[#b5342a] border-[#f5d4d0]',
|
||||
} as const
|
||||
|
||||
type Tone = keyof typeof TONES
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
to: string
|
||||
icon: LucideIcon
|
||||
tone: Tone
|
||||
hint: string
|
||||
end?: boolean
|
||||
/** Renders the open-alert count. Only one item claims it. */
|
||||
badge?: 'alerts'
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
/** Direct links sit beside the menus rather than inside a menu of one. */
|
||||
const HOME: NavItem = {
|
||||
label: 'Dashboard',
|
||||
to: '/officer',
|
||||
icon: Gauge,
|
||||
tone: 'blue',
|
||||
hint: 'Where the book stands today',
|
||||
end: true,
|
||||
}
|
||||
|
||||
const REPORTS: NavItem = {
|
||||
label: 'Reports',
|
||||
to: '/officer/reports',
|
||||
icon: Table2,
|
||||
tone: 'violet',
|
||||
hint: 'Supervisory pack and exports',
|
||||
}
|
||||
|
||||
const GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Portfolio',
|
||||
icon: Store,
|
||||
items: [
|
||||
{
|
||||
label: 'Sector packs',
|
||||
to: '/officer/portfolio',
|
||||
icon: Store,
|
||||
tone: 'green',
|
||||
hint: 'Dairy, poultry, food processing, handicrafts and rural retail — each modelled separately.',
|
||||
},
|
||||
{
|
||||
label: 'Enterprises',
|
||||
to: '/officer/enterprises',
|
||||
icon: Building2,
|
||||
tone: 'indigo',
|
||||
hint: 'The full register, searchable, with score, band and trend per borrower.',
|
||||
},
|
||||
{
|
||||
label: 'Forecasts',
|
||||
to: '/officer/forecasts',
|
||||
icon: LineChart,
|
||||
tone: 'teal',
|
||||
hint: 'Net cash position projected 1–6 months at P10 / P50 / P90, against the instalment due.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Risk',
|
||||
icon: Siren,
|
||||
items: [
|
||||
{
|
||||
label: 'Risk alerts',
|
||||
to: '/officer/alerts',
|
||||
icon: Siren,
|
||||
tone: 'rose',
|
||||
hint: 'Open, acknowledged, escalated and snoozed, ranked by what is closest.',
|
||||
badge: 'alerts',
|
||||
},
|
||||
{
|
||||
label: 'Trip-wires',
|
||||
to: '/officer/rules',
|
||||
icon: PencilRuler,
|
||||
tone: 'amber',
|
||||
hint: 'Every rule with its expression, and why it fired, skipped or stayed silent.',
|
||||
},
|
||||
{
|
||||
label: 'Field visits',
|
||||
to: '/officer/visits',
|
||||
icon: MapPin,
|
||||
tone: 'violet',
|
||||
hint: 'Ranked playbook, scheduling, and the outcome log that closes the loop.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Data',
|
||||
icon: Database,
|
||||
items: [
|
||||
{
|
||||
label: 'Data sources',
|
||||
to: '/officer/sources',
|
||||
icon: Database,
|
||||
tone: 'blue',
|
||||
hint: 'Every route data enters by — APIs, core banking, uploads and the ledger.',
|
||||
},
|
||||
{
|
||||
label: 'Data feeds',
|
||||
to: '/officer/feeds',
|
||||
icon: LineChart,
|
||||
tone: 'teal',
|
||||
hint: 'Mandi prices and climate signals, with the age of every observation.',
|
||||
},
|
||||
{
|
||||
label: 'Data entry',
|
||||
to: '/officer/data-entry',
|
||||
icon: FileSpreadsheet,
|
||||
tone: 'green',
|
||||
hint: 'Upload a rate sheet or a loan book — staged, previewed, then committed.',
|
||||
},
|
||||
{
|
||||
label: 'Core banking',
|
||||
to: '/officer/integration',
|
||||
icon: FolderInput,
|
||||
tone: 'indigo',
|
||||
hint: 'CBSNexora connections, per-bank field mapping and sync history.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Page title for the breadcrumb, resolved longest-prefix-first. */
|
||||
const TITLES: { to: string; title: string; sub: string }[] = [
|
||||
{ to: '/officer/portfolio', title: 'Sector risk packs', sub: 'Five rural trades modelled separately' },
|
||||
{ to: '/officer/enterprises', title: 'Enterprises', sub: 'Every borrower on your book' },
|
||||
{ to: '/officer/alerts', title: 'Risk alerts', sub: 'Early warning, ranked by what is closest' },
|
||||
{ to: '/officer/forecasts', title: 'Forecasts', sub: 'Multi-horizon cash projection with explainability' },
|
||||
{ to: '/officer/visits', title: 'Field visits', sub: 'Playbook, log and feedback loop' },
|
||||
{ to: '/officer/rules', title: 'Trip-wires', sub: 'Every rule, its expression and its outcome' },
|
||||
{ to: '/officer/sources', title: 'Data sources', sub: 'Every route by which data enters the platform' },
|
||||
{ to: '/officer/feeds', title: 'Data feeds', sub: 'Where the numbers came from, and how old they are' },
|
||||
{ to: '/officer/data-entry', title: 'Data entry', sub: 'Staged, previewed, then committed' },
|
||||
{ to: '/officer/integration', title: 'Core banking', sub: 'Connections, field mapping and sync history' },
|
||||
{ to: '/officer/reports', title: 'Reports', sub: 'Supervisory pack and model monitoring' },
|
||||
{ to: '/officer/settings', title: 'Settings', sub: 'Console preferences' },
|
||||
{ to: '/officer/early-warning', title: 'Early warning', sub: 'Ranked by when the downside stops covering the instalment' },
|
||||
{ to: '/officer', title: 'Portfolio overview', sub: 'Where the book stands today' },
|
||||
]
|
||||
|
||||
function titleFor(pathname: string) {
|
||||
return TITLES.find((t) => pathname === t.to || pathname.startsWith(`${t.to}/`)) ?? TITLES[TITLES.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Data age, in the chrome rather than on a screen someone has to go and find.
|
||||
*
|
||||
@@ -62,6 +250,197 @@ function FreshnessChip() {
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dropdown */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* One header menu. Opens on click rather than on hover — a hover menu over a
|
||||
* dense console opens itself while someone is travelling to something else, and
|
||||
* on a touch screen it has no open gesture at all.
|
||||
*/
|
||||
function NavMenu({
|
||||
group,
|
||||
open,
|
||||
onToggle,
|
||||
onClose,
|
||||
openAlerts,
|
||||
}: {
|
||||
group: NavGroup
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
onClose: () => void
|
||||
openAlerts: number
|
||||
}) {
|
||||
const { pathname } = useLocation()
|
||||
const active = group.items.some((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
||||
const Icon = group.icon
|
||||
const groupAlerts = group.items.some((i) => i.badge === 'alerts') ? openAlerts : 0
|
||||
|
||||
return (
|
||||
<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() {
|
||||
return (
|
||||
<OfficerScopeProvider>
|
||||
@@ -72,13 +451,44 @@ export function OfficerShell() {
|
||||
|
||||
function ShellBody() {
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const session = officerSession()
|
||||
const { block, month, setBlock, setMonth } = useScope()
|
||||
const openAlerts = alertsFor(block).filter((a) => a.state === 'OPEN').length
|
||||
const progress = weeklyVisitProgress(block)
|
||||
// Scoped counts. These drove a badge and a progress bar off the unscoped demo
|
||||
// portfolio, so the header told an MPGB officer how many alerts existed across
|
||||
// every bank on the platform.
|
||||
const { rows: scopedAlerts } = useTenantAlerts(block)
|
||||
const { rows: scopedVisits } = useTenantVisits(block)
|
||||
const openAlerts = scopedAlerts.filter((a) => a.state === 'OPEN').length
|
||||
const doneThisWeek = scopedVisits.filter((v) => v.state === 'DONE').length
|
||||
const progress = { done: doneThisWeek, target: Math.max(3, scopedVisits.length) }
|
||||
const months = monthWindow(4, -3).reverse() // current month first, three back
|
||||
const crumb = titleFor(pathname)
|
||||
|
||||
const initials = (session?.fullName ?? 'Field Officer')
|
||||
// One open menu at a time, addressed by label. Null means everything closed.
|
||||
const [openMenu, setOpenMenu] = useState<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(' ')
|
||||
.map((w) => w[0])
|
||||
.join('')
|
||||
@@ -90,134 +500,156 @@ function ShellBody() {
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
const toggle = (id: string) => setOpenMenu((cur) => (cur === id ? null : id))
|
||||
const close = () => setOpenMenu(null)
|
||||
|
||||
const directLink = (item: NavItem) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<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 (
|
||||
<div className="min-h-screen flex bg-[#f4f7f5]">
|
||||
{/* ── sidebar ─────────────────────────────────────────── */}
|
||||
<aside className="w-[212px] shrink-0 bg-forest-deep text-white flex flex-col sticky top-0 h-screen">
|
||||
<div className="px-4 py-4 flex items-center gap-2.5 border-b border-white/10">
|
||||
<UdyamMark size={24} onDark />
|
||||
<div className="leading-tight">
|
||||
<div className="text-[14px] font-bold">
|
||||
Udyam<span className="text-amber-hi">Linc</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-[#8fae9c] font-mono tracking-[0.1em]">FIELD OFFICER CONSOLE</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-screen flex flex-col bg-page">
|
||||
{/* ── fixed header: brand bar with grouped menus ─────────── */}
|
||||
<header ref={headerRef} className="sticky top-0 z-40">
|
||||
<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)]">
|
||||
<NavLink to="/officer" onClick={close} className="flex items-center gap-2.5 shrink-0">
|
||||
<UdyamMark size={26} onDark />
|
||||
<span className="leading-tight hidden sm:block">
|
||||
<span className="block text-[14px] font-bold tracking-[-0.02em]">UdyamLinc</span>
|
||||
<span className="block text-[10px] text-white/55">Field Officer Console</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
|
||||
<nav className="flex-1 py-3 overflow-y-auto">
|
||||
{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>
|
||||
<span className="w-px h-6 bg-white/20 hidden md:block" />
|
||||
|
||||
{/* weekly visit progress — the card pinned to the sidebar on the deck */}
|
||||
<div className="m-3 rounded-lg bg-white/[0.07] border border-white/10 px-3 py-2.5">
|
||||
<div className="text-[9.5px] text-[#8fae9c] font-mono tracking-[0.1em]">THIS WEEK</div>
|
||||
<div className="text-[22px] font-bold leading-tight">
|
||||
{progress.done} <span className="text-[13px] text-[#8fae9c]">/ {progress.target}</span>
|
||||
</div>
|
||||
<div className="text-[9.5px] text-[#8fae9c] mb-1.5">high-risk visits done</div>
|
||||
<div className="h-[5px] rounded-full bg-white/15 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-green-hi"
|
||||
style={{ width: `${Math.min(100, (progress.done / progress.target) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/* No overflow on this element. An `overflow-x: auto` here scrolls the row
|
||||
on a narrow window, but it also establishes a clipping context that cuts
|
||||
off the dropdown panels — and on Windows the always-visible scrollbars
|
||||
eat into a 56px bar. Labels collapse at small widths instead. */}
|
||||
<nav className="flex items-center gap-0.5 min-w-0">
|
||||
{directLink(HOME)}
|
||||
{GROUPS.map((g) => (
|
||||
<NavMenu
|
||||
key={g.label}
|
||||
group={g}
|
||||
open={openMenu === g.label}
|
||||
onToggle={() => toggle(g.label)}
|
||||
onClose={close}
|
||||
openAlerts={openAlerts}
|
||||
/>
|
||||
))}
|
||||
{directLink(REPORTS)}
|
||||
</nav>
|
||||
|
||||
{/* ── work area ───────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<header className="bg-white border-b border-slate-200 px-6 py-2.5 flex items-center gap-3 sticky top-0 z-30">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13.5px] font-bold text-forest leading-tight">Portfolio overview</div>
|
||||
<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>
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
<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]">
|
||||
<Search size={13} />
|
||||
Search enterprise or ID…
|
||||
</span>
|
||||
|
||||
<NavLink
|
||||
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`}
|
||||
>
|
||||
<span className="text-[14px]">🔔</span>
|
||||
{openAlerts > 0 && <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-risk-red" />}
|
||||
<Bell size={15} />
|
||||
{openAlerts > 0 && (
|
||||
<span className="absolute top-1 right-1 w-[6px] h-[6px] rounded-full bg-risk-red" />
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<div className="flex items-center gap-2 pl-2 border-l border-slate-200">
|
||||
<span className="w-7 h-7 rounded-full bg-forest text-white grid place-items-center text-[10.5px] font-bold">
|
||||
{initials}
|
||||
</span>
|
||||
<div className="leading-tight">
|
||||
<div className="text-[11.5px] font-semibold text-slate-700">{session?.fullName ?? 'Field Officer'}</div>
|
||||
<div className="text-[9.5px] text-slate-400">Field Officer</div>
|
||||
</div>
|
||||
<button onClick={signOut} className="text-[10.5px] text-slate-400 hover:text-risk-red ml-1">
|
||||
Sign out
|
||||
</button>
|
||||
<UserMenu
|
||||
name={name}
|
||||
role={(session?.role ?? 'FIELD_OFFICER').replace(/_/g, ' ').toLowerCase().replace(/^./, (c) => c.toUpperCase())}
|
||||
initials={initials}
|
||||
open={openMenu === '__user'}
|
||||
onToggle={() => toggle('__user')}
|
||||
onClose={close}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-6 py-4 max-w-[1560px] w-full">
|
||||
<Outlet />
|
||||
</main>
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
{/* weekly visit progress, which used to live in the sidebar */}
|
||||
<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">
|
||||
UdyamLinc · Predicting stress. Protecting enterprises. — Team LincFusion, LincPay Solutions Pvt. Ltd.
|
||||
</footer>
|
||||
</div>
|
||||
<FreshnessChip />
|
||||
|
||||
<MenuSelect
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { get } from '../../lib/api'
|
||||
import type { FeatureValueRow, RuleOutcome, RuleStatus, TripWireRule } from '../../lib/api'
|
||||
import { Card, Empty } from '../../components/ui/bits'
|
||||
import { DEMO_FEATURES, DEMO_RULES, demoFeatureValues, demoRuleOutcomes } from '../../lib/demo/feeds'
|
||||
import { scopedEnterprises } from '../../lib/demo/ops'
|
||||
import { useTenantEnterprises } from './useTenantData'
|
||||
import { useScope } from './officerScope'
|
||||
|
||||
/**
|
||||
@@ -19,7 +19,9 @@ import { useScope } from './officerScope'
|
||||
*/
|
||||
export function RulesPage() {
|
||||
const { block } = useScope()
|
||||
const enterprises = scopedEnterprises(block)
|
||||
// The picker lists borrowers, so it comes from the scoped API like every
|
||||
// other borrower list in the console.
|
||||
const { rows: enterprises } = useTenantEnterprises(block)
|
||||
const [enterpriseId, setEnterpriseId] = useState(enterprises[0]?.enterpriseId ?? 'MSME-4417')
|
||||
|
||||
const rulesQ = useQuery({ queryKey: ['rules'], queryFn: () => get<TripWireRule[]>('/admin/rules') })
|
||||
|
||||
@@ -93,7 +93,7 @@ export function SettingsPage() {
|
||||
type="checkbox"
|
||||
checked={packs[s]}
|
||||
onChange={(e) => setPacks({ ...packs, [s]: e.target.checked })}
|
||||
className="accent-[#0b5d3b]"
|
||||
className="accent-[#2a71b8]"
|
||||
/>
|
||||
<span className="text-[12px] text-slate-700">{SECTORS[s].label}</span>
|
||||
<span className="text-[11px] text-slate-400 ml-auto">{SECTORS[s].labelHi}</span>
|
||||
|
||||
+379
@@ -204,6 +204,11 @@ export interface Enterprise {
|
||||
band: Band
|
||||
trend: number[] // 6-month score history, oldest first
|
||||
lastUpdated: string
|
||||
/** Months until the downside case first misses the instalment; null if never. */
|
||||
firstBreachHorizon?: number | null
|
||||
firstBreachMonth?: string | null
|
||||
topDriver?: string | null
|
||||
topDriverHi?: string | null
|
||||
}
|
||||
|
||||
/** One month of the quantile forecast. Amounts are net cash position in ₹. */
|
||||
@@ -815,3 +820,377 @@ export interface BranchCell {
|
||||
stressRatePct?: number
|
||||
avgScore?: number
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Enterprise detail — the live shape of GET /officer/enterprises/{id} */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The stress score as the backend stores it: five flat sub-score columns rather
|
||||
* than a nested object, plus the two fields that say where the number came from.
|
||||
*
|
||||
* <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
@@ -41,22 +41,13 @@ export const CANONICAL: CanonicalColumns = {
|
||||
],
|
||||
}
|
||||
|
||||
const FINACLE: Record<string, string> = {
|
||||
enterprise_ref: 'ENTITY_REF',
|
||||
loan_account_ref: 'CUST_ACCT_ID',
|
||||
product: 'SCHM_CODE',
|
||||
sanctioned_amount: 'SANCT_AMT',
|
||||
outstanding: 'PRIN_OS',
|
||||
emi_amount: 'INSTL_AMT',
|
||||
due_date: 'NEXT_DUE_DT',
|
||||
last_paid_date: 'LAST_PAID_DT',
|
||||
dpd: 'DPD_DAYS',
|
||||
bucket: 'ASSET_CLASS',
|
||||
closed: 'ACCT_CLS_FLG',
|
||||
as_of_date: 'EXTRACT_DT',
|
||||
}
|
||||
|
||||
const CUSTOM: Record<string, string> = {
|
||||
/**
|
||||
* CBSNexora's field names. LincPay's own core banking platform, and the only
|
||||
* core named anywhere in this system — where a lender runs someone else's, the
|
||||
* per-profile mapping absorbs their column names without the platform ever
|
||||
* needing the product's name.
|
||||
*/
|
||||
const CBSNEXORA: Record<string, string> = {
|
||||
enterprise_ref: 'borrowerRef',
|
||||
loan_account_ref: 'loanId',
|
||||
product: 'productCode',
|
||||
@@ -86,52 +77,14 @@ export function demoCatalog(): ConnectorCatalogEntry[] {
|
||||
suggestedMapping: IDENTITY,
|
||||
},
|
||||
{
|
||||
vendor: 'FINACLE',
|
||||
displayName: 'Infosys Finacle (simulated)',
|
||||
transport: 'FILE',
|
||||
simulated: true,
|
||||
nativeColumns: [...Object.values(FINACLE), 'CUST_NAME', 'MOBILE_NO', 'BRANCH_CODE', 'CCY'],
|
||||
activationNote:
|
||||
'Finacle exports through a scheduled CRM/Reports extract. A bank enables it, agrees the column list, and the file lands on the drop path.',
|
||||
suggestedMapping: FINACLE,
|
||||
},
|
||||
{
|
||||
vendor: 'FLEXCUBE',
|
||||
displayName: 'Oracle FLEXCUBE (simulated)',
|
||||
transport: 'JDBC',
|
||||
simulated: true,
|
||||
nativeColumns: [
|
||||
'CUSTOMER_REF', 'ACCOUNT_NUMBER', 'PRODUCT_CODE', 'AMOUNT_FINANCED', 'PRINCIPAL_OUTSTANDING',
|
||||
'INSTALMENT_AMOUNT', 'NEXT_DUE_DATE', 'LAST_PAYMENT_DATE', 'OVERDUE_DAYS', 'NPA_STAGE',
|
||||
'ACCOUNT_STATUS', 'VALUE_DATE', 'CUSTOMER_NAME', 'CONTACT_MOBILE', 'BRANCH_CODE',
|
||||
],
|
||||
activationNote:
|
||||
'Read through a reporting schema or extract view rather than the transaction tables. Needs a read-only account scoped to the loan module.',
|
||||
suggestedMapping: {},
|
||||
},
|
||||
{
|
||||
vendor: 'BANCS',
|
||||
displayName: 'TCS BaNCS (simulated)',
|
||||
transport: 'FILE',
|
||||
simulated: true,
|
||||
nativeColumns: [
|
||||
'PARTY_REF', 'ARRANGEMENT_ID', 'PRODUCT_GROUP', 'LIMIT_AMT', 'OUTSTANDING_BAL', 'EMI',
|
||||
'DUE_DATE', 'PAID_DATE', 'DAYS_OVERDUE', 'CLASSIFICATION', 'STATUS', 'POSITION_DATE',
|
||||
'PARTY_NAME', 'MOBILE', 'BRANCH',
|
||||
],
|
||||
activationNote:
|
||||
'Cooperative-bank hosted here. A nightly position file is the realistic ask; a live API rarely is in year one.',
|
||||
suggestedMapping: {},
|
||||
},
|
||||
{
|
||||
vendor: 'CUSTOM_API',
|
||||
displayName: 'In-house LMS over REST (simulated)',
|
||||
vendor: 'CBSNEXORA',
|
||||
displayName: 'CBSNexora (simulated)',
|
||||
transport: 'REST',
|
||||
simulated: true,
|
||||
nativeColumns: [...Object.values(CUSTOM), 'borrowerName', 'borrowerMobile', 'branchCode'],
|
||||
nativeColumns: [...Object.values(CBSNEXORA), 'borrowerName', 'borrowerMobile', 'branchCode'],
|
||||
activationNote:
|
||||
'For a lender whose loan management system is their own. One paged read-only endpoint; the mapping absorbs whatever they call the fields.',
|
||||
suggestedMapping: CUSTOM,
|
||||
'CBSNexora exposes one paged, read-only loan endpoint. A lender enables it for the platform’s service account and agrees the field list; the mapping absorbs whatever their deployment calls them.',
|
||||
suggestedMapping: CBSNEXORA,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -139,21 +92,21 @@ export function demoCatalog(): ConnectorCatalogEntry[] {
|
||||
export function demoProfiles(): ConnectorProfileRow[] {
|
||||
return [
|
||||
{
|
||||
code: 'MPGB-FINACLE',
|
||||
displayName: 'MP Gramin Bank — Finacle nightly extract',
|
||||
vendor: 'FINACLE',
|
||||
vendorName: 'Infosys Finacle (simulated)',
|
||||
transport: 'FILE',
|
||||
code: 'MPGB-CBSNEXORA',
|
||||
displayName: 'MP Gramin Bank — CBSNexora loan API',
|
||||
vendor: 'CBSNEXORA',
|
||||
vendorName: 'CBSNexora (simulated)',
|
||||
transport: 'REST',
|
||||
simulated: true,
|
||||
activationNote: demoCatalog()[1].activationNote,
|
||||
institutionCode: 'MPGB',
|
||||
endpoint: 'loanbook-*.csv',
|
||||
endpoint: 'https://cbsnexora.mpgb.example/api/loans',
|
||||
scheduleCron: '0 30 1 * * *',
|
||||
autoCommit: false,
|
||||
active: true,
|
||||
lastSyncAt: '2026-08-07T01:30:00',
|
||||
lastStatus: 'PARTIAL',
|
||||
mapping: FINACLE,
|
||||
mapping: CBSNEXORA,
|
||||
sourceColumns: demoCatalog()[1].nativeColumns,
|
||||
},
|
||||
{
|
||||
@@ -175,22 +128,22 @@ export function demoProfiles(): ConnectorProfileRow[] {
|
||||
sourceColumns: [],
|
||||
},
|
||||
{
|
||||
code: 'VSFB-LMS',
|
||||
displayName: 'Vindhya SFB — in-house LMS over REST',
|
||||
vendor: 'CUSTOM_API',
|
||||
vendorName: 'In-house LMS over REST (simulated)',
|
||||
code: 'VSFB-CBSNEXORA',
|
||||
displayName: 'Vindhya SFB — CBSNexora loan API',
|
||||
vendor: 'CBSNEXORA',
|
||||
vendorName: 'CBSNexora (simulated)',
|
||||
transport: 'REST',
|
||||
simulated: true,
|
||||
activationNote: demoCatalog()[4].activationNote,
|
||||
activationNote: demoCatalog()[1].activationNote,
|
||||
institutionCode: 'VSFB',
|
||||
endpoint: 'https://lms.vindhyasfb.example/api/loans',
|
||||
endpoint: 'https://cbsnexora.vindhyasfb.example/api/loans',
|
||||
scheduleCron: '0 15 1 * * *',
|
||||
autoCommit: false,
|
||||
active: true,
|
||||
lastSyncAt: null,
|
||||
lastStatus: 'NEVER_RUN',
|
||||
mapping: CUSTOM,
|
||||
sourceColumns: demoCatalog()[4].nativeColumns,
|
||||
mapping: CBSNEXORA,
|
||||
sourceColumns: demoCatalog()[1].nativeColumns,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -244,8 +197,8 @@ export function demoRuns(): ConnectorSyncRun[] {
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
profileCode: 'MPGB-FINACLE',
|
||||
vendor: 'FINACLE',
|
||||
profileCode: 'MPGB-CBSNEXORA',
|
||||
vendor: 'CBSNEXORA',
|
||||
institutionCode: 'MPGB',
|
||||
trigger: 'SCHEDULED',
|
||||
triggeredBy: 'scheduler',
|
||||
@@ -266,8 +219,8 @@ export function demoRuns(): ConnectorSyncRun[] {
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
profileCode: 'MPGB-FINACLE',
|
||||
vendor: 'FINACLE',
|
||||
profileCode: 'MPGB-CBSNEXORA',
|
||||
vendor: 'CBSNEXORA',
|
||||
institutionCode: 'MPGB',
|
||||
trigger: 'MANUAL',
|
||||
triggeredBy: 'supervisor',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
Reference in New Issue
Block a user