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
+92
View File
@@ -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<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. */
export async function uploadManual(file: File, type: 'MARKET_SIGNAL' | 'LOAN_RECORD') {
const form = new FormData()