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