Add Stock Tracking tab to Inventory

New read-only ledger tab combining every GRN, Sale and Return across
all products into one filterable table (type, product, category,
date range), showing all movements by default.
This commit is contained in:
2026-10-09 14:22:19 +05:30
parent 4a9ebe07b8
commit 6263c99767
4 changed files with 269 additions and 0 deletions
@@ -0,0 +1,244 @@
"use client"
import * as React from "react"
import { ArrowDownCircleIcon, ArrowUpCircleIcon, ListIcon, RotateCcwIcon } from "lucide-react"
import { getProducts, getStockMovements } from "@/lib/api/inventory"
import type { ReturnCategory, StockMovement } from "@/lib/models/inventory"
import { formatCurrency } from "@/lib/format"
import { useFetch } from "@/lib/use-fetch"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { InfoBanner } from "@/components/hexa/info-banner"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
const TYPE_OPTIONS: { label: string; value: "all" | StockMovement["type"] }[] = [
{ label: "All types", value: "all" },
{ label: "Stock in (GRN)", value: "GRN" },
{ label: "Stock out (Sale)", value: "Sale" },
{ label: "Returns / write-offs", value: "Return" },
]
const CATEGORY_OPTIONS: { label: string; value: "all" | ReturnCategory }[] = [
{ label: "All categories", value: "all" },
{ label: "Expired", value: "Expired" },
{ label: "Damaged", value: "Damaged" },
{ label: "Lost", value: "Lost" },
{ label: "Sent back to supplier", value: "SupplierReturn" },
{ label: "Handed back by customer", value: "CustomerReturn" },
{ label: "Other", value: "Other" },
]
function categoryLabel(category: ReturnCategory) {
return CATEGORY_OPTIONS.find((o) => o.value === category)?.label ?? category
}
function typeLabel(type: StockMovement["type"]) {
return TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
function typeIcon(type: StockMovement["type"]) {
if (type === "GRN") return <ArrowDownCircleIcon className="size-4" />
if (type === "Sale") return <ArrowUpCircleIcon className="size-4" />
return <RotateCcwIcon className="size-4" />
}
function InventoryStockTrackingTab() {
const movementsFetch = useFetch(() => getStockMovements())
const movements = movementsFetch.data ?? []
const productsFetch = useFetch(() => getProducts(true))
const products = productsFetch.data ?? []
const [typeFilter, setTypeFilter] = React.useState<"all" | StockMovement["type"]>("all")
const [productFilter, setProductFilter] = React.useState("all")
const [categoryFilter, setCategoryFilter] = React.useState<"all" | ReturnCategory>("all")
const [dateFrom, setDateFrom] = React.useState("")
const [dateTo, setDateTo] = React.useState("")
const hasActiveFilters =
typeFilter !== "all" || productFilter !== "all" || categoryFilter !== "all" || !!dateFrom || !!dateTo
function resetFilters() {
setTypeFilter("all")
setProductFilter("all")
setCategoryFilter("all")
setDateFrom("")
setDateTo("")
}
// Shows every stock movement that ever happened by default -- all filters are opt-in.
const filtered = movements.filter((m) => {
if (typeFilter !== "all" && m.type !== typeFilter) return false
if (productFilter !== "all" && m.productId !== productFilter) return false
if (categoryFilter !== "all" && m.category !== categoryFilter) return false
if (dateFrom && new Date(m.date) < new Date(dateFrom)) return false
if (dateTo && new Date(m.date) > new Date(`${dateTo}T23:59:59`)) return false
return true
})
const productOptions = [{ label: "All products", value: "all" }, ...products.map((p) => ({ label: p.name, value: p.productId }))]
const columns: DataTableColumn<StockMovement>[] = [
{ key: "date", header: "Date", cell: (row) => formatDate(row.date) },
{
key: "type",
header: "Type",
cell: (row) => (
<div className="flex items-center gap-1.5">
{typeIcon(row.type)}
<span>{typeLabel(row.type)}</span>
</div>
),
},
{ key: "product", header: "Product", cell: (row) => row.productName },
{ key: "reference", header: "Reference", cell: (row) => row.reference },
{
key: "category",
header: "Category",
cell: (row) => (row.category ? <Badge variant="outline">{categoryLabel(row.category)}</Badge> : "—"),
},
{
key: "quantity",
header: "Quantity",
cell: (row) => (
<span className={row.quantityChange >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
{row.quantityChange >= 0 ? "+" : ""}
{row.quantityChange}
</span>
),
},
{ key: "amount", header: "Amount", cell: (row) => formatCurrency(row.amount) },
]
if (movementsFetch.loading) {
return <PageSkeleton />
}
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold">Stock Tracking</h2>
<p className="text-sm text-muted-foreground">
Every stock movement that ever happened — GRNs in, sales out, and returns — in one
ledger. Shows everything by default; use the filters to narrow it down.
</p>
</div>
<div className="flex flex-col gap-3 rounded-xl border bg-card p-3 sm:flex-row sm:items-end sm:flex-wrap">
<div className="flex min-w-36 flex-col gap-1.5">
<Label>Type</Label>
<Select items={TYPE_OPTIONS} value={typeFilter} onValueChange={(v) => setTypeFilter((v as typeof typeFilter) ?? "all")}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex min-w-44 flex-col gap-1.5">
<Label>Product</Label>
<Select items={productOptions} value={productFilter} onValueChange={(v) => setProductFilter(v ?? "all")}>
<SelectTrigger className="w-full">
<SelectValue placeholder={productsFetch.loading ? "Loading…" : "All products"} />
</SelectTrigger>
<SelectContent>
{productOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex min-w-44 flex-col gap-1.5">
<Label>Return category</Label>
<Select items={CATEGORY_OPTIONS} value={categoryFilter} onValueChange={(v) => setCategoryFilter((v as typeof categoryFilter) ?? "all")}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex min-w-36 flex-col gap-1.5">
<Label>From</Label>
<Input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
</div>
<div className="flex min-w-36 flex-col gap-1.5">
<Label>To</Label>
<Input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
</div>
{hasActiveFilters && (
<Button type="button" variant="outline" onClick={resetFilters}>
Reset filters
</Button>
)}
</div>
<DataTable
columns={columns}
rows={filtered}
rowKey={(row) => `${row.type}-${row.productId}-${row.reference}-${row.date}-${row.amount}`}
emptyMessage={hasActiveFilters ? "No movements match these filters." : "No stock movements recorded yet."}
renderMobileCard={(row) => (
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-2.5">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{typeIcon(row.type)}
</div>
<div>
<p className="font-medium">{row.productName}</p>
<p className="text-xs text-muted-foreground">
{typeLabel(row.type)} · {row.reference} · {formatDate(row.date)}
</p>
</div>
</div>
<div className="flex items-center justify-between border-t pt-2.5">
<span className={row.quantityChange >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
{row.quantityChange >= 0 ? "+" : ""}
{row.quantityChange}
</span>
<span className="font-medium">{formatCurrency(row.amount)}</span>
</div>
</div>
)}
/>
<InfoBanner>
<ListIcon className="size-4" />
Read-only — this tab is a combined view of the GRN, Sales and Returns tabs. To record a
new movement, use the relevant tab directly.
</InfoBanner>
</div>
)
}
export { InventoryStockTrackingTab }
@@ -4,6 +4,7 @@ import { InventoryGrnTab } from "@/components/hexa/views/inventory-grn-tab"
import { InventoryProductsTab } from "@/components/hexa/views/inventory-products-tab"
import { InventoryReturnsTab } from "@/components/hexa/views/inventory-returns-tab"
import { InventorySalesTab } from "@/components/hexa/views/inventory-sales-tab"
import { InventoryStockTrackingTab } from "@/components/hexa/views/inventory-stock-tracking-tab"
import { InventorySuppliersTab } from "@/components/hexa/views/inventory-suppliers-tab"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -16,6 +17,7 @@ function InventoryView() {
<TabsTrigger value="grn">GRN</TabsTrigger>
<TabsTrigger value="sales">Sales</TabsTrigger>
<TabsTrigger value="returns">Returns</TabsTrigger>
<TabsTrigger value="stock-tracking">Stock Tracking</TabsTrigger>
</TabsList>
<TabsContent value="products">
@@ -33,6 +35,9 @@ function InventoryView() {
<TabsContent value="returns">
<InventoryReturnsTab />
</TabsContent>
<TabsContent value="stock-tracking">
<InventoryStockTrackingTab />
</TabsContent>
</Tabs>
)
}
+6
View File
@@ -8,6 +8,7 @@ import type {
ProductDetail,
ProductInput,
Sale,
StockMovement,
StockReturn,
Supplier,
SupplierInput,
@@ -79,6 +80,10 @@ function createStockReturn(input: CreateStockReturnInput) {
return apiRequest<StockReturn>("client/inventory/returns", { method: "POST", body: input })
}
function getStockMovements() {
return apiRequest<StockMovement[]>("client/inventory/stock-movements")
}
export {
getProducts,
getProduct,
@@ -96,4 +101,5 @@ export {
getStockReturns,
getStockReturn,
createStockReturn,
getStockMovements,
}
+14
View File
@@ -146,6 +146,20 @@ export interface StockReturn {
amount: number
}
/** One row in the Stock Tracking tab's unified ledger. Mirrors StockMovementDto. */
export interface StockMovement {
type: "GRN" | "Sale" | "Return"
productId: string
productName: string
reference: string
/** Only set when type === "Return". */
category?: ReturnCategory | null
/** Signed: positive for a GRN or a CustomerReturn, negative for a Sale or any other return. */
quantityChange: number
amount: number
date: string
}
export interface CreateStockReturnInput {
productId: string
quantity: number