feat(integration): core banking console at /officer/integration

Three tabs. Connections lists each bank's configured link with its last
status, and lets an operations user test or run a sync. Field mapping is
the screen that carries the argument: one row per loan-book field, one
column per lender's name for it, which is what "every bank's core is
different" actually reduces to. Sync history reads as an audit — a
failed run is a row, because a feed that stopped three weeks ago must be
visible as something other than silence.

Simulated adapters are badged everywhere they appear, including in the
run history, because the failure mode that matters here is a demo
mistaken for a live feed. Unmapped vendor columns are shown being
dropped at the boundary, with identifying ones marked never stored.

The client-side identifying-column check mirrors the backend's, matching
on the whole name and on each part with separators and camelCase split
alike — so CUST_NAME and borrowerMobile are refused next to the field
rather than after a round trip.

Follows the house pattern: useQuery against the real endpoint, in-file
demo fallback, "Demo data" pill when the backend is unreachable. The
demo fixtures deliberately include one connection that has never run and
one that last failed. Build and oxlint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-07 18:20:15 +05:30
co-authored by Claude Opus 5
parent 8939b1cab9
commit 0a76a53ceb
6 changed files with 1133 additions and 0 deletions
+1
View File
@@ -48,6 +48,7 @@ Demo fallbacks deliberately include failure states: the data-entry fallback cont
- **`/officer/rules`** — every trip-wire with its outcome, including the ones that were *skipped* and why. This is the screen that shows the system refusing to fire on stale data. - **`/officer/rules`** — every trip-wire with its outcome, including the ones that were *skipped* and why. This is the screen that shows the system refusing to fire on stale data.
- **`/officer/feeds`** — provenance. Mandi rows carry a source: `Agmarknet`, or an amber **Hand** chip for a price somebody typed. By the time a price reaches a feature the two are indistinguishable, so the distinction has to be visible here. - **`/officer/feeds`** — provenance. Mandi rows carry a source: `Agmarknet`, or an amber **Hand** chip for a price somebody typed. By the time a price reaches a feature the two are indistinguishable, so the distinction has to be visible here.
- **`/officer/data-entry`** — upload, preview, commit. Accepted, rejected and overwriting rows stay in three separate groups. - **`/officer/data-entry`** — upload, preview, commit. Accepted, rejected and overwriting rows stay in three separate groups.
- **`/officer/integration`** — core banking / LMS. The mapping table is the argument: each lender's core names the same eleven fields differently, and a per-bank mapping is what absorbs that. Simulated adapters are badged as simulated on every screen they appear on, and unmapped vendor columns are shown being dropped at the boundary.
- **`/nabard`** — the India choropleth and the zone → state → district drill-down. States with no portfolio are drawn in a neutral grey that is nowhere on the stress ramp, so absence is never read as health. - **`/nabard`** — the India choropleth and the zone → state → district drill-down. States with no portfolio are drawn in a neutral grey that is nowhere on the stress ramp, so absence is never read as health.
--- ---
+2
View File
@@ -20,6 +20,7 @@ import { VisitsPage } from './features/officer/VisitsPage'
import { RulesPage } from './features/officer/RulesPage' import { RulesPage } from './features/officer/RulesPage'
import { DataFeedsPage } from './features/officer/DataFeedsPage' import { DataFeedsPage } from './features/officer/DataFeedsPage'
import { DataEntryPage } from './features/officer/DataEntryPage' import { DataEntryPage } from './features/officer/DataEntryPage'
import { IntegrationPage } from './features/officer/IntegrationPage'
import { ReportsPage } from './features/officer/ReportsPage' import { ReportsPage } from './features/officer/ReportsPage'
import { SettingsPage } from './features/officer/SettingsPage' import { SettingsPage } from './features/officer/SettingsPage'
@@ -100,6 +101,7 @@ export default function App() {
<Route path="rules" element={<RulesPage />} /> <Route path="rules" element={<RulesPage />} />
<Route path="feeds" element={<DataFeedsPage />} /> <Route path="feeds" element={<DataFeedsPage />} />
<Route path="data-entry" element={<DataEntryPage />} /> <Route path="data-entry" element={<DataEntryPage />} />
<Route path="integration" element={<IntegrationPage />} />
<Route path="reports" element={<ReportsPage />} /> <Route path="reports" element={<ReportsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
</Route> </Route>
+772
View File
@@ -0,0 +1,772 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { errMsg, get, post, put } from '../../lib/api'
import type {
CanonicalColumns,
ConnectorCatalogEntry,
ConnectorProfileRow,
ConnectorSyncResponse,
ConnectorSyncRun,
ConnectorTestResult,
} from '../../lib/api'
import {
Card,
Empty,
Field,
GhostBtn,
KpiTile,
Modal,
PrimaryBtn,
StatusPill,
inputCls,
} from '../../components/ui/bits'
import { CANONICAL, demoCatalog, demoProfiles, demoRuns } from '../../lib/demo/connectors'
/**
* Core banking / LMS integration.
*
* <p><b>What this screen is claiming.</b> Not that UdyamLinc is integrated with
* anyone's core — it is not, and saying so on a slide would be the kind of claim
* that ends a pilot conversation badly. What it shows is the seam: an adapter per
* transport, a mapping per bank, and one validator that a nightly extract and a
* hand-made spreadsheet both go through. Onboarding a lender is then a
* configuration exercise, and this screen is where it happens.
*
* <p>Three things are deliberately hard to miss. Simulated adapters carry a badge
* everywhere they appear, including in the run history, because a demo mistaken
* for a live feed is the failure mode that matters here. Unmapped vendor columns
* are counted and named as <i>dropped at the boundary</i>, which is how the
* no-PII promise is kept structurally rather than by anyone remembering. And
* auto-commit is off, so a sync stages into the same preview an upload does and
* waits for a person.
*/
type Tab = 'connections' | 'mapping' | 'history'
/**
* Whether a column name carries borrower identity — the same test the backend
* applies, mirrored so the reason appears next to the field instead of arriving
* as an error after a round trip.
*
* <p>Matched on the whole name and on each part, splitting separators and
* camelCase alike: a core's real column is `CUST_NAME` and an in-house LMS ships
* `borrowerMobile`, neither of which an exact-match list catches.
*/
function isIdentifying(column: string, neverStored: string[]): boolean {
const name = column ?? ''
if (neverStored.includes(name.replace(/[_\s\-.]/g, '').toLowerCase())) return true
return name
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toLowerCase()
.split(/[_\s\-.]+/)
.some((part) => part && neverStored.includes(part))
}
export function IntegrationPage() {
const queryClient = useQueryClient()
const [tab, setTab] = useState<Tab>('connections')
const [selectedCode, setSelectedCode] = useState<string | null>(null)
const [testResult, setTestResult] = useState<ConnectorTestResult | null>(null)
const [syncResult, setSyncResult] = useState<ConnectorSyncResponse | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const profilesQ = useQuery({
queryKey: ['connector-profiles'],
queryFn: () => get<ConnectorProfileRow[]>('/admin/connectors/profiles'),
})
const catalogQ = useQuery({
queryKey: ['connector-catalog'],
queryFn: () => get<ConnectorCatalogEntry[]>('/admin/connectors/catalog'),
})
const runsQ = useQuery({
queryKey: ['connector-runs'],
queryFn: () => get<ConnectorSyncRun[]>('/admin/connectors/runs', { limit: 30 }),
})
const canonicalQ = useQuery({
queryKey: ['connector-canonical'],
queryFn: () => get<CanonicalColumns>('/admin/connectors/canonical-columns'),
})
const demo = profilesQ.isError || catalogQ.isError
const profiles = profilesQ.data ?? demoProfiles()
const catalog = catalogQ.data ?? demoCatalog()
const runs = runsQ.data ?? demoRuns()
const canonical = canonicalQ.data ?? CANONICAL
const selected = useMemo(
() => profiles.find((p) => p.code === selectedCode) ?? profiles[0] ?? null,
[profiles, selectedCode],
)
const testM = useMutation({
mutationFn: (code: string) => post<ConnectorTestResult>(`/admin/connectors/profiles/${code}/test`),
onSuccess: (data) => {
setTestResult(data)
setError(null)
},
onError: (e) => setError(errMsg(e)),
})
const syncM = useMutation({
mutationFn: (code: string) => post<ConnectorSyncResponse>(`/admin/connectors/profiles/${code}/sync`),
onSuccess: async (data) => {
setSyncResult(data)
setError(null)
setNotice(data.run.message)
await queryClient.invalidateQueries({ queryKey: ['connector-runs'] })
await queryClient.invalidateQueries({ queryKey: ['connector-profiles'] })
},
onError: (e) => setError(errMsg(e)),
})
const saveM = useMutation({
mutationFn: (vars: { code: string; body: Record<string, unknown> }) =>
put<ConnectorProfileRow>(`/admin/connectors/profiles/${vars.code}`, vars.body),
onSuccess: async () => {
setNotice('Mapping saved')
setError(null)
await queryClient.invalidateQueries({ queryKey: ['connector-profiles'] })
},
onError: (e) => setError(errMsg(e)),
})
const connected = profiles.filter((p) => p.active).length
const live = profiles.filter((p) => p.active && !p.simulated).length
const failing = profiles.filter((p) => p.lastStatus === 'FAILED').length
const neverRun = profiles.filter((p) => p.lastStatus === 'NEVER_RUN').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">Core banking integration</h1>
<p className="text-[11px] text-slate-500 max-w-[860px]">
The loan book is the one input no public feed can supply, and no two lenders expose it the
same way. What is built here is the seam — an adapter per transport, a mapping per bank —
and every route lands in the same validation an uploaded spreadsheet goes through.
</p>
</div>
{demo && <StatusPill status="Demo data" />}
</div>
<div className="flex flex-wrap gap-3">
<KpiTile label="Configured connections" value={connected} />
<KpiTile label="Reading a real source" value={live} delta={`${connected - live} simulated`} tone="watch" />
<KpiTile label="Failing" value={failing} tone={failing ? 'red' : 'neutral'} />
<KpiTile label="Never run" value={neverRun} />
</div>
{/* The honest sentence, stated by the product rather than left to a slide. */}
<div className="rounded-[11px] border border-amber/40 bg-amber-soft/40 px-3.5 py-2.5">
<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.
</p>
</div>
<div className="flex gap-1 border-b border-slate-200">
{(
[
['connections', 'Connections'],
['mapping', 'Field mapping'],
['history', 'Sync history'],
] as [Tab, string][]
).map(([key, label]) => (
<button
key={key}
onClick={() => setTab(key)}
className={[
'px-3.5 py-2 text-[11.5px] font-semibold border-b-2 -mb-px',
tab === key ? 'text-forest border-amber' : 'text-slate-500 border-transparent hover:text-forest',
].join(' ')}
>
{label}
</button>
))}
</div>
{notice && !error && <p className="text-[11px] font-semibold text-forest">{notice}</p>}
{error && <p className="text-[11px] font-semibold text-rose-600">{error}</p>}
{tab === 'connections' && (
<ConnectionsTab
profiles={profiles}
catalog={catalog}
busyCode={testM.isPending ? testM.variables : syncM.isPending ? syncM.variables : null}
onTest={(code) => {
setSelectedCode(code)
setSyncResult(null)
testM.mutate(code)
}}
onSync={(code) => {
setSelectedCode(code)
setTestResult(null)
syncM.mutate(code)
}}
onConfigure={(code) => {
setSelectedCode(code)
setTab('mapping')
}}
/>
)}
{tab === 'mapping' && (
<MappingTab
profiles={profiles}
selected={selected}
canonical={canonical}
onSelect={setSelectedCode}
saving={saveM.isPending}
onSave={(mapping) => selected && saveM.mutate({ code: selected.code, body: { mapping } })}
/>
)}
{tab === 'history' && <HistoryTab runs={runs} />}
{testResult && <TestModal result={testResult} onClose={() => setTestResult(null)} />}
{syncResult && <SyncModal result={syncResult} onClose={() => setSyncResult(null)} />}
</div>
)
}
// ── connections ───────────────────────────────────────────────────
function ConnectionsTab({
profiles,
catalog,
busyCode,
onTest,
onSync,
onConfigure,
}: {
profiles: ConnectorProfileRow[]
catalog: ConnectorCatalogEntry[]
busyCode: string | null | undefined
onTest: (code: string) => void
onSync: (code: string) => void
onConfigure: (code: string) => void
}) {
return (
<div className="space-y-4">
<Card title="Configured connections" badge={`${profiles.length}`}>
{profiles.length === 0 ? (
<Empty text="No bank is connected yet." />
) : (
<div className="divide-y divide-slate-100">
{profiles.map((p) => (
<div key={p.code} className="px-3.5 py-3 flex flex-wrap items-start gap-3">
<div className="min-w-[260px] flex-1">
<div className="flex items-center gap-2">
<span className="text-[12.5px] font-bold text-forest">{p.displayName}</span>
<SimulatedBadge simulated={p.simulated} />
</div>
<div className="text-[10.5px] text-slate-500 font-mono mt-0.5">
{p.code} · {p.vendorName} · {p.transport}
</div>
<div className="text-[10.5px] text-slate-500 mt-0.5 break-all">
{p.endpoint ?? 'no endpoint set'}
</div>
{p.activationNote && p.simulated && (
<div className="text-[10.5px] text-slate-400 mt-1 max-w-[560px]">
<b className="text-slate-500">To make live:</b> {p.activationNote}
</div>
)}
</div>
<div className="min-w-[150px]">
<LastStatus profile={p} />
<div className="text-[10px] text-slate-400 font-mono mt-1">
{p.scheduleCron ? `cron ${p.scheduleCron}` : 'not scheduled'}
</div>
<div className="text-[10px] text-slate-400 mt-0.5">
{p.autoCommit ? 'writes automatically' : 'stages for review'}
</div>
</div>
<div className="flex items-center gap-2 ml-auto">
<GhostBtn onClick={() => onConfigure(p.code)}>Mapping</GhostBtn>
<GhostBtn onClick={() => onTest(p.code)} disabled={busyCode === p.code}>
Test
</GhostBtn>
<PrimaryBtn onClick={() => onSync(p.code)} disabled={busyCode === p.code}>
{busyCode === p.code ? 'Working…' : 'Run sync'}
</PrimaryBtn>
</div>
</div>
))}
</div>
)}
</Card>
<Card title="Adapters available" badge={`${catalog.length}`}>
<div className="p-3.5 grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
{catalog.map((c) => (
<div key={c.vendor} className="border border-slate-200 rounded-[9px] p-3">
<div className="flex items-center gap-2">
<span className="text-[12px] font-bold text-forest">{c.displayName}</span>
<SimulatedBadge simulated={c.simulated} />
</div>
<div className="text-[10px] font-mono text-slate-400 mt-0.5">
{c.vendor} · {c.transport}
</div>
<p className="text-[10.5px] text-slate-500 mt-1.5 leading-relaxed">{c.activationNote}</p>
{c.nativeColumns.length > 0 && (
<p className="text-[10px] text-slate-400 mt-1.5 font-mono break-all">
{c.nativeColumns.slice(0, 6).join(', ')}
{c.nativeColumns.length > 6 && ` +${c.nativeColumns.length - 6} more`}
</p>
)}
</div>
))}
</div>
</Card>
</div>
)
}
function SimulatedBadge({ simulated }: { simulated: boolean }) {
return simulated ? (
<span className="pill pill-pend !text-[9.5px]">SIMULATED</span>
) : (
<span className="pill pill-ok !text-[9.5px]">READS REAL SOURCE</span>
)
}
function LastStatus({ profile }: { profile: ConnectorProfileRow }) {
const status = profile.lastStatus ?? 'NEVER_RUN'
const cls =
status === 'SUCCESS'
? 'pill-ok'
: status === 'FAILED'
? 'pill-bad'
: status === 'PARTIAL'
? 'pill-pend'
: 'pill-mut'
return (
<div>
<span className={`pill ${cls} !text-[9.5px]`}>{status.replace(/_/g, ' ')}</span>
<div className="text-[10px] text-slate-400 mt-1">
{profile.lastSyncAt ? `last ${profile.lastSyncAt.replace('T', ' ').slice(0, 16)}` : 'never run'}
</div>
</div>
)
}
// ── field mapping ─────────────────────────────────────────────────
/**
* The screen that absorbs "every bank is different".
*
* <p>Editing is local until Save, and Save is refused by the backend if a
* required column is unmapped or an identifying one is mapped in — checked here
* too, so the reason appears next to the field rather than as a toast after a
* round trip.
*/
function MappingTab({
profiles,
selected,
canonical,
onSelect,
saving,
onSave,
}: {
profiles: ConnectorProfileRow[]
selected: ConnectorProfileRow | null
canonical: CanonicalColumns
onSelect: (code: string) => void
saving: boolean
onSave: (mapping: Record<string, string>) => void
}) {
const [draft, setDraft] = useState<Record<string, string> | null>(null)
const [draftFor, setDraftFor] = useState<string | null>(null)
if (!selected) return <Empty text="No connection to configure." />
// Reset the draft when the selected profile changes, without an effect.
if (draftFor !== selected.code) {
setDraftFor(selected.code)
setDraft({ ...selected.mapping })
return null
}
const mapping = draft ?? selected.mapping
const missing = canonical.required.filter((c) => !mapping[c] || !mapping[c].trim())
const identifying = Object.values(mapping).filter((v) => v && isIdentifying(v, canonical.neverStored))
const mappedSources = new Set(
Object.values(mapping)
.filter(Boolean)
.map((v) => v.replace(/[_\s-]/g, '').toLowerCase()),
)
const dropped = selected.sourceColumns.filter(
(c) => !mappedSources.has(c.replace(/[_\s-]/g, '').toLowerCase()),
)
return (
<div className="space-y-4">
<Card title="Field mapping">
<div className="p-3.5 space-y-3">
<div className="flex flex-wrap items-end gap-3">
<Field label="Connection" className="min-w-[280px]">
<select
className={inputCls}
value={selected.code}
onChange={(e) => onSelect(e.target.value)}
>
{profiles.map((p) => (
<option key={p.code} value={p.code}>
{p.displayName}
</option>
))}
</select>
</Field>
<div className="ml-auto flex items-center gap-2">
<GhostBtn onClick={() => setDraft({ ...selected.mapping })} disabled={saving}>
Reset
</GhostBtn>
<PrimaryBtn
onClick={() => onSave(mapping)}
disabled={saving || missing.length > 0 || identifying.length > 0}
>
{saving ? 'Saving…' : 'Save mapping'}
</PrimaryBtn>
</div>
</div>
<p className="text-[11px] text-slate-500 leading-relaxed">
Each row says which of <b>{selected.vendorName}</b>'s columns carries one field of the
loan-book template. A column left blank is simply absent from that bank's export. Anything
the bank sends that no row points at is <b>dropped at the boundary</b> and never stored —
which is how borrower names and mobile numbers stay out of a system that promised not to
hold them.
</p>
{missing.length > 0 && (
<p className="text-[11px] font-semibold text-rose-600">
{missing.join(', ')} must be mapped — without them no row can be validated, and a nightly
run would reject the whole file.
</p>
)}
{identifying.length > 0 && (
<p className="text-[11px] font-semibold text-rose-600">
{identifying.join(', ')} carries borrower identity and cannot be mapped in.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-[11.5px]">
<thead>
<tr className="text-left text-slate-500 border-b border-slate-200">
<th className="py-1.5 pr-3 font-semibold">Loan-book field</th>
<th className="py-1.5 pr-3 font-semibold">{selected.vendorName} column</th>
<th className="py-1.5 font-semibold">Required</th>
</tr>
</thead>
<tbody>
{canonical.columns.map((column) => {
const required = canonical.required.includes(column)
const value = mapping[column] ?? ''
const bad = required && !value.trim()
return (
<tr key={column} className="border-b border-slate-50">
<td className="py-1.5 pr-3 font-mono text-slate-700">{column}</td>
<td className="py-1.5 pr-3">
{selected.sourceColumns.length > 0 ? (
<select
className={`${inputCls} ${bad ? '!border-rose-400' : ''}`}
value={value}
onChange={(e) => setDraft({ ...mapping, [column]: e.target.value })}
>
<option value="">— not in this export —</option>
{selected.sourceColumns.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
) : (
<input
className={`${inputCls} ${bad ? '!border-rose-400' : ''}`}
value={value}
placeholder="column name in the bank's file"
onChange={(e) => setDraft({ ...mapping, [column]: e.target.value })}
/>
)}
</td>
<td className="py-1.5 text-[10.5px] text-slate-400">
{required ? <span className="text-rose-600 font-semibold">required</span> : 'optional'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</Card>
{selected.sourceColumns.length > 0 && (
<Card title="Dropped at the boundary" badge={`${dropped.length}`}>
<div className="p-3.5">
{dropped.length === 0 ? (
<p className="text-[11px] text-slate-500">
Every column this export carries is mapped. Nothing is being discarded.
</p>
) : (
<>
<p className="text-[11px] text-slate-500 mb-2 leading-relaxed">
{selected.vendorName} sends these and nothing points at them, so they are discarded
before validation and never reach storage. A real bank export carries more of these
than it does useful columns.
</p>
<div className="flex flex-wrap gap-1.5">
{dropped.map((c) => {
const identity = isIdentifying(c, canonical.neverStored)
return (
<span
key={c}
className={[
'font-mono text-[10.5px] px-2 py-0.5 rounded-md border',
identity
? 'border-rose-200 bg-rose-50 text-rose-700'
: 'border-slate-200 bg-slate-50 text-slate-500',
].join(' ')}
>
{c}
{identity && ' · never stored'}
</span>
)
})}
</div>
</>
)}
</div>
</Card>
)}
</div>
)
}
// ── history ───────────────────────────────────────────────────────
function HistoryTab({ runs }: { runs: ConnectorSyncRun[] }) {
return (
<Card title="Sync history" badge={`${runs.length}`}>
{runs.length === 0 ? (
<Empty text="No sync has run yet." />
) : (
<div className="overflow-x-auto">
<table className="w-full text-[11.5px]">
<thead>
<tr className="text-left text-slate-500 border-b border-slate-200 bg-slate-50/60">
<th className="py-2 px-3 font-semibold">Started</th>
<th className="py-2 px-3 font-semibold">Connection</th>
<th className="py-2 px-3 font-semibold">As at</th>
<th className="py-2 px-3 font-semibold text-right">Fetched</th>
<th className="py-2 px-3 font-semibold text-right">Accepted</th>
<th className="py-2 px-3 font-semibold text-right">Rejected</th>
<th className="py-2 px-3 font-semibold text-right">Dropped cols</th>
<th className="py-2 px-3 font-semibold">Status</th>
<th className="py-2 px-3 font-semibold">What happened</th>
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr key={r.id} className="border-b border-slate-50 align-top">
<td className="py-2 px-3 font-mono text-slate-500 whitespace-nowrap">
{r.startedAt.replace('T', ' ').slice(0, 16)}
</td>
<td className="py-2 px-3">
<div className="font-semibold text-slate-700">{r.profileCode}</div>
<div className="text-[10px] text-slate-400">
{r.trigger.toLowerCase()} · {r.triggeredBy}
</div>
</td>
<td className="py-2 px-3 font-mono text-slate-500">{r.asOfDate ?? '—'}</td>
<td className="py-2 px-3 text-right tabular-nums">{r.rowsFetched ?? '—'}</td>
<td className="py-2 px-3 text-right tabular-nums text-emerald-700">
{r.rowsAccepted ?? '—'}
</td>
<td className="py-2 px-3 text-right tabular-nums text-rose-600">
{r.rowsRejected ?? '—'}
</td>
<td className="py-2 px-3 text-right tabular-nums text-slate-500">
{r.columnsDropped ?? '—'}
</td>
<td className="py-2 px-3 whitespace-nowrap">
<span
className={`pill !text-[9.5px] ${
r.status === 'SUCCESS' ? 'pill-ok' : r.status === 'FAILED' ? 'pill-bad' : 'pill-pend'
}`}
>
{r.status}
</span>
{r.simulated && <span className="pill pill-mut !text-[9px] ml-1">SIM</span>}
{!r.committed && r.batchId != null && (
<div className="text-[10px] text-amber-600 mt-1">staged, not written</div>
)}
</td>
<td className="py-2 px-3 text-slate-500 max-w-[340px]">{r.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)
}
// ── modals ────────────────────────────────────────────────────────
function TestModal({ result, onClose }: { result: ConnectorTestResult; onClose: () => void }) {
return (
<Modal title={`Connection test — ${result.profileCode}`} onClose={onClose} wide>
<div className="space-y-3 text-[11.5px]">
<div className="flex items-center gap-2">
<span className={`pill ${result.reachable ? 'pill-ok' : 'pill-bad'}`}>
{result.reachable ? 'Reached the source' : 'Could not reach the source'}
</span>
<SimulatedBadge simulated={result.simulated} />
<span className="text-slate-400 font-mono text-[10px]">
{result.vendor} · {result.transport}
</span>
</div>
{!result.reachable && <p className="text-rose-600 font-semibold">{result.message}</p>}
{result.reachable && (
<>
<p className="text-slate-600">
{result.label} — {result.rowsAvailable} rows available. Nothing was staged or written.
</p>
{result.missingRequired && result.missingRequired.length > 0 && (
<p className="text-rose-600 font-semibold">
Mapping incomplete: {result.missingRequired.join(', ')}. Every row would be rejected.
</p>
)}
<div className="grid gap-3 md:grid-cols-2">
<div>
<div className="font-mono text-[9.5px] tracking-[0.1em] uppercase text-slate-500 mb-1">
As the bank sends it
</div>
<RowTable row={result.sampleRow ?? {}} />
</div>
<div>
<div className="font-mono text-[9.5px] tracking-[0.1em] uppercase text-slate-500 mb-1">
After mapping
</div>
<RowTable row={result.mappedSampleRow ?? {}} />
</div>
</div>
{result.droppedColumns && result.droppedColumns.length > 0 && (
<p className="text-slate-500">
<b>{result.droppedColumns.length} columns dropped at the boundary:</b>{' '}
<span className="font-mono text-[10.5px]">{result.droppedColumns.join(', ')}</span>
</p>
)}
</>
)}
</div>
</Modal>
)
}
function RowTable({ row }: { row: Record<string, string> }) {
const entries = Object.entries(row)
if (entries.length === 0) return <p className="text-slate-400">Nothing returned.</p>
return (
<div className="border border-slate-200 rounded-md overflow-hidden">
{entries.map(([k, v]) => (
<div key={k} className="flex gap-2 px-2 py-1 border-b border-slate-100 last:border-0">
<span className="font-mono text-[10.5px] text-slate-500 min-w-[136px]">{k}</span>
<span className="text-[11px] text-slate-700 break-all">{v ?? '—'}</span>
</div>
))}
</div>
)
}
function SyncModal({ result, onClose }: { result: ConnectorSyncResponse; onClose: () => void }) {
const run = result.run
return (
<Modal title={`Sync — ${run.profileCode}`} onClose={onClose} wide>
<div className="space-y-3 text-[11.5px]">
<div className="flex items-center gap-2">
<span
className={`pill ${
run.status === 'SUCCESS' ? 'pill-ok' : run.status === 'FAILED' ? 'pill-bad' : 'pill-pend'
}`}
>
{run.status}
</span>
<SimulatedBadge simulated={run.simulated} />
<span className="text-slate-400 font-mono text-[10px]">
{run.durationMs}ms · as at {run.asOfDate}
</span>
</div>
<p className="text-slate-600">{run.message}</p>
<div className="flex flex-wrap gap-4">
<Count label="Fetched" value={run.rowsFetched} tone="text-slate-700" />
<Count label="Accepted" value={run.rowsAccepted} tone="text-emerald-700" />
<Count label="Rejected" value={run.rowsRejected} tone="text-rose-600" />
<Count label="Would overwrite" value={run.rowsOverwriting} tone="text-amber-600" />
<Count label="Columns dropped" value={run.columnsDropped} tone="text-slate-500" />
</div>
{run.batchId != null && !run.committed && (
<p className="text-[11px] text-slate-600 leading-relaxed">
The rows are <b>staged, not written</b>. This connection has auto-commit off, so the batch
is waiting in the same preview an uploaded file produces — open{' '}
<b>Data entry → batch {run.batchId}</b> to see every accepted and rejected row before
committing it.
</p>
)}
{result.droppedColumns.length > 0 && (
<p className="text-slate-500">
<b>Dropped at the boundary:</b>{' '}
<span className="font-mono text-[10.5px]">{result.droppedColumns.join(', ')}</span>
</p>
)}
{result.preview && result.preview.rejected.length > 0 && (
<div>
<div className="font-mono text-[9.5px] tracking-[0.1em] uppercase text-slate-500 mb-1">
Rejected rows
</div>
<div className="border border-slate-200 rounded-md overflow-hidden max-h-[220px] overflow-y-auto">
{result.preview.rejected.slice(0, 40).map((line) => (
<div key={line.id} className="px-2 py-1 border-b border-slate-100 last:border-0">
<span className="font-mono text-[10.5px] text-slate-400 mr-2">row {line.rowNumber}</span>
<span className="font-semibold text-rose-600 text-[10.5px] mr-2">
{line.rejectReason}
</span>
<span className="text-[11px] text-slate-600">{line.rejectDetail}</span>
</div>
))}
</div>
</div>
)}
</div>
</Modal>
)
}
function Count({ label, value, tone }: { label: string; value: number | null; tone: string }) {
return (
<div>
<div className={`text-[19px] font-bold leading-none ${tone}`}>{value ?? '—'}</div>
<div className="text-[10px] text-slate-500 mt-0.5">{label}</div>
</div>
)
}
+1
View File
@@ -26,6 +26,7 @@ const NAV: { label: string; to: string; end?: boolean }[] = [
{ label: 'Trip-wires', to: '/officer/rules' }, { label: 'Trip-wires', to: '/officer/rules' },
{ label: 'Data feeds', to: '/officer/feeds' }, { label: 'Data feeds', to: '/officer/feeds' },
{ label: 'Data entry', to: '/officer/data-entry' }, { label: 'Data entry', to: '/officer/data-entry' },
{ label: 'Core banking', to: '/officer/integration' },
{ label: 'Reports', to: '/officer/reports' }, { label: 'Reports', to: '/officer/reports' },
{ label: 'Settings', to: '/officer/settings' }, { label: 'Settings', to: '/officer/settings' },
] ]
+92
View File
@@ -467,6 +467,98 @@ export interface ManualEntryPreview {
warnings: ManualEntryLine[] warnings: ManualEntryLine[]
} }
// ── Core banking / LMS integration ───────────────────────────────
// The loan book is the training label, and no two lenders expose it the same
// way. What is built is the seam: an adapter per transport, a mapping per bank,
// and one validator — the same one an uploaded spreadsheet goes through.
/** An adapter the platform can run. `simulated` is rendered, never hidden. */
export interface ConnectorCatalogEntry {
vendor: string
displayName: string
transport: 'FILE' | 'REST' | 'JDBC'
simulated: boolean
nativeColumns: string[]
/** What a bank would have to do to make this one live. */
activationNote: string
suggestedMapping: Record<string, string>
}
/** One bank's configured link — which adapter, pointed where, whose column means what. */
export interface ConnectorProfileRow {
code: string
displayName: string
vendor: string
vendorName: string
transport: string | null
simulated: boolean
activationNote: string | null
institutionCode: string | null
endpoint: string | null
scheduleCron: string | null
autoCommit: boolean
active: boolean
lastSyncAt: string | null
lastStatus: 'SUCCESS' | 'PARTIAL' | 'FAILED' | 'NEVER_RUN' | null
/** { canonical_column: vendor_column }. Unmapped means dropped at the boundary. */
mapping: Record<string, string>
sourceColumns: string[]
}
export interface ConnectorSyncRun {
id: number
profileCode: string
vendor: string | null
institutionCode: string | null
trigger: 'MANUAL' | 'SCHEDULED'
triggeredBy: string
status: 'SUCCESS' | 'PARTIAL' | 'FAILED'
/** Stamped per run, not read from the profile — old runs must not turn real. */
simulated: boolean
asOfDate: string | null
rowsFetched: number | null
rowsAccepted: number | null
rowsRejected: number | null
rowsOverwriting: number | null
columnsDropped: number | null
batchId: number | null
committed: boolean
durationMs: number | null
startedAt: string
message: string | null
}
/** What a connection test found, without staging anything. */
export interface ConnectorTestResult {
profileCode: string
vendor: string
simulated: boolean
transport: string
reachable: boolean
label?: string
message?: string
rowsAvailable?: number
sourceColumns: string[]
sampleRow?: Record<string, string>
mappedSampleRow?: Record<string, string>
droppedColumns?: string[]
missingRequired?: string[]
}
export interface ConnectorSyncResponse {
run: ConnectorSyncRun
droppedColumns: string[]
extractLabel: string | null
preview?: ManualEntryPreview
}
/** The template a mapping targets, and the columns that may never be mapped in. */
export interface CanonicalColumns {
columns: string[]
required: string[]
neverStored: string[]
}
/** Multipart, so it bypasses the JSON helpers. */ /** Multipart, so it bypasses the JSON helpers. */
export async function uploadManual(file: File, type: 'MARKET_SIGNAL' | 'LOAN_RECORD') { export async function uploadManual(file: File, type: 'MARKET_SIGNAL' | 'LOAN_RECORD') {
const form = new FormData() const form = new FormData()
+265
View File
@@ -0,0 +1,265 @@
import type {
CanonicalColumns,
ConnectorCatalogEntry,
ConnectorProfileRow,
ConnectorSyncRun,
} from '../api'
/**
* Demo fallback for the integration screen.
*
* <p>Mirrors the backend's own catalogue rather than inventing a tidier one, and
* keeps the same two things visible that the live screen shows: which adapters
* are simulated, and that a real export ships borrower columns which are dropped
* at the boundary rather than stored.
*
* <p>One of the three profiles has never run and one last failed. A demo where
* every connector is green would make the run history look decorative, when
* reading it is the entire reason a field officer can trust a DPD number.
*/
export const CANONICAL: CanonicalColumns = {
columns: [
'enterprise_ref',
'loan_account_ref',
'product',
'sanctioned_amount',
'outstanding',
'emi_amount',
'due_date',
'last_paid_date',
'dpd',
'bucket',
'closed',
'as_of_date',
],
required: ['enterprise_ref', 'loan_account_ref', 'as_of_date'],
neverStored: [
'aadhaar', 'accountnumber', 'address', 'borrowername', 'customername', 'dob',
'email', 'ifsc', 'mobile', 'name', 'pan', 'phone',
],
}
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> = {
enterprise_ref: 'borrowerRef',
loan_account_ref: 'loanId',
product: 'productCode',
sanctioned_amount: 'sanctionedAmount',
outstanding: 'outstandingPrincipal',
emi_amount: 'instalment',
due_date: 'nextDueDate',
last_paid_date: 'lastRepaymentDate',
dpd: 'daysPastDue',
bucket: 'assetClassification',
closed: 'isClosed',
as_of_date: 'asOfDate',
}
const IDENTITY: Record<string, string> = Object.fromEntries(CANONICAL.columns.map((c) => [c, c]))
export function demoCatalog(): ConnectorCatalogEntry[] {
return [
{
vendor: 'FILE_DROP',
displayName: 'Nightly file drop (SFTP / mounted share)',
transport: 'FILE',
simulated: false,
nativeColumns: [],
activationNote:
'Point the profile at the directory the bank’s extract lands in. No core-system change is needed — this reads the file the bank already produces.',
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)',
transport: 'REST',
simulated: true,
nativeColumns: [...Object.values(CUSTOM), '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,
},
]
}
export function demoProfiles(): ConnectorProfileRow[] {
return [
{
code: 'MPGB-FINACLE',
displayName: 'MP Gramin Bank — Finacle nightly extract',
vendor: 'FINACLE',
vendorName: 'Infosys Finacle (simulated)',
transport: 'FILE',
simulated: true,
activationNote: demoCatalog()[1].activationNote,
institutionCode: 'MPGB',
endpoint: 'loanbook-*.csv',
scheduleCron: '0 30 1 * * *',
autoCommit: false,
active: true,
lastSyncAt: '2026-08-07T01:30:00',
lastStatus: 'PARTIAL',
mapping: FINACLE,
sourceColumns: demoCatalog()[1].nativeColumns,
},
{
code: 'SEHDCCB-DROP',
displayName: 'Sehore DCCB — SFTP file drop',
vendor: 'FILE_DROP',
vendorName: 'Nightly file drop (SFTP / mounted share)',
transport: 'FILE',
simulated: false,
activationNote: demoCatalog()[0].activationNote,
institutionCode: 'SEHDCCB',
endpoint: 'sehdccb-loanbook-*.{csv,xlsx}',
scheduleCron: '0 0 2 * * *',
autoCommit: false,
active: true,
lastSyncAt: '2026-08-07T02:00:00',
lastStatus: 'FAILED',
mapping: IDENTITY,
sourceColumns: [],
},
{
code: 'VSFB-LMS',
displayName: 'Vindhya SFB — in-house LMS over REST',
vendor: 'CUSTOM_API',
vendorName: 'In-house LMS over REST (simulated)',
transport: 'REST',
simulated: true,
activationNote: demoCatalog()[4].activationNote,
institutionCode: 'VSFB',
endpoint: 'https://lms.vindhyasfb.example/api/loans',
scheduleCron: '0 15 1 * * *',
autoCommit: false,
active: true,
lastSyncAt: null,
lastStatus: 'NEVER_RUN',
mapping: CUSTOM,
sourceColumns: demoCatalog()[4].nativeColumns,
},
]
}
export function demoRuns(): ConnectorSyncRun[] {
return [
{
id: 14,
profileCode: 'SEHDCCB-DROP',
vendor: 'FILE_DROP',
institutionCode: 'SEHDCCB',
trigger: 'SCHEDULED',
triggeredBy: 'scheduler',
status: 'FAILED',
simulated: false,
asOfDate: '2026-08-06',
rowsFetched: 0,
rowsAccepted: null,
rowsRejected: null,
rowsOverwriting: null,
columnsDropped: null,
batchId: null,
committed: false,
durationMs: 41,
startedAt: '2026-08-07T02:00:00',
message: 'No drop directory at ./data/cbs-drop — the bank has not delivered tonight’s file',
},
{
id: 13,
profileCode: 'MPGB-FINACLE',
vendor: 'FINACLE',
institutionCode: 'MPGB',
trigger: 'SCHEDULED',
triggeredBy: 'scheduler',
status: 'PARTIAL',
simulated: true,
asOfDate: '2026-08-06',
rowsFetched: 61,
rowsAccepted: 58,
rowsRejected: 3,
rowsOverwriting: 12,
columnsDropped: 4,
batchId: 22,
committed: false,
durationMs: 880,
startedAt: '2026-08-07T01:30:00',
message:
'58 accepted · 3 rejected · 4 unmapped columns dropped at the boundary · staged, awaiting commit · SIMULATED extract, not a live core',
},
{
id: 12,
profileCode: 'MPGB-FINACLE',
vendor: 'FINACLE',
institutionCode: 'MPGB',
trigger: 'MANUAL',
triggeredBy: 'supervisor',
status: 'SUCCESS',
simulated: true,
asOfDate: '2026-07-31',
rowsFetched: 61,
rowsAccepted: 61,
rowsRejected: 0,
rowsOverwriting: 0,
columnsDropped: 4,
batchId: 19,
committed: true,
durationMs: 913,
startedAt: '2026-08-06T16:12:00',
message:
'61 accepted · 0 rejected · 4 unmapped columns dropped at the boundary · written · SIMULATED extract, not a live core',
},
]
}