|
|
|
@@ -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 }
|