import { useEffect, useState } from 'react' import { formatINR, type BillLine, type BillTotals } from '@sims/domain' import { renderInvoiceHtml, type InvoiceDoc } from '@sims/printing' import { Badge, Button, DataTable, EmptyState, Field, Modal, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui' import { createItem, getAudit, getBills, getExpiry, getItems, getParties, getReturns, getSeller, getStock, type Seller } from '../api' /** The first live pages — real data from the store-server (the slice). */ const inr = (p: unknown) => formatINR(Number(p), { symbol: false }) function useData(loader: () => Promise, deps: unknown[] = []): { data?: T; error?: string; reload: () => void } { const [data, setData] = useState() const [error, setError] = useState() const reload = () => { setError(undefined) loader().then(setData).catch((e: Error) => setError(e.message)) } // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(reload, deps) return { data, error, reload } } const PAGE = 100 /** Offset pagination with an explicit "Load more" — R13: a cap the operator can lift, never silent. */ function usePaged( loader: (limit: number, offset: number) => Promise, deps: unknown[], ): { rows: T[]; error?: string; hasMore: boolean; loading: boolean; loadMore: () => void } { const [rows, setRows] = useState([]) const [error, setError] = useState() const [hasMore, setHasMore] = useState(false) const [loading, setLoading] = useState(false) const load = (offset: number) => { setLoading(true) setError(undefined) loader(PAGE, offset) .then((page) => { setRows((prev) => (offset === 0 ? page : [...prev, ...page])) setHasMore(page.length === PAGE) }) .catch((e: Error) => setError(e.message)) .finally(() => setLoading(false)) } // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { setRows([]); load(0) }, deps) return { rows, error, hasMore, loading, loadMore: () => load(rows.length) } } export function ItemsPage() { const [q, setQ] = useState('') const { data, error, reload } = useData(() => getItems(q), [q]) const [creating, setCreating] = useState(false) return (
setCreating(true)}>New item} /> setQ(e.target.value)} /> {data?.length ?? '…'} items {error !== undefined && {error}} {data !== undefined && ( ({ code: String(i['code']), name: String(i['name']), hsn: String(i['hsn']), tax: String(i['taxClassCode']), mrp: i['mrpPaise'] !== undefined ? inr(i['mrpPaise']) : '—', price: `${inr(i['salePricePaise'])}${(i['unit'] as { code: string }).code === 'KG' ? '/kg' : ''}`, status: {String(i['status'])}, }))} /> )} {creating && setCreating(false)} onCreated={() => { setCreating(false); reload() }} />}
) } function NewItemModal(props: { onClose: () => void; onCreated: () => void }) { const [f, setF] = useState({ code: '', name: '', hsn: '', taxClassCode: 'GST18', unitCode: 'PCS', priceRs: '', mrpRs: '', barcode: '' }) const [error, setError] = useState() const set = (k: keyof typeof f) => (e: { target: { value: string } }) => setF((p) => ({ ...p, [k]: e.target.value })) const save = () => { createItem({ code: f.code, name: f.name, hsn: f.hsn, taxClassCode: f.taxClassCode, unitCode: f.unitCode, unitDecimals: f.unitCode === 'KG' ? 3 : 0, salePricePaise: Math.round(Number(f.priceRs) * 100), ...(f.mrpRs !== '' ? { mrpPaise: Math.round(Number(f.mrpRs) * 100) } : {}), ...(f.barcode !== '' ? { barcode: f.barcode } : {}), }).then(props.onCreated).catch((e: Error) => setError(e.message)) } return ( {error !== undefined && {error}}
) } export function CustomersPage() { const { data, error } = useData(() => getParties('customer')) return (
{error !== undefined && {error}} {data === undefined ? Loading… : ( ({ code: String(p['code']), name: String(p['name']), phone: p['phone'] !== undefined ? String(p['phone']) : '—', gstin: p['gstin'] !== undefined ? {String(p['gstin'])} : '—', limit: p['creditLimitPaise'] !== undefined ? inr(p['creditLimitPaise']) : '—', }))} /> )}
) } /** Build the A4 GST invoice for a bill row and open it in a print-ready window. */ function openInvoice(bill: Record, seller: Seller | undefined): string | undefined { let payload: { lines: BillLine[]; totals: BillTotals; payments?: { mode: string }[] } try { payload = JSON.parse(String(bill['payload'])) as typeof payload } catch { return 'This bill has no stored line detail to invoice.' } // The B2B buyer + place of supply are snapshotted on the bill at commit (S3), // so the invoice reads them from the document, not from today's customer row. const buyerGstin = bill['buyer_gstin'] !== null && bill['buyer_gstin'] !== undefined ? String(bill['buyer_gstin']) : undefined const placeOfSupply = bill['place_of_supply'] !== null && bill['place_of_supply'] !== undefined ? String(bill['place_of_supply']) : undefined const posState = placeOfSupply ?? seller?.stateCode const doc: InvoiceDoc = { seller: { name: seller?.name ?? 'Store', ...(seller?.gstin !== undefined ? { gstin: seller.gstin } : {}), ...(seller?.stateCode !== undefined ? { stateCode: seller.stateCode } : {}), }, ...(bill['customer_name'] !== null && bill['customer_name'] !== undefined ? { buyer: { name: String(bill['customer_name']), ...(buyerGstin !== undefined ? { gstin: buyerGstin } : {}), ...(placeOfSupply !== undefined ? { stateCode: placeOfSupply } : {}), }, } : {}), invoiceNo: String(bill['doc_no']), invoiceDate: String(bill['business_date']), ...(posState !== undefined ? { placeOfSupplyStateCode: posState } : {}), lines: payload.lines, totals: payload.totals, ...(payload.payments?.[0]?.mode !== undefined ? { paymentLabel: payload.payments[0].mode } : {}), } // Serve the self-contained HTML as a blob URL — no document.write, and the new // window loads it as a real document (so its own Print A4 button works). const url = URL.createObjectURL(new Blob([renderInvoiceHtml(doc)], { type: 'text/html' })) const win = window.open(url, '_blank') if (win === null) { URL.revokeObjectURL(url); return 'Allow pop-ups to open the invoice (it prints in a new tab).' } setTimeout(() => URL.revokeObjectURL(url), 60_000) return undefined } export function BillsPage() { const [date, setDate] = useState(new Date().toISOString().slice(0, 10)) const { rows, error, hasMore, loading, loadMore } = usePaged>( (limit, offset) => getBills(date, limit, offset), [date], ) const { data: seller } = useData(() => getSeller()) const [printErr, setPrintErr] = useState() const total = rows.reduce((a, b) => a + Number(b['payable_paise']), 0) return (
setDate(e.target.value)} /> {rows.length}{hasMore ? '+' : ''} bills {formatINR(total)} {error !== undefined && {error}} {printErr !== undefined && {printErr}} {rows.length === 0 && loading ? Loading… : rows.length === 0 && error === undefined ? No bills on {date}. Make one at the POS! : ( setPrintErr(openInvoice(rows[i]!, seller))} rows={rows.map((b) => ({ no: String(b['doc_no']), time: new Date(String(b['created_at_wall'])).toLocaleTimeString(), cashier: String(b['cashier_name'] ?? ''), customer: b['customer_name'] !== null ? String(b['customer_name']) : 'walk-in', gst: inr(Number(b['cgst_paise']) + Number(b['sgst_paise']) + Number(b['igst_paise']) + Number(b['cess_paise'])), amount: inr(b['payable_paise']), invoice: Print A4 ↗, }))} /> )} {hasMore && (
)}
) } /** * Sales → Returns & Credit Notes — LIVE. Every SALE_RETURN document, tenant-scoped and * paginated (R13). A credit note references its original bill (Against Bill) and reverses tax * at the original bill-date rates; the refund is the immutable document's payable. Replaces the * registry wireframe via the CUSTOM map. */ export function ReturnsPage() { const { rows, error, hasMore, loading, loadMore } = usePaged>( (limit, offset) => getReturns(limit, offset), [], ) const total = rows.reduce((a, b) => a + Number(b['payable_paise']), 0) const reasonOf = (b: Record): string => { try { return String((JSON.parse(String(b['payload'])) as { reason?: string }).reason ?? '') } catch { return '' } } return (
{rows.length}{hasMore ? '+' : ''} credit notes {formatINR(total)} refunded {error !== undefined && {error}} {rows.length === 0 && loading ? Loading… : rows.length === 0 && error === undefined ? No credit notes yet — process a return at the POS (F9). : ( ({ no: String(b['doc_no']), against: b['original_doc_no'] !== null && b['original_doc_no'] !== undefined ? String(b['original_doc_no']) : '—', date: new Date(String(b['created_at_wall'])).toLocaleString(), cashier: String(b['cashier_name'] ?? ''), customer: b['customer_name'] !== null && b['customer_name'] !== undefined ? String(b['customer_name']) : 'walk-in', reason: reasonOf(b), amount: inr(b['payable_paise']), }))} /> )} {hasMore && (
)}
) } export function StockPage() { const { data, error } = useData(() => getStock()) // S5: the per-batch breakdown for batch-tracked items (a second table, kept simple). const { data: batches } = useData(() => getExpiry()) const today = new Date().toISOString().slice(0, 10) return (
{error !== undefined && {error}} {data === undefined ? Loading… : ( { const onHand = Number(s['on_hand']) return { code: String(s['code']), name: String(s['name']), onhand: `${Math.round(onHand * 1000) / 1000} ${String(s['unit_code'])}`, tracked: Number(s['batch_tracked']) === 1 ? batch : '—', state: onHand <= 0 ? out : onHand < 15 ? low : ok, } })} /> )} {batches !== undefined && batches.length > 0 && ( <>

Batch-tracked stock

{ const onHand = Number(b['on_hand']) const expiry = b['expiry_date'] !== null ? String(b['expiry_date']) : undefined const expired = expiry !== undefined && expiry < today return { item: String(b['name']), batch: String(b['batch_no']), expiry: expiry === undefined ? no expiry : expired ? {expiry} : expiry, onhand: `${Math.round(onHand * 1000) / 1000} ${String(b['unit_code'])}`, value: inr(onHand * Number(b['sale_price_paise'])), } })} /> )}
) } export function AuditPage() { const { rows, error, hasMore, loading, loadMore } = usePaged>( (limit, offset) => getAudit(limit, offset), [], ) return (
{error !== undefined && {error}} {rows.length === 0 && loading ? Loading… : rows.length === 0 && error === undefined ? No audit entries yet. : ( ({ at: new Date(String(a['at_wall'])).toLocaleString(), who: String(a['user_name'] ?? a['user_id']), action: {String(a['action'])}, // Override rows carry a before_json (who approved what change from what) — // show before → after so PRICE/QTY/DISCOUNT overrides read at a glance. detail: `${String(a['entity'])} ${a['before_json'] !== null ? `${String(a['before_json']).slice(0, 60)} → ` : ''}${a['after_json'] !== null ? String(a['after_json']).slice(0, 90) : ''}`, }))} /> )} {hasMore && (
)}
) } /** * S5 Expiry Dashboard — live. Every in-stock batch, bucketed by the working date: * expired-with-stock, expiring ≤7 days, ≤30 days. Per-batch on-hand is derived from * movements (R7); value is on-hand × the item's current sale price. Replaces the * registry wireframe via the CUSTOM map. */ interface ExpiryRow { id: string; code: string; name: string; batchNo: string; expiry?: string; onHand: number; unit: string; value: number } function bucketTable(title: string, tone: 'err' | 'warn' | undefined, rows: ExpiryRow[]) { if (rows.length === 0) return null return ( <>

{title}

({ item: r.name, batch: r.batchNo, expiry: r.expiry === undefined ? '—' : tone === 'err' ? {r.expiry} : tone === 'warn' ? {r.expiry} : r.expiry, qty: `${Math.round(r.onHand * 1000) / 1000} ${r.unit}`, value: inr(r.value), }))} /> ) } export function ExpiryPage() { const { data, error } = useData(() => getExpiry()) const today = new Date().toISOString().slice(0, 10) const plusDays = (n: number): string => { const d = new Date() d.setDate(d.getDate() + n) return d.toISOString().slice(0, 10) } const in7 = plusDays(7) const in30 = plusDays(30) const rows: ExpiryRow[] = (data ?? []).map((r) => ({ id: String(r['id']), code: String(r['code']), name: String(r['name']), batchNo: String(r['batch_no']), expiry: r['expiry_date'] !== null && r['expiry_date'] !== undefined ? String(r['expiry_date']) : undefined, onHand: Number(r['on_hand']), unit: String(r['unit_code']), value: Number(r['on_hand']) * Number(r['sale_price_paise']), })) const dated = rows.filter((r) => r.expiry !== undefined) const expired = dated.filter((r) => r.expiry! < today) const le7 = dated.filter((r) => r.expiry! >= today && r.expiry! <= in7) const le30 = dated.filter((r) => r.expiry! > in7 && r.expiry! <= in30) const far = rows.filter((r) => r.expiry === undefined || r.expiry > in30) const sum = (list: ExpiryRow[]): number => list.reduce((a, r) => a + r.value, 0) return (
{error !== undefined && {error}} {data === undefined ? Loading… : rows.length === 0 ? ( No batch-tracked stock yet — receive a batch in Purchase Entry. ) : ( <> {bucketTable('Expired — clear or write off', 'err', expired)} {bucketTable('Expiring within 7 days', 'warn', le7)} {bucketTable('Expiring within 30 days', undefined, le30)}
Dead-stock: {far.length} batch{far.length === 1 ? '' : 'es'} carry {formatINR(sum(far))} of stock dated beyond 30 days. Slow-mover ranking by sales velocity is a follow-up — this view flags by expiry today.
)}
) }