import { useEffect, useMemo, useRef, useState } from 'react' import { formatINR } from '@sims/domain' import { parseEntry } from '@sims/scanning' import { resolveTaxClass, type TaxClassRow } from '@sims/billing-engine' import type { LabelInput } from '@sims/printing' import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui' import { getBootstrapPublic, getCostHistory, getItems, getLastCosts, getParties, getPurchases, getStock, postPurchase } from '../api' import { openLabelSheet } from './labels' /** Crash-proof draft (spec P1-5): a 180-line invoice must survive a browser crash. */ const DRAFT_KEY = 'pe.draft' interface Draft { supplier?: { id: string; name: string } invoiceNo: string invoiceDate: string lines: PLine[] } /** * Purchase Entry — the second crown-jewel screen (09-UX §3): a purchaser types * a 50–200 line supplier invoice as fast as the paper can be read. * Flow per line: scan/type item → qty → cost (last-cost prefilled) → Enter * returns to the scan box. Cost changes prompt a price revision inline. */ interface ItemLite { id: string; code: string; name: string; taxClassCode: string salePricePaise: number; mrpPaise?: number unit: { code: string; decimals: number }; barcodes: string[] } interface PLine { itemId: string; name: string; unitCode: string qty: number; unitCostPaise: number; taxRateBp: number lastCostPaise?: number currentSalePaise: number; currentMrpPaise?: number newSalePricePaise?: number; newMrpPaise?: number } const inr = (p: number) => formatINR(p, { symbol: false }) const rs = (s: string) => Math.round(Number(s) * 100) export function PurchaseEntryPage() { const today = new Date().toISOString().slice(0, 10) const [items, setItems] = useState([]) const [taxClasses, setTaxClasses] = useState([]) const [suppliers, setSuppliers] = useState<{ id: string; name: string }[]>([]) const [supplier, setSupplier] = useState<{ id: string; name: string } | undefined>() const [supplierQuery, setSupplierQuery] = useState('') const [lastCost, setLastCost] = useState>({}) const [invoiceNo, setInvoiceNo] = useState('') const [invoiceDate, setInvoiceDate] = useState(today) const [lines, setLines] = useState([]) const [entry, setEntry] = useState('') const [printedTotal, setPrintedTotal] = useState('') const [notice, setNotice] = useState<{ tone: 'ok' | 'warn' | 'err'; text: string } | undefined>() const [busy, setBusy] = useState(false) /** Filled on post so the receiving clerk can print labels for what just came in. */ const [labelQueue, setLabelQueue] = useState() /** Which cell owns focus: the scan box, or qty/cost of a line. */ const [focusCell, setFocusCell] = useState<{ line: number; cell: 'qty' | 'cost' } | undefined>() const entryRef = useRef(null) const cellRef = useRef(null) const [stockMap, setStockMap] = useState>(new Map()) const [draft, setDraft] = useState() const [costHist, setCostHist] = useState<{ cost: number; date: string; supplier: string }[] | undefined>() useEffect(() => { Promise.all([getItems(), getParties('supplier'), getBootstrapPublic(), getStock()]) .then(([its, sups, boot, stock]) => { setItems(its as unknown as ItemLite[]) setSuppliers(sups.map((s) => ({ id: String(s['id']), name: String(s['name']) }))) setTaxClasses(boot.taxClasses) setStockMap(new Map(stock.map((s) => [String(s['code']), Number(s['on_hand'])]))) }) .catch((e: Error) => setNotice({ tone: 'err', text: e.message })) // resume a crashed entry? try { const raw = localStorage.getItem(DRAFT_KEY) if (raw !== null) { const d = JSON.parse(raw) as Draft if (d.lines.length > 0) setDraft(d) } } catch { /* corrupted draft — ignore */ } }, []) // journal every change; cleared on post useEffect(() => { if (lines.length > 0) { localStorage.setItem(DRAFT_KEY, JSON.stringify({ supplier, invoiceNo, invoiceDate, lines } satisfies Draft)) } }, [lines, supplier, invoiceNo, invoiceDate]) // right-rail glanceability (spec P1-1): cost history for the line being edited useEffect(() => { const line = focusCell !== undefined ? lines[focusCell.line] : undefined if (line === undefined) return setCostHist(undefined) getCostHistory(line.itemId, supplier?.id).then(setCostHist).catch(() => setCostHist(undefined)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusCell?.line]) useEffect(() => { getLastCosts(supplier?.id).then(setLastCost).catch(() => undefined) }, [supplier?.id]) useEffect(() => { if (focusCell !== undefined) cellRef.current?.select() else entryRef.current?.focus() }, [focusCell, lines.length]) const say = (tone: 'ok' | 'warn' | 'err', text: string) => setNotice({ tone, text }) const rateOf = (item: ItemLite): number => { try { const r = resolveTaxClass(taxClasses, item.taxClassCode, invoiceDate) return r.ratePctBp + r.cessPctBp } catch { return 0 } } const addItem = (item: ItemLite, qty = 1) => { const memory = lastCost[item.id] const prefill = memory?.supplierLast ?? memory?.last ?? 0 setLines((prev) => { setFocusCell({ line: prev.length, cell: 'qty' }) return [...prev, { itemId: item.id, name: item.name, unitCode: item.unit.code, qty, unitCostPaise: prefill, taxRateBp: rateOf(item), ...(memory?.supplierLast !== undefined || memory?.last !== undefined ? { lastCostPaise: memory.supplierLast ?? memory.last } : {}), currentSalePaise: item.salePricePaise, ...(item.mrpPaise !== undefined ? { currentMrpPaise: item.mrpPaise } : {}), }] }) setNotice(undefined) } const handleEntry = () => { const parsed = parseEntry(entry) setEntry('') const find = (code: string) => items.find((i) => i.barcodes.includes(code)) ?? items.find((i) => i.code === code) switch (parsed.kind) { case 'empty': return case 'multiplier': { if (parsed.rest === '') return say('warn', 'Type the item after the quantity, e.g. 24*surf') const item = /^\d+$/.test(parsed.rest) ? find(parsed.rest) : items.find((i) => i.name.toLowerCase().includes(parsed.rest.toLowerCase())) return item !== undefined ? addItem(item, parsed.qty) : say('err', `No item for "${parsed.rest}" (E-1102)`) } case 'barcode': case 'code': { const item = find(parsed.code) return item !== undefined ? addItem(item) : say('err', `Unknown code ${parsed.code} (E-1102)`) } case 'search': { const q = parsed.text.toLowerCase() const item = items.filter((i) => i.name.toLowerCase().includes(q)) .sort((a, b) => Number(b.name.toLowerCase().startsWith(q)) - Number(a.name.toLowerCase().startsWith(q)))[0] return item !== undefined ? addItem(item) : say('err', `No item matches "${parsed.text}" (E-1102)`) } } } const setLine = (i: number, patch: Partial) => setLines((prev) => prev.map((l, j) => (j === i ? { ...l, ...patch } : l))) const totals = useMemo(() => { let taxable = 0, tax = 0 for (const l of lines) { const t = Math.round(l.qty * l.unitCostPaise) taxable += t tax += Math.round((t * l.taxRateBp) / 10_000) } return { taxable, tax, total: taxable + tax } }, [lines]) const printedPaise = printedTotal === '' ? undefined : rs(printedTotal) const crossCheck = printedPaise === undefined ? 'none' : printedPaise === totals.total ? 'match' : 'mismatch' const post = () => { if (busy) return if (supplier === undefined) return say('warn', 'Pick the supplier first') if (invoiceNo.trim() === '') return say('warn', 'Enter the supplier invoice number') if (lines.length === 0) return say('warn', 'No lines yet — scan or type the first item') if (crossCheck === 'mismatch') { return say('warn', `Printed total ${formatINR(printedPaise!)} ≠ computed ${formatINR(totals.total)} — fix a line or clear the check field to post anyway`) } setBusy(true) postPurchase({ storeId: 's1', supplierId: supplier.id, invoiceNo: invoiceNo.trim(), invoiceDate, businessDate: today, lines: lines.map((l) => ({ itemId: l.itemId, name: l.name, qty: l.qty, unitCostPaise: l.unitCostPaise, taxRateBp: l.taxRateBp, ...(l.newSalePricePaise !== undefined ? { newSalePricePaise: l.newSalePricePaise } : {}), ...(l.newMrpPaise !== undefined ? { newMrpPaise: l.newMrpPaise } : {}), })), clientTotalPaise: totals.total, }) .then((res) => { say('ok', `Purchase posted — ${lines.length} lines, ${formatINR(res.totalPaise)}. Stock is in; price updates applied.`) // Label queue (09-UX §3.2): received qty each, MRP/price effective after the post, // first barcode from the item master. The clerk prints on the spot. setLabelQueue(lines.map((l): LabelInput => { const item = items.find((i) => i.id === l.itemId) const mrp = l.newMrpPaise ?? l.currentMrpPaise ?? l.newSalePricePaise ?? l.currentSalePaise const barcode = item?.barcodes.find((b) => b !== '') return { name: l.name, mrpPaise: mrp, pricePaise: l.newSalePricePaise ?? l.currentSalePaise, ...(barcode !== undefined ? { code13: barcode } : {}), } })) setLines([]); setInvoiceNo(''); setPrintedTotal('') localStorage.removeItem(DRAFT_KEY) getLastCosts(supplier.id).then(setLastCost).catch(() => undefined) }) .catch((err: Error) => say('err', `${err.message} — nothing was posted`)) .finally(() => setBusy(false)) } useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'F12' || (e.ctrlKey && e.key === 'Enter')) { e.preventDefault(); post() } if (e.key === 'Escape') { setFocusCell(undefined); setNotice(undefined) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }) const supplierMatches = supplierQuery === '' ? [] : suppliers.filter((s) => s.name.toLowerCase().includes(supplierQuery.toLowerCase())).slice(0, 6) return (
{busy ? 'Posting…' : 'Post purchase'}} />
{supplier === undefined ? ( <> setSupplierQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && supplierMatches[0] !== undefined) { setSupplier(supplierMatches[0]); setSupplierQuery('') } }} /> {supplierMatches.length > 0 && (
{supplierMatches.map((s) => (
{ setSupplier(s); setSupplierQuery('') }}>{s.name}
))}
)} ) : ( {supplier.name} ✕ )} {supplier !== undefined && ( )}
setInvoiceNo(e.target.value)} /> setInvoiceDate(e.target.value)} />
setEntry(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleEntry() } }} /> {notice !== undefined && {notice.text}} {labelQueue !== undefined && ( Print labels for {labelQueue.length} received item(s)?{' '} {' '} )} {draft !== undefined && ( Unfinished entry found — {draft.invoiceNo !== '' ? draft.invoiceNo : 'no invoice no'} with {draft.lines.length} line(s).{' '} {' '} )} {focusCell !== undefined && lines[focusCell.line] !== undefined && (() => { const l = lines[focusCell.line]! const item = items.find((i) => i.id === l.itemId) const margin = l.unitCostPaise > 0 ? ((l.currentSalePaise - l.unitCostPaise) / l.unitCostPaise) * 100 : 0 return ( {l.name} · stock {item !== undefined ? (stockMap.get(item.code) ?? '—') : '—'} {l.unitCode} · sale {inr(l.currentSalePaise)}{l.currentMrpPaise !== undefined ? ` · MRP ${inr(l.currentMrpPaise)}` : ''} · margin at this cost {margin.toFixed(1)}% {costHist !== undefined && costHist.length > 0 && ( <> · last costs: {costHist.map((h) => `${inr(Number(h.cost))} (${String(h.date)})`).join(' · ')} )} ) })()} {lines.length === 0 ? ( Pick the supplier, then scan the first line of the invoice. ) : ( {lines.map((l, i) => { const lineTaxable = Math.round(l.qty * l.unitCostPaise) const lineTax = Math.round((lineTaxable * l.taxRateBp) / 10_000) const costChanged = l.lastCostPaise !== undefined && l.unitCostPaise !== l.lastCostPaise && l.unitCostPaise > 0 const cell = (kind: 'qty' | 'cost') => focusCell?.line === i && focusCell.cell === kind ? ( { if (e.key !== 'Enter' && e.key !== 'Tab') return e.preventDefault() const v = (e.target as HTMLInputElement).value if (kind === 'qty') { const q = Number(v) if (q > 0) setLine(i, { qty: q }) setFocusCell({ line: i, cell: 'cost' }) } else { setLine(i, { unitCostPaise: rs(v) }) setFocusCell(undefined) // Enter on cost returns to the scan box } }} /> ) : ( setFocusCell({ line: i, cell: kind })}> {kind === 'qty' ? `${l.qty} ${l.unitCode}` : inr(l.unitCostPaise)} ) return ( ) })}
#ItemQtyCost ₹ LastGSTLine totalPrice
{i + 1} {l.name} {cell('qty')} {cell('cost')} {l.lastCostPaise !== undefined ? inr(l.lastCostPaise) : '—'} {l.taxRateBp / 100}% {inr(lineTaxable + lineTax)} {costChanged ? ( setLine(i, p)} /> ) : l.newSalePricePaise !== undefined ? ( new ₹{(l.newSalePricePaise / 100).toFixed(2)} ) : ( keep )}
)} Taxable {formatINR(totals.taxable)} GST {formatINR(totals.tax)} Total {formatINR(totals.total)} setPrintedTotal(e.target.value)} /> {crossCheck === 'match' && totals match ✓} {crossCheck === 'mismatch' && ≠ computed — check a line}
) } /** Inline price-revision prompt when the cost moved — margin at a glance. */ function PriceRevision(props: { line: PLine; onSet: (patch: Partial) => void }) { const { line } = props const [open, setOpen] = useState(false) const [sale, setSale] = useState((line.currentSalePaise / 100).toFixed(2)) const [mrp, setMrp] = useState(line.currentMrpPaise !== undefined ? (line.currentMrpPaise / 100).toFixed(2) : '') const marginBp = line.unitCostPaise > 0 ? Math.round(((line.currentSalePaise - line.unitCostPaise) / line.unitCostPaise) * 10_000) : 0 if (!open) { return ( cost changed · margin {(marginBp / 100).toFixed(1)}%{' '} ) } return ( setSale(e.target.value)} placeholder="Sale ₹" /> setMrp(e.target.value)} placeholder="MRP ₹" /> ) } export function PurchasesListPage() { const [data, setData] = useState[] | undefined>() const [error, setError] = useState() useEffect(() => { getPurchases().then(setData).catch((e: Error) => setError(e.message)) }, []) return (
{error !== undefined && {error}} {data === undefined ? Loading… : data.length === 0 ? ( No purchases yet — enter one in Purchase Entry. ) : ( ({ inv: String(p['invoice_no']), supplier: String(p['supplier_name'] ?? ''), date: String(p['invoice_date']), lines: String(p['line_count']), total: inr(Number(p['total_paise'])), }))} /> )}
) }