feat: add inventory management components and API integration
- Implemented InventorySalesTab for recording and viewing sales. - Created InventorySuppliersTab for managing suppliers. - Developed InventoryView to consolidate inventory-related tabs. - Added API functions for handling products, suppliers, sales, and GRNs. - Defined necessary TypeScript models for inventory entities. - Updated navigation to include an Inventory section.
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { EyeIcon, PlusIcon, ReceiptIcon, TrashIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { createSale, getProducts, getSale, getSales } from "@/lib/api/inventory"
|
||||
import { getMembers } from "@/lib/api/customers"
|
||||
import type { CreateSaleInput, Sale, SaleItemInput } from "@/lib/models/inventory"
|
||||
import type { PaymentMethod } from "@/lib/models/booking"
|
||||
import { formatCurrency } from "@/lib/format"
|
||||
import { useFetch } from "@/lib/use-fetch"
|
||||
import { useMutation } from "@/lib/use-mutation"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||
|
||||
const PAYMENT_METHOD_OPTIONS: { label: string; value: PaymentMethod }[] = [
|
||||
{ label: "Cash", value: "Cash" },
|
||||
{ label: "Bank", value: "Bank" },
|
||||
]
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
interface SaleLine {
|
||||
productId: string
|
||||
quantity: string
|
||||
unitPrice: string
|
||||
}
|
||||
|
||||
interface CreateSaleDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
function CreateSaleDialog({ open, onOpenChange, onCreated }: CreateSaleDialogProps) {
|
||||
const productsFetch = useFetch(() => getProducts(false))
|
||||
const products = productsFetch.data ?? []
|
||||
const membersFetch = useFetch(() => getMembers())
|
||||
const members = (membersFetch.data ?? []).filter((m) => !!m.customer)
|
||||
const memberOptions = [
|
||||
{ label: "Walk-in (not registered)", value: "none" },
|
||||
...members.map((m) => ({ label: m.customer!.cusName ?? m.customer!.primaryPH ?? "Member", value: m.fkCustomerId })),
|
||||
]
|
||||
|
||||
const [customerId, setCustomerId] = React.useState("")
|
||||
const [customerName, setCustomerName] = React.useState("")
|
||||
const [paymentMethod, setPaymentMethod] = React.useState<PaymentMethod>("Cash")
|
||||
const [lines, setLines] = React.useState<SaleLine[]>([{ productId: "", quantity: "1", unitPrice: "0" }])
|
||||
const { busy, run } = useMutation()
|
||||
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
if (open) {
|
||||
setCustomerId("")
|
||||
setCustomerName("")
|
||||
setPaymentMethod("Cash")
|
||||
setLines([{ productId: "", quantity: "1", unitPrice: "0" }])
|
||||
}
|
||||
}
|
||||
|
||||
function selectMember(id: string) {
|
||||
setCustomerId(id === "none" ? "" : id)
|
||||
const member = id !== "none" ? members.find((m) => m.fkCustomerId === id) : undefined
|
||||
if (member?.customer) setCustomerName(member.customer.cusName ?? "")
|
||||
}
|
||||
|
||||
function updateLine(idx: number, patch: Partial<SaleLine>) {
|
||||
setLines((prev) =>
|
||||
prev.map((l, i) => {
|
||||
if (i !== idx) return l
|
||||
const next = { ...l, ...patch }
|
||||
if (patch.productId) {
|
||||
const product = products.find((p) => p.productId === patch.productId)
|
||||
if (product) next.unitPrice = String(product.sellingPrice)
|
||||
}
|
||||
return next
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, { productId: "", quantity: "1", unitPrice: "0" }])
|
||||
}
|
||||
|
||||
function removeLine(idx: number) {
|
||||
setLines((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const total = lines.reduce((sum, l) => sum + (Number(l.quantity) || 0) * (Number(l.unitPrice) || 0), 0)
|
||||
const linesValid = lines.length > 0 && lines.every((l) => !!l.productId && Number(l.quantity) > 0 && Number(l.unitPrice) >= 0)
|
||||
const canSubmit = linesValid && !busy
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
const input: CreateSaleInput = {
|
||||
paymentMethod,
|
||||
customerId: customerId || undefined,
|
||||
customerName: customerName.trim() || undefined,
|
||||
items: lines.map<SaleItemInput>((l) => ({
|
||||
productId: l.productId,
|
||||
quantity: Number(l.quantity),
|
||||
unitPrice: Number(l.unitPrice),
|
||||
})),
|
||||
}
|
||||
try {
|
||||
await run(async () => {
|
||||
await createSale(input)
|
||||
toast.success("Sale recorded", { description: "Stock levels were updated." })
|
||||
onCreated()
|
||||
})
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// run() already toasted the real error; keep the dialog open for retry.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Record Sale</DialogTitle>
|
||||
<DialogDescription>Stock going out to a customer — decreases quantity on hand.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Select existing member (optional)</Label>
|
||||
<Select items={memberOptions} value={customerId || "none"} onValueChange={(v) => selectMember(v ?? "none")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={membersFetch.loading ? "Loading…" : "Walk-in (not registered)"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{memberOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sale-customer-name">Customer Name</Label>
|
||||
<Input
|
||||
id="sale-customer-name"
|
||||
placeholder="Optional"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>
|
||||
Items <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="grid grid-cols-[2fr_1fr_1fr_auto] gap-2 text-xs text-muted-foreground">
|
||||
<span>Product</span>
|
||||
<span>Quantity</span>
|
||||
<span>Unit Price (Rs.)</span>
|
||||
<span />
|
||||
</div>
|
||||
{lines.map((line, idx) => (
|
||||
<div key={idx} className="grid grid-cols-[2fr_1fr_1fr_auto] items-center gap-2">
|
||||
<Select
|
||||
items={products.map((p) => ({ label: p.name, value: p.productId }))}
|
||||
value={line.productId}
|
||||
onValueChange={(v) => updateLine(idx, { productId: v ?? "" })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a product" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products.map((p) => (
|
||||
<SelectItem key={p.productId} value={p.productId} disabled={p.quantityOnHand <= 0}>
|
||||
{p.name} ({p.quantityOnHand} left)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.01}
|
||||
step="0.01"
|
||||
aria-label="Quantity"
|
||||
placeholder="Qty"
|
||||
value={line.quantity}
|
||||
onChange={(e) => updateLine(idx, { quantity: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
aria-label="Unit price"
|
||||
placeholder="Unit price"
|
||||
value={line.unitPrice}
|
||||
onChange={(e) => updateLine(idx, { unitPrice: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Remove line"
|
||||
disabled={lines.length === 1}
|
||||
onClick={() => removeLine(idx)}
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" className="self-start" onClick={addLine}>
|
||||
<PlusIcon className="size-4" />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Payment Method</Label>
|
||||
<Select items={PAYMENT_METHOD_OPTIONS} value={paymentMethod} onValueChange={(v) => setPaymentMethod((v as PaymentMethod) ?? "Cash")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHOD_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/40 p-3 text-sm">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-semibold">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{busy && <Spinner className="size-4" />}
|
||||
Record Sale
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
interface SaleDetailDialogProps {
|
||||
saleId: string | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function SaleDetailDialog({ saleId, onOpenChange }: SaleDetailDialogProps) {
|
||||
const detailFetch = useFetch(() => (saleId ? getSale(saleId) : Promise.resolve(null)))
|
||||
const sale = detailFetch.data
|
||||
|
||||
// This dialog stays mounted across opens (only `saleId` changes) -- useFetch only runs its
|
||||
// fetcher on mount and on an explicit reload(), so a fresh id needs an explicit reload too.
|
||||
React.useEffect(() => {
|
||||
if (saleId) detailFetch.reload()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to the id changing
|
||||
}, [saleId, detailFetch.reload])
|
||||
|
||||
return (
|
||||
<Dialog open={!!saleId} onOpenChange={(open) => !open && onOpenChange(false)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sale Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{detailFetch.loading || !sale ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Sale Number</p>
|
||||
<p className="font-medium">{sale.saleNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Sold</p>
|
||||
<p className="font-medium">{formatDate(sale.soldAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Customer</p>
|
||||
<p className="font-medium">{sale.customerName || "Walk-in"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Payment Method</p>
|
||||
<Badge variant="outline">{sale.paymentMethod}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Total</p>
|
||||
<p className="font-medium">{formatCurrency(sale.totalAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">Items</p>
|
||||
<div className="flex flex-col divide-y rounded-lg border">
|
||||
{sale.items.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between gap-3 p-2.5 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">{item.productName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.quantity} × {formatCurrency(item.unitPrice)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-medium">{formatCurrency(item.lineTotal)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function InventorySalesTab() {
|
||||
const salesFetch = useFetch(() => getSales())
|
||||
const sales = salesFetch.data ?? []
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||
const [detailSaleId, setDetailSaleId] = React.useState<string | null>(null)
|
||||
|
||||
const columns: DataTableColumn<Sale>[] = [
|
||||
{
|
||||
key: "saleNumber",
|
||||
header: "Sale",
|
||||
cell: (row) => (
|
||||
<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">
|
||||
<ReceiptIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{row.saleNumber}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.customerName || "Walk-in"}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "sold", header: "Sold", cell: (row) => formatDate(row.soldAt) },
|
||||
{ key: "items", header: "Items", cell: (row) => row.items.length },
|
||||
{ key: "method", header: "Payment", cell: (row) => <Badge variant="outline">{row.paymentMethod}</Badge> },
|
||||
{ key: "total", header: "Total", cell: (row) => formatCurrency(row.totalAmount) },
|
||||
{
|
||||
key: "actions",
|
||||
header: "Actions",
|
||||
className: "text-center",
|
||||
cell: (row) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" aria-label="View details" onClick={() => setDetailSaleId(row.saleId)}>
|
||||
<EyeIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>View details</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (salesFetch.loading) {
|
||||
return <PageSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Sales</h2>
|
||||
<p className="text-sm text-muted-foreground">Stock going out to a customer.</p>
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<PlusIcon />
|
||||
Record Sale
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={sales}
|
||||
rowKey={(row) => row.saleId}
|
||||
emptyMessage="No sales yet."
|
||||
onRowClick={(row) => setDetailSaleId(row.saleId)}
|
||||
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">
|
||||
<ReceiptIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{row.saleNumber}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.customerName || "Walk-in"} · {formatDate(row.soldAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t pt-2.5">
|
||||
<Badge variant="outline">{row.paymentMethod}</Badge>
|
||||
<span className="font-medium">{formatCurrency(row.totalAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<CreateSaleDialog open={dialogOpen} onOpenChange={setDialogOpen} onCreated={salesFetch.reload} />
|
||||
<SaleDetailDialog saleId={detailSaleId} onOpenChange={() => setDetailSaleId(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InventorySalesTab }
|
||||
Reference in New Issue
Block a user