From 438b97d2d4929d7322debda25f435d22404b09de Mon Sep 17 00:00:00 2001 From: LincPay Date: Thu, 6 Aug 2026 15:17:44 +0530 Subject: [PATCH] feat(console): data-entry screen, and mark hand-entered prices as such MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /officer/data-entry — upload, preview, commit, and the batch history, for the feeds no API serves and for the loan book. The screen is built around one rule: nothing is written until someone has seen what will be. Accepted, rejected and overwriting rows are shown as three separate groups, because merging them into one list of rows hides the only two questions the person clicking Commit has — what will not land, and what will land on top of something already there. An overwriting row shows the value it replaces beside the one replacing it. The screen states in plain words that the effective date is the date being reported about rather than today, and that when we learned it is recorded separately — the distinction is invisible in a spreadsheet and expensive to get wrong. Selecting the loan book adds the no-borrower-identity rule. On the feeds page, mandi rows now carry a source: Agmarknet, or a '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 there or it is visible nowhere. The demo fallback deliberately contains rejections. A demo where every upload is clean teaches the wrong thing — a reviewer who only sees green rows will not know the rejection panel exists. tsc, oxlint and npm run build all clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/App.tsx | 2 + src/features/officer/DataEntryPage.tsx | 384 +++++++++++++++++++++++++ src/features/officer/DataFeedsPage.tsx | 25 +- src/features/officer/OfficerShell.tsx | 1 + src/lib/api.ts | 59 ++++ src/lib/demo/manualEntry.ts | 141 +++++++++ 6 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 src/features/officer/DataEntryPage.tsx create mode 100644 src/lib/demo/manualEntry.ts diff --git a/src/App.tsx b/src/App.tsx index 68a6daf..b3aa3ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { ForecastsPage } from './features/officer/ForecastsPage' 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 { ReportsPage } from './features/officer/ReportsPage' import { SettingsPage } from './features/officer/SettingsPage' @@ -77,6 +78,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/features/officer/DataEntryPage.tsx b/src/features/officer/DataEntryPage.tsx new file mode 100644 index 0000000..7e09188 --- /dev/null +++ b/src/features/officer/DataEntryPage.tsx @@ -0,0 +1,384 @@ +import { useRef, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { API_BASE, del, errMsg, get, post, uploadManual } from '../../lib/api' +import type { ManualEntryBatch, ManualEntryLine, ManualEntryPreview } from '../../lib/api' +import { Card, Empty, GhostBtn, PrimaryBtn, StatusPill, inputCls } from '../../components/ui/bits' +import { demoBatches, demoPreview } from '../../lib/demo/manualEntry' + +type EntryType = 'MARKET_SIGNAL' | 'LOAN_RECORD' + +/** + * Manual and bulk entry for the data no API serves. + * + *

Egg, milk and fodder have no usable feed for the pilot districts, and the + * loan book has none at all until a lender builds a nightly extract. This screen + * is how a bank gets that data in. + * + *

The screen is built around one rule: nothing is written until someone has + * seen what will be. An upload stages a preview and reports three things + * separately — what will land, what was rejected and why, and what will land on + * top of a value already there. Merging those into one list of "rows" would hide + * the only two questions the person clicking Commit actually has. + */ +export function DataEntryPage() { + const queryClient = useQueryClient() + const fileRef = useRef(null) + + const [entryType, setEntryType] = useState('MARKET_SIGNAL') + const [preview, setPreview] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + + const batchesQ = useQuery({ + queryKey: ['manual-batches'], + queryFn: () => get('/admin/manual/batches', { limit: 20 }), + }) + const demo = batchesQ.isError + const batches = batchesQ.data ?? demoBatches() + + async function onUpload(file: File) { + setBusy(true) + setError(null) + setNotice(null) + try { + const res = await uploadManual(file, entryType) + setPreview(res.data) + setNotice(res.message) + } catch (e) { + // The backend is unreachable — show the shape of a preview rather than a + // dead screen, and say so. + setPreview(demoPreview()) + setError(errMsg(e)) + } finally { + setBusy(false) + if (fileRef.current) fileRef.current.value = '' + } + } + + async function onCommit() { + if (!preview) return + setBusy(true) + setError(null) + try { + await post(`/admin/manual/${preview.batch.id}/commit`) + setNotice(`Batch ${preview.batch.id} committed — ${preview.accepted.length} rows written`) + setPreview(null) + await queryClient.invalidateQueries({ queryKey: ['manual-batches'] }) + } catch (e) { + setError(errMsg(e)) + } finally { + setBusy(false) + } + } + + async function onDiscard() { + if (!preview) return + setBusy(true) + try { + await del(`/admin/manual/${preview.batch.id}`) + setNotice(`Batch ${preview.batch.id} discarded — nothing was written`) + setPreview(null) + await queryClient.invalidateQueries({ queryKey: ['manual-batches'] }) + } catch (e) { + setError(errMsg(e)) + } finally { + setBusy(false) + } + } + + const templateHref = + entryType === 'LOAN_RECORD' + ? `${API_BASE}/admin/manual/template/loans` + : `${API_BASE}/admin/manual/template/market` + + return ( +

+
+
+

Data entry

+

+ For the feeds no API serves — NECC egg rates, dairy-federation milk, green fodder — and the + loan book until a nightly extract exists. Nothing is written until you have seen what will be. +

+
+ {demo && } +
+ + {/* ── upload ─────────────────────────────────────────── */} + +
+
+ + + + + + Download template + +
+ +

+ Effective date is the date the price or extract describes — the date you are reporting + about, not today. When we learned it is recorded separately and automatically, so a + July rate uploaded in August stays invisible to a July scoring run. + {entryType === 'LOAN_RECORD' && ( + <> + {' '} + The loan book must carry no borrower identity — no name, phone, Aadhaar, PAN, + account number or address. A file containing any of those is refused whole rather than + having the column quietly dropped. + + )} +

+ + {busy &&

Working…

} + {notice && !error &&

{notice}

} + {error && ( +

+ {error} — showing an example preview so the screen still explains itself. +

+ )} +
+
+ + {/* ── preview ────────────────────────────────────────── */} + {preview && ( + + void onDiscard()} disabled={busy}> + Discard + + void onCommit()} disabled={busy || preview.accepted.length === 0}> + Commit {preview.accepted.length} rows + +
+ } + > +
+
+ + + + +
+ + {preview.batch.note && ( +

{preview.batch.note}

+ )} + + {preview.rejected.length > 0 && ( +
+ [ + l.rowNumber, + `${l.rawDistrict ?? '—'} / ${l.rawCommodity ?? '—'}`, + l.rawEffectiveDate ?? '—', + + {l.rejectReason} + — {l.rejectDetail} + , + ]} + /> +
+ )} + + {preview.overwriting.length > 0 && ( +
+ [ + l.rowNumber, + `${l.rawDistrict ?? '—'} / ${l.rawCommodity ?? '—'}`, + l.effectiveDate ?? '—', + l.priorModalPrice ?? '—', + l.modalPrice ?? '—', + ]} + /> +
+ )} + + {preview.warnings.length > 0 && ( +
+ [ + l.rowNumber, + `${l.rawDistrict ?? '—'} / ${l.rawCommodity ?? '—'}`, + l.warning, + ]} + /> +
+ )} + + {preview.accepted.length > 0 && ( +
+ [ + l.rowNumber, + `${l.rawDistrict ?? '—'} / ${l.rawCommodity ?? '—'}`, + l.effectiveDate ?? '—', + l.modalPrice ?? '—', + l.unit ?? '—', + ]} + /> +
+ )} +
+ + )} + + {/* ── history ────────────────────────────────────────── */} + +
+ {batches.length === 0 ? ( + + ) : ( +
+ + + + {['Batch', 'Type', 'File', 'By', 'When', 'Parsed', 'Written', 'Rejected', 'Status'].map( + (h) => ( + + ), + )} + + + + {batches.map((b) => ( + + + + + + + + + + + + ))} + +
+ {h} +
#{b.id} + {b.entryType === 'LOAN_RECORD' ? 'Loan book' : 'Market prices'} + {b.filename ?? '—'}{b.uploadedBy}{b.uploadedAt?.replace('T', ' ').slice(0, 16)}{b.rowsParsed ?? '—'}{b.status === 'COMMITTED' ? (b.rowsAccepted ?? '—') : '—'} + {b.rowsRejected ? {b.rowsRejected} : '0'} + + +
+
+ )} +
+
+ + ) +} + +// ── small pieces ─────────────────────────────────────────────── + +function Count({ label, value, tone }: { label: string; value: number; tone: string }) { + return ( +
+
{value}
+
{label}
+
+ ) +} + +function Section({ + title, + tone, + children, +}: { + title: string + tone: 'rose' | 'amber' | 'emerald' + children: React.ReactNode +}) { + const bar = { rose: 'bg-rose-500', amber: 'bg-amber-500', emerald: 'bg-emerald-500' }[tone] + return ( +
+
+ +

{title}

+
+ {children} +
+ ) +} + +function LineTable({ + lines, + columns, + render, +}: { + lines: ManualEntryLine[] + columns: string[] + render: (line: ManualEntryLine) => React.ReactNode[] +}) { + return ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {lines.map((line) => ( + + {render(line).map((cell, i) => ( + + ))} + + ))} + +
+ {c} +
+ {cell} +
+
+ ) +} diff --git a/src/features/officer/DataFeedsPage.tsx b/src/features/officer/DataFeedsPage.tsx index 74569b9..d6b15b8 100644 --- a/src/features/officer/DataFeedsPage.tsx +++ b/src/features/officer/DataFeedsPage.tsx @@ -128,6 +128,7 @@ export function DataFeedsPage() { District Commodity + Source Modal Range Arrivals @@ -140,6 +141,9 @@ export function DataFeedsPage() { {row.districtName} {row.commodityName} + + + {row.modalPrice === null ? '—' : `₹${row.modalPrice.toLocaleString('en-IN')}`} @@ -159,7 +163,10 @@ export function DataFeedsPage() { )}

- A price level is not a feature. These rows become feed_cost_change,{' '} + Hand marks a price somebody typed, on the data-entry screen, because no API serves it. + It is worth exactly as much as the person who entered it — which is why it never looks like a + government feed. A price level is not a feature: these rows become{' '} + feed_cost_change,{' '} margin_squeeze and{' '} price_z_score against their trailing means.

@@ -287,6 +294,22 @@ function FreshnessRow({ ) } +/** + * Where a row came from. A hand-entered egg rate and an Agmarknet price are both + * just numbers by the time they reach a feature, so the distinction has to be + * visible here or it is visible nowhere. + */ +function SourceChip({ source }: { source: string }) { + if (source === 'MANUAL') { + return ( + + Hand + + ) + } + return {source === 'AGMARKNET' ? 'Agmarknet' : source} +} + function AgeChip({ validFor, warnAbove }: { validFor: string; warnAbove: number }) { const days = Math.max(0, Math.round((Date.now() - new Date(validFor).getTime()) / 86_400_000)) return ( diff --git a/src/features/officer/OfficerShell.tsx b/src/features/officer/OfficerShell.tsx index 433cb1c..ac1e38e 100644 --- a/src/features/officer/OfficerShell.tsx +++ b/src/features/officer/OfficerShell.tsx @@ -24,6 +24,7 @@ const NAV: { label: string; to: string; end?: boolean }[] = [ { label: 'Field visits', to: '/officer/visits' }, { label: 'Trip-wires', to: '/officer/rules' }, { label: 'Data feeds', to: '/officer/feeds' }, + { label: 'Data entry', to: '/officer/data-entry' }, { label: 'Reports', to: '/officer/reports' }, { label: 'Settings', to: '/officer/settings' }, ] diff --git a/src/lib/api.ts b/src/lib/api.ts index 4892b4a..34c236f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -397,3 +397,62 @@ export interface AppHome { advice: { textEn: string; textHi: string } lastSyncedAt: string | null } + +// ── Manual & bulk data entry ───────────────────────────────────── +// The feeds no API serves: NECC egg rates, dairy-federation milk, fodder — and +// the loan book, until a lender builds a nightly extract. + +export interface ManualEntryBatch { + id: number + entryType: 'MARKET_SIGNAL' | 'LOAN_RECORD' + filename: string | null + uploadedBy: string + uploadedAt: string + status: 'PREVIEW' | 'COMMITTED' | 'DISCARDED' + committedAt: string | null + committedBy: string | null + rowsParsed: number | null + rowsAccepted: number | null + rowsRejected: number | null + rowsOverwriting: number | null + note: string | null +} + +export interface ManualEntryLine { + id: number + rowNumber: number + rawDistrict: string | null + rawCommodity: string | null + rawEffectiveDate: string | null + rawModalPrice: string | null + effectiveDate: string | null + weekStart: string | null + modalPrice: number | null + minPrice: number | null + maxPrice: number | null + unit: string | null + accepted: boolean + rejectReason: string | null + rejectDetail: string | null + warning: string | null + overwriting: boolean + /** What an accepted row would replace — a correction is only auditable if this survives. */ + priorModalPrice: number | null +} + +/** Accepted, rejected and overwriting stay apart all the way from the backend. */ +export interface ManualEntryPreview { + batch: ManualEntryBatch + accepted: ManualEntryLine[] + rejected: ManualEntryLine[] + overwriting: ManualEntryLine[] + warnings: ManualEntryLine[] +} + +/** Multipart, so it bypasses the JSON helpers. */ +export async function uploadManual(file: File, type: 'MARKET_SIGNAL' | 'LOAN_RECORD') { + const form = new FormData() + form.append('file', file) + const res = await api.post(`/admin/manual/upload?type=${type}`, form) + return res.data as { success: boolean; message: string; data: ManualEntryPreview } +} diff --git a/src/lib/demo/manualEntry.ts b/src/lib/demo/manualEntry.ts new file mode 100644 index 0000000..96ae69f --- /dev/null +++ b/src/lib/demo/manualEntry.ts @@ -0,0 +1,141 @@ +import type { ManualEntryBatch, ManualEntryLine } from '../api' + +/** + * Demo fallback for the data-entry screen. + * + *

Deliberately shows a batch with rejections in it. A demo where every upload + * is clean teaches the wrong thing: the screen's whole job is to make what will + * *not* land as visible as what will, and a reviewer who only ever sees green + * rows will not know the rejection panel exists. + */ + +export function demoBatches(): ManualEntryBatch[] { + return [ + { + id: 5, + entryType: 'MARKET_SIGNAL', + filename: 'necc-egg-july.csv', + uploadedBy: 'supervisor', + uploadedAt: '2026-08-06T09:14:00', + status: 'COMMITTED', + committedAt: '2026-08-06T09:16:00', + committedBy: 'supervisor', + rowsParsed: 48, + rowsAccepted: 48, + rowsRejected: 0, + rowsOverwriting: 0, + note: null, + }, + { + id: 4, + entryType: 'LOAN_RECORD', + filename: 'loanbook-2026-07.xlsx', + uploadedBy: 'supervisor', + uploadedAt: '2026-08-05T18:02:00', + status: 'COMMITTED', + committedAt: '2026-08-05T18:05:00', + committedBy: 'supervisor', + rowsParsed: 12, + rowsAccepted: 12, + rowsRejected: 0, + rowsOverwriting: 2, + note: null, + }, + { + id: 3, + entryType: 'MARKET_SIGNAL', + filename: 'milk-rates-draft.csv', + uploadedBy: 'supervisor', + uploadedAt: '2026-08-05T11:40:00', + status: 'DISCARDED', + committedAt: null, + committedBy: 'supervisor', + rowsParsed: 18, + rowsAccepted: 12, + rowsRejected: 6, + rowsOverwriting: 0, + note: null, + }, + ] +} + +export function demoPreview(): { + batch: ManualEntryBatch + accepted: ManualEntryLine[] + rejected: ManualEntryLine[] + overwriting: ManualEntryLine[] + warnings: ManualEntryLine[] +} { + const line = (over: Partial): ManualEntryLine => ({ + id: Math.round(Math.random() * 1e6), + rowNumber: 2, + rawDistrict: 'BHOPAL', + rawCommodity: 'EGG', + rawEffectiveDate: '2026-08-03', + rawModalPrice: '610', + effectiveDate: '2026-08-03', + weekStart: '2026-08-03', + modalPrice: 610, + minPrice: null, + maxPrice: null, + unit: 'HUNDRED', + accepted: true, + rejectReason: null, + rejectDetail: null, + warning: null, + overwriting: false, + priorModalPrice: null, + ...over, + }) + + const accepted = [ + line({ rowNumber: 2 }), + line({ rowNumber: 3, rawDistrict: 'SEHORE', rawCommodity: 'MILK', modalPrice: 38.5, unit: 'LITRE' }), + line({ rowNumber: 4, modalPrice: 625, overwriting: true, priorModalPrice: 610 }), + ] + const rejected = [ + line({ + rowNumber: 5, + accepted: false, + rawDistrict: 'NOWHERE', + rejectReason: 'UNKNOWN_DISTRICT', + rejectDetail: "no active district with code 'NOWHERE'", + }), + line({ + rowNumber: 6, + accepted: false, + rawEffectiveDate: '2026-12-25', + rejectReason: 'FUTURE_DATE', + rejectDetail: '2026-12-25 is in the future', + }), + line({ + rowNumber: 7, + accepted: false, + rawCommodity: 'MILK', + rejectReason: 'UNIT_MISMATCH', + rejectDetail: 'MILK is recorded in LITRE, not QUINTAL', + }), + ] + + return { + batch: { + id: 6, + entryType: 'MARKET_SIGNAL', + filename: 'necc-egg-august.csv', + uploadedBy: 'supervisor', + uploadedAt: '2026-08-06T10:00:00', + status: 'PREVIEW', + committedAt: null, + committedBy: null, + rowsParsed: 6, + rowsAccepted: 3, + rowsRejected: 3, + rowsOverwriting: 1, + note: null, + }, + accepted, + rejected, + overwriting: accepted.filter((l) => l.overwriting), + warnings: [], + } +}