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