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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user