Feat/inventory session booking #4

Open
HarithaRandunu wants to merge 4 commits from feat/inventory-sessionBooking into dev
4 changed files with 269 additions and 0 deletions
Showing only changes of commit 6263c99767 - Show all commits
@@ -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