You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/backoffice/src/pages/purchase-entry.tsx

470 lines
21 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 50200 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<ItemLite[]>([])
const [taxClasses, setTaxClasses] = useState<TaxClassRow[]>([])
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<Record<string, { last?: number; supplierLast?: number }>>({})
const [invoiceNo, setInvoiceNo] = useState('')
const [invoiceDate, setInvoiceDate] = useState(today)
const [lines, setLines] = useState<PLine[]>([])
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<LabelInput[] | undefined>()
/** 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<HTMLInputElement>(null)
const cellRef = useRef<HTMLInputElement>(null)
const [stockMap, setStockMap] = useState<Map<string, number>>(new Map())
const [draft, setDraft] = useState<Draft | undefined>()
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<PLine>) =>
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 (
<div className="wf-page">
<PageHeader
title="Purchase Entry" badge="LIVE"
desc="Scan or type item → qty → cost → back to scan. Last cost prefills; cost changes offer a price revision; F12 posts."
actions={<Button tone="primary" onClick={post} hotkey="F12">{busy ? 'Posting…' : 'Post purchase'}</Button>}
/>
<Toolbar>
<div style={{ position: 'relative', minWidth: 260 }}>
{supplier === undefined ? (
<>
<input
className="wf" placeholder="Supplier… (type to search)"
value={supplierQuery} onChange={(e) => setSupplierQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && supplierMatches[0] !== undefined) {
setSupplier(supplierMatches[0]); setSupplierQuery('')
}
}}
/>
{supplierMatches.length > 0 && (
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 10 }} className="wf-modal">
{supplierMatches.map((s) => (
<div key={s.id} style={{ padding: '6px 8px', cursor: 'pointer' }}
onClick={() => { setSupplier(s); setSupplierQuery('') }}>{s.name}</div>
))}
</div>
)}
</>
) : (
<Badge tone="accent">{supplier.name} </Badge>
)}
{supplier !== undefined && (
<Button onClick={() => setSupplier(undefined)}>Change</Button>
)}
</div>
<input className="wf" style={{ maxWidth: 160 }} placeholder="Invoice no" value={invoiceNo} onChange={(e) => setInvoiceNo(e.target.value)} />
<input type="date" className="wf" style={{ maxWidth: 160 }} value={invoiceDate} onChange={(e) => setInvoiceDate(e.target.value)} />
</Toolbar>
<input
ref={entryRef}
className="wf scanbox" style={{ fontSize: 17, fontFamily: 'var(--mono)', marginBottom: 8 }}
placeholder="Scan barcode · item code · name · 24*surf for quantity"
value={entry}
onChange={(e) => setEntry(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleEntry() } }}
/>
{notice !== undefined && <Notice tone={notice.tone}>{notice.text}</Notice>}
{labelQueue !== undefined && (
<Notice tone="ok">
Print labels for {labelQueue.length} received item(s)?{' '}
<Button tone="primary" onClick={() => { const err = openLabelSheet(labelQueue); if (err !== undefined) say('warn', err) }}>Print labels </Button>{' '}
<Button onClick={() => setLabelQueue(undefined)}>Dismiss</Button>
</Notice>
)}
{draft !== undefined && (
<Notice tone="warn">
Unfinished entry found <b>{draft.invoiceNo !== '' ? draft.invoiceNo : 'no invoice no'}</b> with {draft.lines.length} line(s).{' '}
<Button tone="primary" onClick={() => {
setLines(draft.lines)
setInvoiceNo(draft.invoiceNo)
setInvoiceDate(draft.invoiceDate)
if (draft.supplier !== undefined) setSupplier(draft.supplier)
setDraft(undefined)
}}>Resume</Button>{' '}
<Button onClick={() => { localStorage.removeItem(DRAFT_KEY); setDraft(undefined) }}>Discard</Button>
</Notice>
)}
{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 (
<Notice>
<b>{l.name}</b> · stock {item !== undefined ? (stockMap.get(item.code) ?? '—') : '—'} {l.unitCode}
· sale {inr(l.currentSalePaise)}{l.currentMrpPaise !== undefined ? ` · MRP ${inr(l.currentMrpPaise)}` : ''}
· margin at this cost <b>{margin.toFixed(1)}%</b>
{costHist !== undefined && costHist.length > 0 && (
<> · last costs: {costHist.map((h) => `${inr(Number(h.cost))} (${String(h.date)})`).join(' · ')}</>
)}
</Notice>
)
})()}
{lines.length === 0 ? (
<EmptyState>Pick the supplier, then scan the first line of the invoice.</EmptyState>
) : (
<table className="wf">
<thead>
<tr>
<th>#</th><th>Item</th><th className="num">Qty</th><th className="num">Cost </th>
<th className="num">Last</th><th className="num">GST</th><th className="num">Line total</th><th>Price</th>
</tr>
</thead>
<tbody>
{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 ? (
<input
ref={cellRef}
className="wf num" style={{ width: 90 }}
defaultValue={kind === 'qty' ? String(l.qty) : (l.unitCostPaise / 100).toFixed(2)}
onKeyDown={(e) => {
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
}
}}
/>
) : (
<span style={{ cursor: 'pointer' }} onClick={() => setFocusCell({ line: i, cell: kind })}>
{kind === 'qty' ? `${l.qty} ${l.unitCode}` : inr(l.unitCostPaise)}
</span>
)
return (
<tr key={i}>
<td>{i + 1}</td>
<td>{l.name}</td>
<td className="num">{cell('qty')}</td>
<td className="num">{cell('cost')}</td>
<td className="num">{l.lastCostPaise !== undefined ? inr(l.lastCostPaise) : '—'}</td>
<td className="num">{l.taxRateBp / 100}%</td>
<td className="num">{inr(lineTaxable + lineTax)}</td>
<td>
{costChanged ? (
<PriceRevision line={l} onSet={(p) => setLine(i, p)} />
) : l.newSalePricePaise !== undefined ? (
<Badge tone="ok">new {(l.newSalePricePaise / 100).toFixed(2)}</Badge>
) : (
<Badge>keep</Badge>
)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
<Toolbar>
<Badge>Taxable {formatINR(totals.taxable)}</Badge>
<Badge>GST {formatINR(totals.tax)}</Badge>
<Badge tone="accent">Total {formatINR(totals.total)}</Badge>
<span className="spacer" />
<Field label="Supplier's printed total ₹ (cross-check)">
<input className="wf num" style={{ width: 150 }} value={printedTotal} onChange={(e) => setPrintedTotal(e.target.value)} />
</Field>
{crossCheck === 'match' && <Badge tone="ok">totals match </Badge>}
{crossCheck === 'mismatch' && <Badge tone="err"> computed check a line</Badge>}
</Toolbar>
</div>
)
}
/** Inline price-revision prompt when the cost moved — margin at a glance. */
function PriceRevision(props: { line: PLine; onSet: (patch: Partial<PLine>) => 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 (
<span>
<Badge tone="warn">cost changed · margin {(marginBp / 100).toFixed(1)}%</Badge>{' '}
<Button onClick={() => setOpen(true)}>Revise price</Button>
</span>
)
}
return (
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
<input className="wf num" style={{ width: 84 }} value={sale} onChange={(e) => setSale(e.target.value)} placeholder="Sale ₹" />
<input className="wf num" style={{ width: 84 }} value={mrp} onChange={(e) => setMrp(e.target.value)} placeholder="MRP ₹" />
<Button tone="primary" onClick={() => {
props.onSet({
newSalePricePaise: rs(sale),
...(mrp !== '' ? { newMrpPaise: rs(mrp) } : {}),
})
setOpen(false)
}}>Set</Button>
</span>
)
}
export function PurchasesListPage() {
const [data, setData] = useState<Record<string, unknown>[] | undefined>()
const [error, setError] = useState<string | undefined>()
useEffect(() => {
getPurchases().then(setData).catch((e: Error) => setError(e.message))
}, [])
return (
<div className="wf-page">
<PageHeader title="Purchase List" badge="LIVE" desc="Every posted supplier invoice — duplicates are rejected at entry." />
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data === undefined ? <EmptyState>Loading</EmptyState> : data.length === 0 ? (
<EmptyState>No purchases yet enter one in Purchase Entry.</EmptyState>
) : (
<DataTable
columns={[
{ key: 'inv', label: 'Invoice' }, { key: 'supplier', label: 'Supplier' },
{ key: 'date', label: 'Invoice date' }, { key: 'lines', label: 'Lines', numeric: true },
{ key: 'total', label: 'Total', numeric: true },
]}
rows={data.map((p) => ({
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'])),
}))}
/>
)}
</div>
)
}