From b6eea1360f9cde2dac7883f6e8831fb5f567c8f7 Mon Sep 17 00:00:00 2001 From: LincPay Date: Tue, 25 Aug 2026 13:46:14 +0530 Subject: [PATCH] feat(sources): show every data source, and fix the navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `. + * + *

The scope controls in the console header were the last two OS-rendered + * dropdowns in the chrome: a native ` 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" - > - - {BLOCKS.map((b) => ( - - ))} - - - +

+ + + Search enterprise or ID… + - 🔔 - {openAlerts > 0 && } + + {openAlerts > 0 && ( + + )} -
- - {initials} - -
-
{session?.fullName ?? 'Field Officer'}
-
Field Officer
-
- + c.toUpperCase())} + initials={initials} + open={openMenu === '__user'} + onToggle={() => toggle('__user')} + onClose={close} + onSignOut={signOut} + /> +
+
+ + {/* ── scope row: what everything below is about ─────────── */} +
+
+
+ {crumb.title} +
+
+ {block === ALL_BLOCKS ? (session?.branch ?? 'All blocks') : `${block} block`} ·{' '} + {monthRef(new Date(`${month}-01T00:00:00`)).fullEn} · {crumb.sub}
- -
- -
+
+ {/* weekly visit progress, which used to live in the sidebar */} + + + {progress.done}/{progress.target} + + + + + visits + -
- UdyamLinc · Predicting stress. Protecting enterprises. — Team LincFusion, LincPay Solutions Pvt. Ltd. -
-
+ + + ({ value: b as string, label: b, hint: `${b} block only` })), + ]} + /> + + ({ value: m.key, label: m.fullEn }))} + /> +
+ + + +
+ +
+ + ) } diff --git a/src/features/officer/RulesPage.tsx b/src/features/officer/RulesPage.tsx index a8e6f14..413ab9f 100644 --- a/src/features/officer/RulesPage.tsx +++ b/src/features/officer/RulesPage.tsx @@ -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('/admin/rules') }) diff --git a/src/features/officer/SettingsPage.tsx b/src/features/officer/SettingsPage.tsx index 97087fd..c16ec68 100644 --- a/src/features/officer/SettingsPage.tsx +++ b/src/features/officer/SettingsPage.tsx @@ -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]" /> {SECTORS[s].label} {SECTORS[s].labelHi} diff --git a/src/lib/api.ts b/src/lib/api.ts index 616ec3d..dd2deb4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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. + * + *

`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 { + const wire = await get(`/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)), + } +} + +/** + * Did the backend answer, and answer "no"? + * + *

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. + * + *

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. + * + *

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("/officer/alerts", { block, state }) +} + +export function fetchVisits(block: string) { + return get('/officer/visits', { block }) +} + +/** Ids on this officer's book that have a phone-app login. */ +export function fetchAppEnrolled(block: string) { + return get('/officer/app-enrolled', { block }) +} + +export function fetchEnterprises(block: string) { + return get('/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. + * + *

`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. + * + *

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. + * + *

`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. + * + *

`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 +} + +export function fetchModelMetrics() { + return get('/admin/model/metrics') +} + +/** Human labels for the arms the backtest writes. */ +export const ARM_LABEL: Record = { + 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. + * + *

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 + alert: RiskAlertRow | null + forecast: ForecastPointRow[] + todayIn: number + todayOut: number + adviceEn: string | null + adviceHi: string | null + lastSyncedAt: string | null +} + +export function fetchAppHome() { + return get('/app/home') +} + +export function fetchAppLedger() { + return get[]>('/app/ledger') +} + +/** The owner's own reason codes, newest run only. Scoped by the token. */ +export function fetchAppReasons() { + return get('/app/reasons') +} + +export function fetchAppAlerts() { + return get('/app/alerts') +} + +/** + * Push locally queued entries. + * + *

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( + '/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. + * + *

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>('/app/consent', { active }) +} + +/** + * Server ledger row → the shape the app is written against. + * + *

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): 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(token: string, path: string): Promise { + 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 { + const res = await rawApi.post('/auth/app-login', { enterpriseId, pin }) + return res.data.data as EnterpriseSession +} + +export const borrower = { + home: (t: string) => asBorrower(t, '/app/home'), + reasons: (t: string) => asBorrower(t, '/app/reasons'), + ledger: (t: string) => asBorrower[]>(t, '/app/ledger'), +} diff --git a/src/lib/demo/connectors.ts b/src/lib/demo/connectors.ts index d0fe2c4..b5b5604 100644 --- a/src/lib/demo/connectors.ts +++ b/src/lib/demo/connectors.ts @@ -41,22 +41,13 @@ export const CANONICAL: CanonicalColumns = { ], } -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 = { +/** + * 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 = { 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', diff --git a/src/lib/sourceInventory.ts b/src/lib/sourceInventory.ts new file mode 100644 index 0000000..18bba90 --- /dev/null +++ b/src/lib/sourceInventory.ts @@ -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. + * + *

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. + * + *

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((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, + }, + ] +} diff --git a/src/lib/sourceStatus.ts b/src/lib/sourceStatus.ts new file mode 100644 index 0000000..356ee6e --- /dev/null +++ b/src/lib/sourceStatus.ts @@ -0,0 +1,78 @@ +import type { LucideIcon } from 'lucide-react' + +/** + * The vocabulary the data-source inventory is written in. + * + *

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 = { + LIVE: 'Live', + SIMULATED: 'Simulated', + NOT_CONNECTED: 'Not connected', +} + +export const STATUS_PILL: Record = { + LIVE: 'pill-ok', + SIMULATED: 'pill-pend', + NOT_CONNECTED: 'pill-mut', +} + +export const STATUS_HELP: Record = { + 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 = { + LIVE: 'bg-green-soft text-forest', + SIMULATED: 'bg-amber-soft text-amber', + NOT_CONNECTED: 'bg-slate-100 text-slate-400', +}