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/live.tsx

475 lines
23 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, 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<T>(loader: () => Promise<T>, deps: unknown[] = []): { data?: T; error?: string; reload: () => void } {
const [data, setData] = useState<T | undefined>()
const [error, setError] = useState<string | undefined>()
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<T>(
loader: (limit: number, offset: number) => Promise<T[]>,
deps: unknown[],
): { rows: T[]; error?: string; hasMore: boolean; loading: boolean; loadMore: () => void } {
const [rows, setRows] = useState<T[]>([])
const [error, setError] = useState<string | undefined>()
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 (
<div className="wf-page">
<PageHeader
title="Items" badge="LIVE"
desc="The item master, straight from the store DB. What you add here is billable at the counter immediately."
actions={<Button tone="primary" onClick={() => setCreating(true)}>New item</Button>}
/>
<Toolbar>
<input className="wf" style={{ maxWidth: 280 }} placeholder="Search name / code / barcode…" value={q} onChange={(e) => setQ(e.target.value)} />
<Badge>{data?.length ?? '…'} items</Badge>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data !== undefined && (
<DataTable
columns={[
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' }, { key: 'hsn', label: 'HSN' },
{ key: 'tax', label: 'GST class' }, { key: 'mrp', label: 'MRP', numeric: true },
{ key: 'price', label: 'Price', numeric: true }, { key: 'status', label: 'Status' },
]}
rows={data.map((i) => ({
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: <Badge tone={i['status'] === 'active' ? 'ok' : 'warn'}>{String(i['status'])}</Badge>,
}))}
/>
)}
{creating && <NewItemModal onClose={() => setCreating(false)} onCreated={() => { setCreating(false); reload() }} />}
</div>
)
}
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<string | undefined>()
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 (
<Modal title="New item" onClose={props.onClose}>
<Field label="Code"><input className="wf" value={f.code} onChange={set('code')} /></Field>
<Field label="Name"><input className="wf" value={f.name} onChange={set('name')} /></Field>
<Field label="HSN"><input className="wf" value={f.hsn} onChange={set('hsn')} /></Field>
<Field label="GST class">
<select className="wf" value={f.taxClassCode} onChange={set('taxClassCode')}>
{['ZERO', 'GST5', 'GST12', 'GST18', 'DEMERIT'].map((c) => <option key={c}>{c}</option>)}
</select>
</Field>
<Field label="Unit">
<select className="wf" value={f.unitCode} onChange={set('unitCode')}>
<option>PCS</option><option>KG</option>
</select>
</Field>
<Field label="Sale price (₹, tax-inclusive)"><input className="wf num" value={f.priceRs} onChange={set('priceRs')} /></Field>
<Field label="MRP (₹, optional)"><input className="wf num" value={f.mrpRs} onChange={set('mrpRs')} /></Field>
<Field label="Barcode (optional)"><input className="wf num" value={f.barcode} onChange={set('barcode')} /></Field>
{error !== undefined && <Notice tone="err">{error}</Notice>}
<div style={{ display: 'flex', gap: 8 }}>
<Button tone="primary" onClick={save}>Save item</Button>
<Button onClick={props.onClose}>Cancel</Button>
</div>
</Modal>
)
}
export function CustomersPage() {
const { data, error } = useData(() => getParties('customer'))
return (
<div className="wf-page">
<PageHeader title="Customers" badge="LIVE" desc="Phone-keyed profiles — created here or from the POS customer-attach (F6)." />
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data === undefined ? <EmptyState>Loading</EmptyState> : (
<DataTable
columns={[
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' },
{ key: 'phone', label: 'Phone' }, { key: 'gstin', label: 'GSTIN' },
{ key: 'limit', label: 'Credit limit', numeric: true },
]}
rows={data.map((p) => ({
code: String(p['code']), name: String(p['name']),
phone: p['phone'] !== undefined ? String(p['phone']) : '—',
gstin: p['gstin'] !== undefined ? <Badge tone="accent">{String(p['gstin'])}</Badge> : '—',
limit: p['creditLimitPaise'] !== undefined ? inr(p['creditLimitPaise']) : '—',
}))}
/>
)}
</div>
)
}
/** Build the A4 GST invoice for a bill row and open it in a print-ready window. */
function openInvoice(bill: Record<string, unknown>, 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<Record<string, unknown>>(
(limit, offset) => getBills(date, limit, offset), [date],
)
const { data: seller } = useData(() => getSeller())
const [printErr, setPrintErr] = useState<string | undefined>()
const total = rows.reduce((a, b) => a + Number(b['payable_paise']), 0)
return (
<div className="wf-page">
<PageHeader title="Bills" badge="LIVE" desc="Every committed bill — immutable documents from the counters. Click a row to open its A4 GST invoice." />
<Toolbar>
<input type="date" className="wf" style={{ maxWidth: 170 }} value={date} onChange={(e) => setDate(e.target.value)} />
<Badge>{rows.length}{hasMore ? '+' : ''} bills</Badge>
<Badge tone="accent">{formatINR(total)}</Badge>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
{printErr !== undefined && <Notice tone="warn">{printErr}</Notice>}
{rows.length === 0 && loading ? <EmptyState>Loading</EmptyState>
: rows.length === 0 && error === undefined ? <EmptyState>No bills on {date}. Make one at the POS!</EmptyState> : (
<DataTable
columns={[
{ key: 'no', label: 'Bill No' }, { key: 'time', label: 'Time' }, { key: 'cashier', label: 'Cashier' },
{ key: 'customer', label: 'Customer' }, { key: 'gst', label: 'GST', numeric: true },
{ key: 'amount', label: 'Amount', numeric: true }, { key: 'invoice', label: 'Invoice' },
]}
onRowClick={(_row, i) => 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: <Badge tone="accent">Print A4 </Badge>,
}))}
/>
)}
{hasMore && (
<div style={{ marginTop: 10 }}>
<Button onClick={loadMore}>{loading ? 'Loading…' : 'Load more'}</Button>
</div>
)}
</div>
)
}
/**
* 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<Record<string, unknown>>(
(limit, offset) => getReturns(limit, offset), [],
)
const total = rows.reduce((a, b) => a + Number(b['payable_paise']), 0)
const reasonOf = (b: Record<string, unknown>): string => {
try { return String((JSON.parse(String(b['payload'])) as { reason?: string }).reason ?? '') } catch { return '' }
}
return (
<div className="wf-page">
<PageHeader
title="Returns & Credit Notes" badge="LIVE"
desc="Each credit note reverses an original bill — taxes reverse at the original bill-date rates. Immutable documents from the counters (POS F9)."
/>
<Toolbar>
<Badge>{rows.length}{hasMore ? '+' : ''} credit notes</Badge>
<Badge tone="accent">{formatINR(total)} refunded</Badge>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
{rows.length === 0 && loading ? <EmptyState>Loading</EmptyState>
: rows.length === 0 && error === undefined ? <EmptyState>No credit notes yet process a return at the POS (F9).</EmptyState> : (
<DataTable
columns={[
{ key: 'no', label: 'Credit Note' }, { key: 'against', label: 'Against Bill' },
{ key: 'date', label: 'Date' }, { key: 'cashier', label: 'Cashier' },
{ key: 'customer', label: 'Customer' }, { key: 'reason', label: 'Reason' },
{ key: 'amount', label: 'Refund', numeric: true },
]}
rows={rows.map((b) => ({
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 && (
<div style={{ marginTop: 10 }}>
<Button onClick={loadMore}>{loading ? 'Loading…' : 'Load more'}</Button>
</div>
)}
</div>
)
}
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 (
<div className="wf-page">
<PageHeader title="Stock" badge="LIVE" desc="On-hand derived from movements (invariant I4) — opening stock minus every sale. Batch-tracked items break down by lot below." />
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data === undefined ? <EmptyState>Loading</EmptyState> : (
<DataTable
columns={[
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Item' },
{ key: 'onhand', label: 'On hand', numeric: true }, { key: 'tracked', label: 'Tracking' },
{ key: 'state', label: '' },
]}
rows={data.map((s) => {
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 ? <Badge tone="accent">batch</Badge> : '—',
state: onHand <= 0 ? <Badge tone="err">out</Badge> : onHand < 15 ? <Badge tone="warn">low</Badge> : <Badge tone="ok">ok</Badge>,
}
})}
/>
)}
{batches !== undefined && batches.length > 0 && (
<>
<h3 style={{ margin: '18px 0 8px' }}>Batch-tracked stock</h3>
<DataTable
columns={[
{ key: 'item', label: 'Item' }, { key: 'batch', label: 'Batch' },
{ key: 'expiry', label: 'Expiry' }, { key: 'onhand', label: 'On hand', numeric: true },
{ key: 'value', label: 'Value @ sale', numeric: true },
]}
rows={batches.map((b) => {
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 ? <Badge>no expiry</Badge>
: expired ? <Badge tone="err">{expiry}</Badge> : expiry,
onhand: `${Math.round(onHand * 1000) / 1000} ${String(b['unit_code'])}`,
value: inr(onHand * Number(b['sale_price_paise'])),
}
})}
/>
</>
)}
</div>
)
}
export function AuditPage() {
const { rows, error, hasMore, loading, loadMore } = usePaged<Record<string, unknown>>(
(limit, offset) => getAudit(limit, offset), [],
)
return (
<div className="wf-page">
<PageHeader title="Audit Log" badge="LIVE" desc="Append-only — logins, failed logins, item edits, every bill commit." />
{error !== undefined && <Notice tone="err">{error}</Notice>}
{rows.length === 0 && loading ? <EmptyState>Loading</EmptyState>
: rows.length === 0 && error === undefined ? <EmptyState>No audit entries yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'at', label: 'When' }, { key: 'who', label: 'Who' },
{ key: 'action', label: 'Action' }, { key: 'detail', label: 'Detail' },
]}
rows={rows.map((a) => ({
at: new Date(String(a['at_wall'])).toLocaleString(),
who: String(a['user_name'] ?? a['user_id']),
action: <Badge tone="accent">{String(a['action'])}</Badge>,
// 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 && (
<div style={{ marginTop: 10 }}>
<Button onClick={loadMore}>{loading ? 'Loading…' : 'Load more'}</Button>
</div>
)}
</div>
)
}
/**
* 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 (
<>
<h3 style={{ margin: '18px 0 8px', color: tone !== undefined ? `var(--${tone})` : undefined }}>{title}</h3>
<DataTable
columns={[
{ key: 'item', label: 'Item' }, { key: 'batch', label: 'Batch' },
{ key: 'expiry', label: 'Expiry' }, { key: 'qty', label: 'On hand', numeric: true },
{ key: 'value', label: 'Value @ sale', numeric: true },
]}
rows={rows.map((r) => ({
item: r.name, batch: r.batchNo,
expiry: r.expiry === undefined ? '—'
: tone === 'err' ? <Badge tone="err">{r.expiry}</Badge>
: tone === 'warn' ? <Badge tone="warn">{r.expiry}</Badge>
: 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 (
<div className="wf-page">
<PageHeader title="Expiry Dashboard" badge="LIVE" desc="Near-expiry and expired stock by batch — act before it becomes wastage. Value is on-hand × current sale price." />
{error !== undefined && <Notice tone="err">{error}</Notice>}
{data === undefined ? <EmptyState>Loading</EmptyState> : rows.length === 0 ? (
<EmptyState>No batch-tracked stock yet receive a batch in Purchase Entry.</EmptyState>
) : (
<>
<Stats>
<StatCard label="Expiring ≤ 7 days" value={formatINR(sum(le7))} hint={`${le7.length} batch${le7.length === 1 ? '' : 'es'}`} />
<StatCard label="Expiring ≤ 30 days" value={formatINR(sum(le30))} hint={`${le30.length} batch${le30.length === 1 ? '' : 'es'}`} />
<StatCard label="Expired — with stock" value={formatINR(sum(expired))} hint={`${expired.length} batch${expired.length === 1 ? '' : 'es'}`} />
</Stats>
{bucketTable('Expired — clear or write off', 'err', expired)}
{bucketTable('Expiring within 7 days', 'warn', le7)}
{bucketTable('Expiring within 30 days', undefined, le30)}
<div style={{ marginTop: 18 }}>
<Notice tone="warn">
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.
</Notice>
</div>
</>
)}
</div>
)
}