4a9ebe07b8
- 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.
449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
"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 }
|