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:
@@ -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 }
|
||||
Reference in New Issue
Block a user