feat(console): data-entry screen, and mark hand-entered prices as such

/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) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-06 15:17:44 +05:30
co-authored by Claude Opus 5
parent f430f85202
commit 438b97d2d4
6 changed files with 611 additions and 1 deletions
+2
View File
@@ -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() {
<Route path="visits" element={<VisitsPage />} />
<Route path="rules" element={<RulesPage />} />
<Route path="feeds" element={<DataFeedsPage />} />
<Route path="data-entry" element={<DataEntryPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
+384
View File
@@ -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.
*
* <p>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.
*
* <p>The screen is built around one rule: <b>nothing is written until someone has
* seen what will be.</b> 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<HTMLInputElement>(null)
const [entryType, setEntryType] = useState<EntryType>('MARKET_SIGNAL')
const [preview, setPreview] = useState<ManualEntryPreview | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const batchesQ = useQuery({
queryKey: ['manual-batches'],
queryFn: () => get<ManualEntryBatch[]>('/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 (
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<h1 className="text-[15px] font-bold text-forest leading-tight">Data entry</h1>
<p className="text-[11px] text-slate-500">
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.
</p>
</div>
{demo && <StatusPill status="Demo data" />}
</div>
{/* ── upload ─────────────────────────────────────────── */}
<Card title="Upload a file">
<div className="p-4 space-y-3">
<div className="flex flex-wrap items-end gap-3">
<label className="text-[11px] text-slate-600">
<span className="block mb-1 font-semibold">What is in the file</span>
<select
className={inputCls}
value={entryType}
onChange={(e) => {
setEntryType(e.target.value as EntryType)
setPreview(null)
}}
>
<option value="MARKET_SIGNAL">Market prices — egg, milk, fodder</option>
<option value="LOAN_RECORD">Loan book — one row per loan per extract date</option>
</select>
</label>
<label className="text-[11px] text-slate-600">
<span className="block mb-1 font-semibold">File (.xlsx or .csv)</span>
<input
ref={fileRef}
type="file"
accept=".xlsx,.csv"
className={inputCls}
disabled={busy}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void onUpload(file)
}}
/>
</label>
<a
href={templateHref}
className="text-[11px] font-semibold text-forest underline underline-offset-2 pb-2"
>
Download template
</a>
</div>
<p className="text-[11px] text-slate-500 leading-relaxed">
<b>Effective date</b> is the date the price or extract describes — the date you are reporting
<i>about</i>, 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 <b>no borrower identity</b> — 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.
</>
)}
</p>
{busy && <p className="text-[11px] text-slate-500">Working…</p>}
{notice && !error && <p className="text-[11px] font-semibold text-forest">{notice}</p>}
{error && (
<p className="text-[11px] font-semibold text-rose-600">
{error} — showing an example preview so the screen still explains itself.
</p>
)}
</div>
</Card>
{/* ── preview ────────────────────────────────────────── */}
{preview && (
<Card
title={`Preview — ${preview.batch.filename ?? 'upload'}`}
actions={
<div className="flex items-center gap-2">
<GhostBtn onClick={() => void onDiscard()} disabled={busy}>
Discard
</GhostBtn>
<PrimaryBtn onClick={() => void onCommit()} disabled={busy || preview.accepted.length === 0}>
Commit {preview.accepted.length} rows
</PrimaryBtn>
</div>
}
>
<div className="p-4 space-y-4">
<div className="flex flex-wrap gap-4 text-[11px]">
<Count label="Will be written" value={preview.accepted.length} tone="text-emerald-700" />
<Count label="Rejected" value={preview.rejected.length} tone="text-rose-600" />
<Count label="Overwriting" value={preview.overwriting.length} tone="text-amber-600" />
<Count label="Warnings" value={preview.warnings.length} tone="text-amber-600" />
</div>
{preview.batch.note && (
<p className="text-[11px] font-semibold text-rose-600">{preview.batch.note}</p>
)}
{preview.rejected.length > 0 && (
<Section title="Rejected — these will not be written" tone="rose">
<LineTable
lines={preview.rejected}
columns={['Row', 'Reference', 'Effective', 'Reason']}
render={(l) => [
l.rowNumber,
`${l.rawDistrict ?? '—'} / ${l.rawCommodity ?? '—'}`,
l.rawEffectiveDate ?? '—',
<span key="r">
<b>{l.rejectReason}</b>
<span className="text-slate-500"> — {l.rejectDetail}</span>
</span>,
]}
/>
</Section>
)}
{preview.overwriting.length > 0 && (
<Section title="Overwriting — a value is already recorded for these" tone="amber">
<LineTable
lines={preview.overwriting}
columns={['Row', 'Reference', 'Effective', 'Was', 'Becomes']}
render={(l) => [
l.rowNumber,
`${l.rawDistrict ?? '—'} / ${l.rawCommodity ?? '—'}`,
l.effectiveDate ?? '—',
l.priorModalPrice ?? '—',
l.modalPrice ?? '—',
]}
/>
</Section>
)}
{preview.warnings.length > 0 && (
<Section title="Accepted, but worth a look" tone="amber">
<LineTable
lines={preview.warnings}
columns={['Row', 'Reference', 'Warning']}
render={(l) => [
l.rowNumber,
`${l.rawDistrict ?? '—'} / ${l.rawCommodity ?? '—'}`,
l.warning,
]}
/>
</Section>
)}
{preview.accepted.length > 0 && (
<Section title="Will be written" tone="emerald">
<LineTable
lines={preview.accepted}
columns={['Row', 'Reference', 'Effective', 'Value', 'Unit']}
render={(l) => [
l.rowNumber,
`${l.rawDistrict ?? '—'} / ${l.rawCommodity ?? '—'}`,
l.effectiveDate ?? '—',
l.modalPrice ?? '—',
l.unit ?? '—',
]}
/>
</Section>
)}
</div>
</Card>
)}
{/* ── history ────────────────────────────────────────── */}
<Card title="Upload history">
<div className="p-4">
{batches.length === 0 ? (
<Empty text="Nothing has been uploaded yet." />
) : (
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead>
<tr className="text-left text-slate-500 border-b border-slate-200">
{['Batch', 'Type', 'File', 'By', 'When', 'Parsed', 'Written', 'Rejected', 'Status'].map(
(h) => (
<th key={h} className="py-1.5 pr-3 font-semibold">
{h}
</th>
),
)}
</tr>
</thead>
<tbody>
{batches.map((b) => (
<tr key={b.id} className="border-b border-slate-100">
<td className="py-1.5 pr-3 font-semibold">#{b.id}</td>
<td className="py-1.5 pr-3">
{b.entryType === 'LOAN_RECORD' ? 'Loan book' : 'Market prices'}
</td>
<td className="py-1.5 pr-3 text-slate-600">{b.filename ?? '—'}</td>
<td className="py-1.5 pr-3 text-slate-600">{b.uploadedBy}</td>
<td className="py-1.5 pr-3 text-slate-500">{b.uploadedAt?.replace('T', ' ').slice(0, 16)}</td>
<td className="py-1.5 pr-3">{b.rowsParsed ?? '—'}</td>
<td className="py-1.5 pr-3">{b.status === 'COMMITTED' ? (b.rowsAccepted ?? '—') : '—'}</td>
<td className="py-1.5 pr-3">
{b.rowsRejected ? <span className="text-rose-600 font-semibold">{b.rowsRejected}</span> : '0'}
</td>
<td className="py-1.5 pr-3">
<StatusPill status={b.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Card>
</div>
)
}
// ── small pieces ───────────────────────────────────────────────
function Count({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div>
<div className={`text-[18px] font-bold leading-none ${tone}`}>{value}</div>
<div className="text-slate-500 mt-0.5">{label}</div>
</div>
)
}
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 (
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className={`inline-block w-1 h-3.5 rounded ${bar}`} />
<h3 className="text-[11px] font-bold text-slate-700">{title}</h3>
</div>
{children}
</div>
)
}
function LineTable({
lines,
columns,
render,
}: {
lines: ManualEntryLine[]
columns: string[]
render: (line: ManualEntryLine) => React.ReactNode[]
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead>
<tr className="text-left text-slate-500 border-b border-slate-200">
{columns.map((c) => (
<th key={c} className="py-1 pr-3 font-semibold">
{c}
</th>
))}
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.id} className="border-b border-slate-100">
{render(line).map((cell, i) => (
<td key={i} className="py-1 pr-3 align-top">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
+24 -1
View File
@@ -128,6 +128,7 @@ export function DataFeedsPage() {
<tr>
<th>District</th>
<th>Commodity</th>
<th>Source</th>
<th className="text-right">Modal</th>
<th className="text-right">Range</th>
<th className="text-right">Arrivals</th>
@@ -140,6 +141,9 @@ export function DataFeedsPage() {
<tr key={row.id}>
<td>{row.districtName}</td>
<td className="font-medium text-slate-700">{row.commodityName}</td>
<td>
<SourceChip source={row.source} />
</td>
<td className="text-right font-semibold">
{row.modalPrice === null ? '—' : `₹${row.modalPrice.toLocaleString('en-IN')}`}
</td>
@@ -159,7 +163,10 @@ export function DataFeedsPage() {
</table>
)}
<p className="text-[10.5px] text-slate-400 mt-2.5">
A price level is not a feature. These rows become <span className="font-mono">feed_cost_change</span>,{' '}
<b>Hand</b> 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{' '}
<span className="font-mono">feed_cost_change</span>,{' '}
<span className="font-mono">margin_squeeze</span> and{' '}
<span className="font-mono">price_z_score</span> against their trailing means.
</p>
@@ -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 (
<span className="text-[9.5px] font-semibold bg-amber-50 text-amber-700 border border-amber-200 px-1.5 py-0.5 rounded-lg">
Hand
</span>
)
}
return <span className="text-[9.5px] text-slate-500">{source === 'AGMARKNET' ? 'Agmarknet' : source}</span>
}
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 (
+1
View File
@@ -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' },
]
+59
View File
@@ -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 }
}
+141
View File
@@ -0,0 +1,141 @@
import type { ManualEntryBatch, ManualEntryLine } from '../api'
/**
* Demo fallback for the data-entry screen.
*
* <p>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>): 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: [],
}
}