From 6263c997675a29f8a7c41abc320f37ca1ab675d8 Mon Sep 17 00:00:00 2001 From: Haritha Randunu Date: Fri, 9 Oct 2026 14:22:19 +0530 Subject: [PATCH] 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. --- .../views/inventory-stock-tracking-tab.tsx | 244 ++++++++++++++++++ src/components/hexa/views/inventory-view.tsx | 5 + src/lib/api/inventory.ts | 6 + src/lib/models/inventory.ts | 14 + 4 files changed, 269 insertions(+) create mode 100644 src/components/hexa/views/inventory-stock-tracking-tab.tsx diff --git a/src/components/hexa/views/inventory-stock-tracking-tab.tsx b/src/components/hexa/views/inventory-stock-tracking-tab.tsx new file mode 100644 index 0000000..14d4280 --- /dev/null +++ b/src/components/hexa/views/inventory-stock-tracking-tab.tsx @@ -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 + if (type === "Sale") return + return +} + +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[] = [ + { key: "date", header: "Date", cell: (row) => formatDate(row.date) }, + { + key: "type", + header: "Type", + cell: (row) => ( +
+ {typeIcon(row.type)} + {typeLabel(row.type)} +
+ ), + }, + { key: "product", header: "Product", cell: (row) => row.productName }, + { key: "reference", header: "Reference", cell: (row) => row.reference }, + { + key: "category", + header: "Category", + cell: (row) => (row.category ? {categoryLabel(row.category)} : "—"), + }, + { + key: "quantity", + header: "Quantity", + cell: (row) => ( + = 0 ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}> + {row.quantityChange >= 0 ? "+" : ""} + {row.quantityChange} + + ), + }, + { key: "amount", header: "Amount", cell: (row) => formatCurrency(row.amount) }, + ] + + if (movementsFetch.loading) { + return + } + + return ( +
+
+

Stock Tracking

+

+ 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. +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + setDateFrom(e.target.value)} /> +
+
+ + setDateTo(e.target.value)} /> +
+ + {hasActiveFilters && ( + + )} +
+ + `${row.type}-${row.productId}-${row.reference}-${row.date}-${row.amount}`} + emptyMessage={hasActiveFilters ? "No movements match these filters." : "No stock movements recorded yet."} + renderMobileCard={(row) => ( +
+
+
+ {typeIcon(row.type)} +
+
+

{row.productName}

+

+ {typeLabel(row.type)} · {row.reference} · {formatDate(row.date)} +

+
+
+
+ = 0 ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}> + {row.quantityChange >= 0 ? "+" : ""} + {row.quantityChange} + + {formatCurrency(row.amount)} +
+
+ )} + /> + + + + 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. + +
+ ) +} + +export { InventoryStockTrackingTab } diff --git a/src/components/hexa/views/inventory-view.tsx b/src/components/hexa/views/inventory-view.tsx index f4948fa..ab87120 100644 --- a/src/components/hexa/views/inventory-view.tsx +++ b/src/components/hexa/views/inventory-view.tsx @@ -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() { GRN Sales Returns + Stock Tracking @@ -33,6 +35,9 @@ function InventoryView() { + + + ) } diff --git a/src/lib/api/inventory.ts b/src/lib/api/inventory.ts index 1a3c96d..4c95141 100644 --- a/src/lib/api/inventory.ts +++ b/src/lib/api/inventory.ts @@ -8,6 +8,7 @@ import type { ProductDetail, ProductInput, Sale, + StockMovement, StockReturn, Supplier, SupplierInput, @@ -79,6 +80,10 @@ function createStockReturn(input: CreateStockReturnInput) { return apiRequest("client/inventory/returns", { method: "POST", body: input }) } +function getStockMovements() { + return apiRequest("client/inventory/stock-movements") +} + export { getProducts, getProduct, @@ -96,4 +101,5 @@ export { getStockReturns, getStockReturn, createStockReturn, + getStockMovements, } diff --git a/src/lib/models/inventory.ts b/src/lib/models/inventory.ts index c770529..e288fe9 100644 --- a/src/lib/models/inventory.ts +++ b/src/lib/models/inventory.ts @@ -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