diff --git a/README.md b/README.md
index 34a8019..f52854f 100644
--- a/README.md
+++ b/README.md
@@ -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/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/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.
---
diff --git a/src/App.tsx b/src/App.tsx
index 59b3e9e..41f72aa 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -20,6 +20,7 @@ import { VisitsPage } from './features/officer/VisitsPage'
import { RulesPage } from './features/officer/RulesPage'
import { DataFeedsPage } from './features/officer/DataFeedsPage'
import { DataEntryPage } from './features/officer/DataEntryPage'
+import { IntegrationPage } from './features/officer/IntegrationPage'
import { ReportsPage } from './features/officer/ReportsPage'
import { SettingsPage } from './features/officer/SettingsPage'
@@ -100,6 +101,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/features/officer/IntegrationPage.tsx b/src/features/officer/IntegrationPage.tsx
new file mode 100644
index 0000000..6985bd8
--- /dev/null
+++ b/src/features/officer/IntegrationPage.tsx
@@ -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.
+ *
+ *
What this screen is claiming. 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.
+ *
+ *
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 dropped at the boundary, 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.
+ *
+ *
+ 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.
+
+
+ {demo && }
+
+
+
+
+
+
+
+
+
+ {/* The honest sentence, stated by the product rather than left to a slide. */}
+
+
+ What is real and what is not. The file-drop adapter reads an actual extract from a
+ directory a bank delivers to, and its rows are validated, staged and written like any other.
+ The Finacle, FLEXCUBE, BaNCS and in-house-LMS adapters are simulated: they generate an
+ extract in each core's own column dialect so the mapping layer can be exercised end to end.
+ A live link to any of them is agreed per bank during onboarding — the schema conversation is
+ the work, and this screen is what that conversation configures.
+
+ Each row says which of {selected.vendorName}'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 dropped at the boundary and never stored —
+ which is how borrower names and mobile numbers stay out of a system that promised not to
+ hold them.
+
+
+ {missing.length > 0 && (
+
+ {missing.join(', ')} must be mapped — without them no row can be validated, and a nightly
+ run would reject the whole file.
+
+ )}
+ {identifying.length > 0 && (
+
+ {identifying.join(', ')} carries borrower identity and cannot be mapped in.
+
+ Every column this export carries is mapped. Nothing is being discarded.
+
+ ) : (
+ <>
+
+ {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.
+
+
+ {run.status}
+
+
+
+ {run.durationMs}ms · as at {run.asOfDate}
+
+
+
+
{run.message}
+
+
+
+
+
+
+
+
+
+ {run.batchId != null && !run.committed && (
+
+ The rows are staged, not written. This connection has auto-commit off, so the batch
+ is waiting in the same preview an uploaded file produces — open{' '}
+ Data entry → batch {run.batchId} to see every accepted and rejected row before
+ committing it.
+
+ )}
+
+ {result.droppedColumns.length > 0 && (
+
+ Dropped at the boundary:{' '}
+ {result.droppedColumns.join(', ')}
+
+ )
+}
diff --git a/src/features/officer/OfficerShell.tsx b/src/features/officer/OfficerShell.tsx
index a9e63ef..1bfcf64 100644
--- a/src/features/officer/OfficerShell.tsx
+++ b/src/features/officer/OfficerShell.tsx
@@ -26,6 +26,7 @@ const NAV: { label: string; to: string; end?: boolean }[] = [
{ 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' },
]
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 9a0ab2f..38f75e7 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -467,6 +467,98 @@ export interface ManualEntryPreview {
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
+}
+
+/** 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
+ 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
+ mappedSampleRow?: Record
+ 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. */
export async function uploadManual(file: File, type: 'MARKET_SIGNAL' | 'LOAN_RECORD') {
const form = new FormData()
diff --git a/src/lib/demo/connectors.ts b/src/lib/demo/connectors.ts
new file mode 100644
index 0000000..3784c94
--- /dev/null
+++ b/src/lib/demo/connectors.ts
@@ -0,0 +1,265 @@
+import type {
+ CanonicalColumns,
+ ConnectorCatalogEntry,
+ ConnectorProfileRow,
+ ConnectorSyncRun,
+} from '../api'
+
+/**
+ * Demo fallback for the integration screen.
+ *
+ *
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.
+ *
+ *
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 = {
+ 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 = {
+ 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 = 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',
+ },
+ ]
+}