|
|
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<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>
|
|
|
)
|
|
|
}
|