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: [], + } +}