Files
Client_Fe/src/components/hexa/views/inventory-sales-tab.tsx
T
HarithaRandunu 4a9ebe07b8 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.
2026-10-07 15:43:17 +05:30

449 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 }