Commit Graph
10 Commits
Author SHA1 Message Date
LincPayandClaude Opus 5 9d307c2d20 fix(reports): show the measured metrics, including the one that beats us
The Reports page carried a BENCHMARKS block of invented figures. It now reads
GET /admin/model/metrics and the block is deleted, with a comment recording
what it held and why it is gone. No fabricated number remains anywhere in the
product.

The comparison chart sorts by value rather than by kind, which puts the
obligation_ratio_only baseline at 0.768 above the LightGBM model at 0.760.
Grouping models above baselines would have hidden that, which is the original
problem wearing better clothes. Baselines are drawn grey, models blue.

The synthetic-label share travels with every figure, so accuracy cannot be
read here without the caveat. fmt() prints an em dash for an absent metric,
never 0.00 — a missing measurement and a measured zero are different claims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:46:02 +05:30
LincPayandClaude Opus 5 1ffc1cbd4c feat(demo): one borrower on both surfaces, and a lead-time dashboard
/demo puts the officer console and the borrower's phone side by side on one
screen. The right column is a real borrower sign-in — selecting a row calls
/auth/app-login and every panel beside it is fetched with that borrower's own
token. Nothing is mirrored across, which is the point: if the two sides agree
it is because they read one record, and the page checks that rather than
asserting it.

It uses its own axios instance. The shared one picks its JWT from
window.location.pathname, which is right for the two portals and wrong here —
it would send the officer's token to /app/*, and its 401 handler would then
clear both sessions and bounce to sign-in, during a live demo.

The officer dashboard is reorganised around when a borrower's downside stops
covering the instalment, rather than by composite score. Lead time is the
thing this platform can show that a bureau score cannot, so the triage funnel
counts act-now / in-window / covered and the work list ranks by it. The
previous score-ranked view is preserved as a tab; they answer different
questions and sort by different keys, and merging them would mean losing one
ordering.

StatusPill no longer white-screens on a missing status. It renders wire data
on fifteen call sites; a row that arrives without one is a data gap, not
grounds to take the page down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:45:48 +05:30
LincPayandClaude Opus 5 13c628fa4b feat(pwa): actually send the offline queue, and sync when the radio returns
syncPending() flipped entries from PENDING to SYNCED without sending
anything. The offline half of the promise worked — the owner could record a
sale with no signal and never saw a spinner — but the entry stopped at
localStorage and the lender never saw it.

It now posts to /app/ledger/sync and marks only the entries the server
accepted. It re-reads storage before writing rather than reusing the snapshot,
because the owner may have tapped save again while the request was in flight
and that entry must stay PENDING. A failure is deliberately silent: the
entries stay queued and go again next time, because from the owner's side
nothing has gone wrong, and losing a tap is the one failure this app cannot
have.

useAutoSync() drains the queue on the browser's `online` event and once on
mount — the app is commonly opened after connectivity returns rather than
while it is returning, in which case no online event ever fires.

The consent toggle now writes to the server instead of holding local state,
and the alerts screen renders the borrower's real reason codes from
/app/reasons. Where the request succeeds with nothing on record it says so;
inventing three sentences on the borrower's own screen would be a lie about
the one thing they are entitled to see.

Also fixes a month label that rendered blank on every live forecast point: a
${p.month} interpolation had been eaten by an earlier edit, so the strip only
displayed correctly when the API was unreachable and the demo generator took
over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:45:34 +05:30
LincPayandClaude Opus 5 41c49113c2 fix(tenancy): borrower rows come from the scoped API or they do not render
The console fell back to lib/demo/* whenever a request failed, which reads
well until you notice the demo portfolio has no owner on it. The ids are
generated from a formula and ops.ts does not mention institutions at all, so
the fallback listed all 142 fabricated borrowers to whoever was signed in — a
Madhya Pradesh Gramin officer was shown a Vindhya Small Finance Bank
borrower. Clicking it produced a 404 from the API, which was the only correct
part of the exchange. Risk alerts and Field visits never called the API at
all.

Scoping the demo data was attempted and cannot be done honestly: the session
carries blocks, but blocks do not map to banks. Sehore holds both MPGB and
Sehore DCCB, Raisen holds both Sehore DCCB and VSFB, and MPGB's own block list
includes Bhopal, which is entirely VSFB.

So the rule is now the one the central claim requires: borrower rows come
from the tenant-scoped API or the screen says it cannot reach the server. A
console that invents borrowers while the server is down is worse than one
that admits it cannot reach the server — particularly this console, whose
whole argument is that a bank's book is unreachable from another bank's
screen.

useTenantData.ts is the only sanctioned source. Each hook returns
`unavailable` rather than substituting data. scopedEnterprises() now takes the
book as a required argument with no default, so reintroducing the leak is a
compile error rather than an oversight.

Feeds, data sources, connectors and the rule catalogue keep their demo
fallbacks. Nothing in them is a borrower.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:45:19 +05:30
LincPay 39ca34be68 feat(config): resolve the API base at runtime from config.js 2026-08-15 17:01:45 +05:30
LincPay aa1484c285 build(dev): proxy /api in dev and preview so the production bundle is testable 2026-08-15 16:46:33 +05:30
LincPay 318b83625a build(deploy): relative API base and a same-origin /api proxy 2026-08-15 15:33:21 +05:30
LincPayandClaude Opus 5 627e11e9a9 feat(nabard): regional office console, and the horticulture sector pack
Adds the fourth supervisory tier to the console — RegionalOfficesPage at
/nabard/regional-offices, a nav entry, and a sidebar that names the tier
(NABARD Head Office vs Regional Office) because NABARD HO and the Maharashtra
RO are both in Mumbai and a drill-down labelled by city reads as circular.

The login page gains a public /auth/regional-offices dropdown and a one-click
persona panel grouped by tier. The dropdown only pre-fills credentials: scope
still comes from the token's office claim, so picking Bhopal and signing in as
Bengaluru shows Bengaluru.

The geography drill gets a By regional office / By zone root toggle rather than
an RO rung inserted into the zone ladder. They do not nest — Chandigarh
supervises Punjab and Haryana across a zone boundary — and they answer
different questions: a zone is weather and cropping pattern, an office is who is
accountable. Both roots narrow through the same zone parameter.

Two bugs worth naming, since both were invisible until they weren't:

  - isApexRole() matched only NABARD_*, so RO_ADMIN routed to the bank console —
    the borrower-facing surface, for the one user who may never see a borrower.
    The backend refused every call, so it read as an empty screen rather than a
    leak, but it was still the wrong door.
  - Adding a sector to the backend without the UI's Record<Sector, ...> maps
    white-screens the whole officer console on SECTORS[e.sector].short of
    undefined. HORTICULTURE needed seven maps. Widen the Sector type first and
    TypeScript finds every one of them.

Bands read from the stored value, never recomputed from score. They differ
because a fired trip-wire escalates the band without rewriting the score —
Madhya Pradesh reads 35 RED by band and 5 by threshold, so recomputing made RED
collapse when drilling HO -> RO on the same state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:29:36 +05:30
LincPayandClaude Opus 5 e923daf825 feat(integration): DPD history tab — coverage, generation and backfill
A fourth tab on /officer/integration reading the loan book as a training
set. The verdict banner is the point of the screen: DPD history is the
model's label, and three thousand generated snapshots look identical on
a chart to three thousand real ones, so the split is stated in words at
the top rather than left to be inferred from a percentage.

Four tiles, and "unknown provenance" is one of them — rows written
before stamping existed are shown as their own bucket rather than
counted as real. Generation and purge sit beside a backfill-from-core
control, with the honest note that a backfill from a simulated adapter
produces simulated rows and only a bank's own core counts toward the
real total.

The demo fixture deliberately reports a book that is NOT trainable. A
fixture claiming a healthy training set would defeat the entire purpose
of the panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:58:14 +05:30
LincPayandClaude Opus 5 0a76a53ceb 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>
2026-08-07 18:20:15 +05:30