Fe changes

This commit is contained in:
2026-10-16 15:47:23 +05:30
parent 09d86835a2
commit c5a0992b5d
23 changed files with 2715 additions and 89 deletions
@@ -3,12 +3,14 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
import { ExportButtons } from '@/components/shared/export-buttons'
import type { TableConfig } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast'
import { getApiError, getPaginatedItems } from '@/lib/api/index'
import { exportExcel, exportPdf, fetchAllPages, type ExportColumn } from '@/lib/export'
import {
productReports,
type ProductReportSummary,
@@ -71,6 +73,44 @@ const returnsColumns: TableConfig<ProductReturnRow>['columns'] = [
},
]
const STOCK_EXPORT: ExportColumn<ProductStockRow>[] = [
{ header: 'SKU', value: r => r.sku },
{ header: 'Item', value: r => r.name },
{ header: 'Brand', value: r => r.brand ?? '' },
{ header: 'Category', value: r => r.category },
{ header: 'UOM', value: r => r.uom },
{ header: 'Warehouse', value: r => r.warehouseQty, numeric: true },
{ header: 'Vehicles', value: r => r.vehicleQty, numeric: true },
{ header: 'Total', value: r => r.totalQty, numeric: true },
{ header: 'Stock Value', value: r => r.stockValue, numeric: true },
{ header: 'Low Stock', value: r => (r.isLowStock ? 'Yes' : 'No') },
]
const SALES_EXPORT: ExportColumn<ProductSalesRow>[] = [
{ header: 'SKU', value: r => r.sku },
{ header: 'Item', value: r => r.name },
{ header: 'Orders', value: r => r.orders, numeric: true },
{ header: 'Stores', value: r => r.stores, numeric: true },
{ header: 'Qty Ordered', value: r => r.qtyOrdered, numeric: true },
{ header: 'Qty Fulfilled', value: r => r.qtyFulfilled, numeric: true },
{ header: 'Sales', value: r => r.salesValue, numeric: true },
{ header: 'Margin', value: r => r.margin, numeric: true },
{ header: 'Margin %', value: r => r.marginPercent, numeric: true },
]
const RETURNS_EXPORT: ExportColumn<ProductReturnRow>[] = [
{ header: 'SKU', value: r => r.sku },
{ header: 'Item', value: r => r.name },
{ header: 'Returned Qty', value: r => r.returnedQty, numeric: true },
{ header: 'Returned Value', value: r => r.returnedValue, numeric: true },
{ header: 'Sold Qty', value: r => r.soldQty, numeric: true },
{ header: 'Return %', value: r => r.returnPercent, numeric: true },
{
header: 'By Reason',
value: r => Object.entries(r.qtyByReason).map(([reason, qty]) => `${reason}: ${num(qty)}`).join(', '),
},
]
export function ProductReportClient() {
const [tab, setTab] = useState<Tab>('stock')
const [search, setSearch] = useState('')
@@ -128,6 +168,30 @@ export function ProductReportClient() {
setTotal(0)
}
async function handleExport(format: 'excel' | 'pdf') {
const filters = (p: number, s: number) => ({
page: p, pageSize: s, search: search || undefined, from: from || undefined, to: to || undefined,
})
const range = `${from || 'start'} to ${to || 'today'}`
if (tab === 'stock') {
const rows = await fetchAllPages((p, s) =>
productReports.stock({ ...filters(p, s), lowStockOnly }).then(r => ({ items: getPaginatedItems(r), total: r.total })))
if (format === 'excel') await exportExcel('product-stock', STOCK_EXPORT, rows)
else await exportPdf('product-stock', 'Product Report: Stock', STOCK_EXPORT, rows, lowStockOnly ? 'Low stock only' : undefined)
} else if (tab === 'sales') {
const rows = await fetchAllPages((p, s) =>
productReports.sales(filters(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total })))
if (format === 'excel') await exportExcel('product-sales', SALES_EXPORT, rows)
else await exportPdf('product-sales', 'Product Report: Sales', SALES_EXPORT, rows, range)
} else {
const rows = await fetchAllPages((p, s) =>
productReports.returns(filters(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total })))
if (format === 'excel') await exportExcel('product-returns', RETURNS_EXPORT, rows)
else await exportPdf('product-returns', 'Product Report: Returns', RETURNS_EXPORT, rows, range)
}
}
const common = { serverSide: true, stickyHeader: true, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] } }
const stockConfig = useMemo<TableConfig<ProductStockRow>>(
@@ -197,6 +261,9 @@ export function ProductReportClient() {
Low stock only
</Button>
)}
<div className="ml-auto flex items-center gap-2">
<ExportButtons onExport={handleExport} disabled={loading || total === 0} />
</div>
</div>
{tab === 'stock' && (
@@ -0,0 +1,331 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
import { ExportButtons } from '@/components/shared/export-buttons'
import type { TableConfig } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { getApiError, getPaginatedItems } from '@/lib/api/index'
import { exportExcel, exportPdf, fetchAllPages, type ExportColumn } from '@/lib/export'
import { categories } from '@/lib/api/catalogue'
import { vehicles, warehouses } from '@/lib/api/master'
import {
stockReports,
type StockExpiryRow,
type StockLedgerRow,
type StockOverview,
} from '@/lib/api/reports'
type Tab = 'ledger' | 'expiry'
const TABS: { key: Tab; label: string }[] = [
{ key: 'ledger', label: 'Stock Ledger' },
{ key: 'expiry', label: 'Expiry' },
]
const ALL = 'all'
const EXPIRY_WINDOWS = [7, 30, 60, 90]
const num = (v: number | null | undefined) =>
v == null ? '—' : v.toLocaleString('en-IN', { maximumFractionDigits: 2 })
const fmtDate = (v: string) => new Date(v).toLocaleDateString('en-GB')
const STATUS_CLASS: Record<string, string> = {
OK: 'text-emerald-600',
Low: 'text-amber-600',
Out: 'text-red-600',
}
const ledgerColumns: TableConfig<StockLedgerRow>['columns'] = [
{ key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 },
{ key: 'category', title: 'Category', fields: ['category'], width: 140 },
{ key: 'uom', title: 'UOM', fields: ['uom'], width: 70 },
{ key: 'openingQty', title: 'Opening', fields: [{ key: 'openingQty', transform: v => num(v as number) }], width: 100 },
{ key: 'qtyIn', title: 'In', fields: [{ key: 'qtyIn', transform: v => num(v as number) }], width: 90 },
{ key: 'qtyOut', title: 'Out', fields: [{ key: 'qtyOut', transform: v => num(v as number) }], width: 90 },
{ key: 'closingQty', title: 'Closing', fields: [{ key: 'closingQty', transform: v => num(v as number) }], width: 100 },
{ key: 'currentQty', title: 'On Hand', fields: [{ key: 'currentQty', transform: v => num(v as number) }], width: 100 },
{ key: 'stockValue', title: 'Value', fields: [{ key: 'stockValue', transform: v => num(v as number) }], width: 110 },
{
key: 'status',
title: 'Status',
fields: ['status'],
width: 90,
cellRender: row => <span className={`text-sm font-medium ${STATUS_CLASS[row.status] ?? ''}`}>{row.status}</span>,
},
]
const expiryColumns: TableConfig<StockExpiryRow>['columns'] = [
{ key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 },
{ key: 'batchNumber', title: 'Batch', fields: [{ key: 'batchNumber', transform: v => (v as string | null) ?? '—' }], width: 130 },
{ key: 'locationName', title: 'Location', fields: [{ key: 'locationName', primary: true }, { key: 'locationType' }], width: 150 },
{ key: 'quantity', title: 'Qty', fields: [{ key: 'quantity', transform: v => num(v as number) }], width: 90 },
{ key: 'value', title: 'Value', fields: [{ key: 'value', transform: v => num(v as number) }], width: 110 },
{ key: 'expiryDate', title: 'Expiry', fields: [{ key: 'expiryDate', transform: v => fmtDate(v as string) }], width: 110 },
{
key: 'daysLeft',
title: 'Days Left',
fields: ['daysLeft'],
width: 110,
cellRender: row => (
<span className={`text-sm font-medium ${row.daysLeft < 0 ? 'text-red-600' : row.daysLeft <= 7 ? 'text-amber-600' : ''}`}>
{row.daysLeft < 0 ? `Expired ${-row.daysLeft}d ago` : `${row.daysLeft}d`}
</span>
),
},
]
const LEDGER_EXPORT: ExportColumn<StockLedgerRow>[] = [
{ header: 'SKU', value: r => r.sku },
{ header: 'Item', value: r => r.name },
{ header: 'Category', value: r => r.category },
{ header: 'UOM', value: r => r.uom },
{ header: 'Opening', value: r => r.openingQty, numeric: true },
{ header: 'In', value: r => r.qtyIn, numeric: true },
{ header: 'Out', value: r => r.qtyOut, numeric: true },
{ header: 'Closing', value: r => r.closingQty, numeric: true },
{ header: 'On Hand', value: r => r.currentQty, numeric: true },
{ header: 'Value', value: r => r.stockValue, numeric: true },
{ header: 'Status', value: r => r.status },
]
const EXPIRY_EXPORT: ExportColumn<StockExpiryRow>[] = [
{ header: 'SKU', value: r => r.sku },
{ header: 'Item', value: r => r.name },
{ header: 'Batch', value: r => r.batchNumber ?? '' },
{ header: 'Location', value: r => r.locationName },
{ header: 'Qty', value: r => r.quantity, numeric: true },
{ header: 'Value', value: r => r.value, numeric: true },
{ header: 'Expiry', value: r => fmtDate(r.expiryDate) },
{ header: 'Days Left', value: r => r.daysLeft, numeric: true },
]
export function StockReportClient() {
const [tab, setTab] = useState<Tab>('ledger')
const [search, setSearch] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [locationId, setLocationId] = useState(ALL)
const [categoryId, setCategoryId] = useState(ALL)
const [status, setStatus] = useState(ALL)
const [expiryDays, setExpiryDays] = useState(30)
const [locationOptions, setLocationOptions] = useState<{ id: string; label: string }[]>([])
const [categoryOptions, setCategoryOptions] = useState<{ id: string; name: string }[]>([])
const [overview, setOverview] = useState<StockOverview | null>(null)
const [ledgerData, setLedgerData] = useState<StockLedgerRow[]>([])
const [expiryData, setExpiryData] = useState<StockExpiryRow[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
useEffect(() => {
Promise.all([warehouses.list({ pageSize: 200 }), vehicles.list({ pageSize: 200 }), categories.list()])
.then(([w, v, c]) => {
setLocationOptions([
...getPaginatedItems(w).map(x => ({ id: x.id, label: `Warehouse · ${x.name}` })),
...getPaginatedItems(v).map(x => ({ id: x.id, label: `Vehicle · ${x.numberPlate}` })),
])
setCategoryOptions(c.map(x => ({ id: x.id, name: x.name })))
})
.catch(err => toast.add({ title: 'Failed to load filters', description: getApiError(err), type: 'error' }))
}, [])
const location = locationId === ALL ? undefined : locationId
const ledgerParams = useCallback(
(p: number, s: number) => ({
page: p,
pageSize: s,
search: search || undefined,
locationId: location,
from: from || undefined,
to: to || undefined,
categoryId: categoryId === ALL ? undefined : categoryId,
status: status === ALL ? undefined : (status as 'low' | 'out'),
}),
[search, location, from, to, categoryId, status],
)
const expiryParams = useCallback(
(p: number, s: number) => ({ page: p, pageSize: s, search: search || undefined, locationId: location, days: expiryDays }),
[search, location, expiryDays],
)
const fetchData = useCallback(async () => {
setLoading(true)
try {
if (tab === 'ledger') {
const res = await stockReports.ledger(ledgerParams(page, pageSize))
setLedgerData(getPaginatedItems(res))
setTotal(res.total)
} else {
const res = await stockReports.expiry(expiryParams(page, pageSize))
setExpiryData(getPaginatedItems(res))
setTotal(res.total)
}
} catch (err) {
toast.add({ title: 'Failed to load stock report', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [tab, page, pageSize, ledgerParams, expiryParams])
useEffect(() => {
fetchData()
}, [fetchData])
useEffect(() => {
stockReports
.overview({ locationId: location, expiryDays })
.then(setOverview)
.catch(err => toast.add({ title: 'Failed to load summary', description: getApiError(err), type: 'error' }))
}, [location, expiryDays])
function changeTab(next: Tab) {
setTab(next)
setPage(1)
setTotal(0)
}
async function handleExport(format: 'excel' | 'pdf') {
const locationLabel = locationOptions.find(o => o.id === locationId)?.label ?? 'All locations'
if (tab === 'ledger') {
const rows = await fetchAllPages((p, s) =>
stockReports.ledger(ledgerParams(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total })),
)
const range = `${from || 'last 30 days'} to ${to || 'today'}`
if (format === 'excel') await exportExcel('stock-ledger', LEDGER_EXPORT, rows)
else await exportPdf('stock-ledger', 'Stock Ledger', LEDGER_EXPORT, rows, `${locationLabel} · ${range}`)
} else {
const rows = await fetchAllPages((p, s) =>
stockReports.expiry(expiryParams(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total })),
)
if (format === 'excel') await exportExcel('stock-expiry', EXPIRY_EXPORT, rows)
else await exportPdf('stock-expiry', 'Stock Expiry', EXPIRY_EXPORT, rows, `${locationLabel} · within ${expiryDays} days`)
}
}
const common = { serverSide: true, stickyHeader: true, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] } }
const ledgerConfig = useMemo<TableConfig<StockLedgerRow>>(
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No products found.', columns: ledgerColumns }),
[pageSize],
)
const expiryConfig = useMemo<TableConfig<StockExpiryRow>>(
() => ({
...common,
rowKey: r => `${r.batchId}-${r.locationId}`,
emptyMessage: 'No batches expiring in this window.',
columns: expiryColumns,
}),
[pageSize],
)
const onServerSideChange = ({ page: p }: { page: { page: number; pageSize: number } }) => {
setPage(p.page)
setPageSize(p.pageSize)
}
const cards: [string, string][] = overview
? [
['Products in stock', num(overview.totalSkus)],
['Total qty', num(overview.totalQty)],
['Stock value', num(overview.totalValue)],
['Low stock', num(overview.lowStockCount)],
['Out of stock', num(overview.outOfStockCount)],
[`Expiring in ${expiryDays}d`, num(overview.expiringSoonCount)],
['Expired', num(overview.expiredCount)],
]
: []
return (
<>
<div>
<h1 className="text-2xl font-semibold text-foreground">Stock Report</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Opening, in, out and closing stock across all locations or one warehouse or vehicle, plus batches nearing expiry.
</p>
</div>
{overview && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
{cards.map(([label, value]) => (
<Card key={label} className="rounded-3xl border-border/60">
<CardContent className="space-y-1">
<CardDescription>{label}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</CardContent>
</Card>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{TABS.map(t => (
<Button key={t.key} size="sm" variant={tab === t.key ? 'default' : 'outline'} onClick={() => changeTab(t.key)}>
{t.label}
</Button>
))}
<Select value={locationId} onValueChange={v => { setLocationId(v ?? ALL); setPage(1) }}>
<SelectTrigger className="w-56"><SelectValue placeholder="Location" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All locations</SelectItem>
{locationOptions.map(o => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<Input
className="w-56"
placeholder="Search name or SKU"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
/>
{tab === 'ledger' ? (
<>
<Select value={categoryId} onValueChange={v => { setCategoryId(v ?? ALL); setPage(1) }}>
<SelectTrigger className="w-44"><SelectValue placeholder="Category" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All categories</SelectItem>
{categoryOptions.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={status} onValueChange={v => { setStatus(v ?? ALL); setPage(1) }}>
<SelectTrigger className="w-40"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All statuses</SelectItem>
<SelectItem value="low">Low stock</SelectItem>
<SelectItem value="out">Out of stock</SelectItem>
</SelectContent>
</Select>
<Input className="w-40" type="date" value={from} onChange={e => { setFrom(e.target.value); setPage(1) }} />
<Input className="w-40" type="date" value={to} onChange={e => { setTo(e.target.value); setPage(1) }} />
</>
) : (
<Select value={String(expiryDays)} onValueChange={v => { setExpiryDays(Number(v)); setPage(1) }}>
<SelectTrigger className="w-44"><SelectValue placeholder="Window" /></SelectTrigger>
<SelectContent>
{EXPIRY_WINDOWS.map(d => <SelectItem key={d} value={String(d)}>Within {d} days</SelectItem>)}
</SelectContent>
</Select>
)}
<div className="ml-auto flex items-center gap-2">
<ExportButtons onExport={handleExport} disabled={loading || total === 0} />
</div>
</div>
{tab === 'ledger' && (
<DataTable config={ledgerConfig} data={ledgerData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
)}
{tab === 'expiry' && (
<DataTable config={expiryConfig} data={expiryData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
)}
</>
)
}
@@ -0,0 +1,5 @@
import { StockReportClient } from './_components/stock-report-client'
export default function StockReportPage() {
return <StockReportClient />
}