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:
2026-10-07 15:43:17 +05:30
parent 01fc955764
commit 4a9ebe07b8
11 changed files with 2354 additions and 0 deletions
@@ -0,0 +1,405 @@
"use client"
import * as React from "react"
import { EyeIcon, PlusIcon, TrashIcon, TruckIcon } from "lucide-react"
import { toast } from "sonner"
import { createGrn, getGrn, getGrns, getProducts, getSuppliers } from "@/lib/api/inventory"
import type { CreateGrnInput, GoodsReceivedNote, GrnItemInput } from "@/lib/models/inventory"
import { formatCurrency } from "@/lib/format"
import { useFetch } from "@/lib/use-fetch"
import { useMutation } from "@/lib/use-mutation"
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 { Textarea } from "@/components/ui/textarea"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
function formatDate(iso: string) {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
interface GrnLine {
productId: string
quantity: string
unitCost: string
}
interface CreateGrnDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: () => void
}
function CreateGrnDialog({ open, onOpenChange, onCreated }: CreateGrnDialogProps) {
const suppliersFetch = useFetch(() => getSuppliers(false))
const suppliers = suppliersFetch.data ?? []
const productsFetch = useFetch(() => getProducts(false))
const products = productsFetch.data ?? []
const [supplierId, setSupplierId] = React.useState("")
const [notes, setNotes] = React.useState("")
const [lines, setLines] = React.useState<GrnLine[]>([{ productId: "", quantity: "1", unitCost: "0" }])
const { busy, run } = useMutation()
const [prevOpen, setPrevOpen] = React.useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) {
setSupplierId("")
setNotes("")
setLines([{ productId: "", quantity: "1", unitCost: "0" }])
}
}
function updateLine(idx: number, patch: Partial<GrnLine>) {
setLines((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)))
}
function addLine() {
setLines((prev) => [...prev, { productId: "", quantity: "1", unitCost: "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.unitCost) || 0), 0)
const linesValid = lines.length > 0 && lines.every((l) => !!l.productId && Number(l.quantity) > 0 && Number(l.unitCost) >= 0)
const canSubmit = linesValid && !busy
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
const input: CreateGrnInput = {
supplierId: supplierId || undefined,
notes: notes.trim() || undefined,
items: lines.map<GrnItemInput>((l) => ({
productId: l.productId,
quantity: Number(l.quantity),
unitCost: Number(l.unitCost),
})),
}
try {
await run(async () => {
await createGrn(input)
toast.success("GRN 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 Goods Received</DialogTitle>
<DialogDescription>
Stock coming in from a supplier — increases each product&apos;s quantity on hand.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<Label>Supplier</Label>
<Select
items={[{ label: "No supplier", value: "none" }, ...suppliers.map((s) => ({ label: s.name, value: s.supplierId }))]}
value={supplierId || "none"}
onValueChange={(v) => setSupplierId(v === "none" ? "" : v ?? "")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={suppliersFetch.loading ? "Loading…" : "No supplier"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No supplier</SelectItem>
{suppliers.map((s) => (
<SelectItem key={s.supplierId} value={s.supplierId}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</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 Cost (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}>
{p.name}
</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 cost"
placeholder="Unit cost"
value={line.unitCost}
onChange={(e) => updateLine(idx, { unitCost: 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 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>
<div className="flex flex-col gap-1.5">
<Label htmlFor="grn-notes">Notes</Label>
<Textarea id="grn-notes" maxLength={300} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<DialogFooter className="-mx-4 -mt-4 -mb-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{busy && <Spinner className="size-4" />}
Record GRN
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
interface GrnDetailDialogProps {
grnId: string | null
onOpenChange: (open: boolean) => void
}
function GrnDetailDialog({ grnId, onOpenChange }: GrnDetailDialogProps) {
const detailFetch = useFetch(() => (grnId ? getGrn(grnId) : Promise.resolve(null)))
const grn = detailFetch.data
// This dialog stays mounted across opens (only `grnId` 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 (grnId) detailFetch.reload()
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to the id changing
}, [grnId, detailFetch.reload])
return (
<Dialog open={!!grnId} onOpenChange={(open) => !open && onOpenChange(false)}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>GRN Details</DialogTitle>
</DialogHeader>
{detailFetch.loading || !grn ? (
<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">GRN Number</p>
<p className="font-medium">{grn.grnNumber}</p>
</div>
<div>
<p className="text-muted-foreground">Supplier</p>
<p className="font-medium">{grn.supplierName || "No supplier"}</p>
</div>
<div>
<p className="text-muted-foreground">Received</p>
<p className="font-medium">{formatDate(grn.receivedAt)}</p>
</div>
<div>
<p className="text-muted-foreground">Total</p>
<p className="font-medium">{formatCurrency(grn.totalAmount)}</p>
</div>
{grn.notes && (
<div className="col-span-2">
<p className="text-muted-foreground">Notes</p>
<p className="font-medium">{grn.notes}</p>
</div>
)}
</div>
<div>
<p className="mb-2 text-sm font-medium">Items</p>
<div className="flex flex-col divide-y rounded-lg border">
{grn.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.unitCost)}
</p>
</div>
<span className="font-medium">{formatCurrency(item.lineTotal)}</span>
</div>
))}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
function InventoryGrnTab() {
const grnsFetch = useFetch(() => getGrns())
const grns = grnsFetch.data ?? []
const [dialogOpen, setDialogOpen] = React.useState(false)
const [detailGrnId, setDetailGrnId] = React.useState<string | null>(null)
const columns: DataTableColumn<GoodsReceivedNote>[] = [
{
key: "grnNumber",
header: "GRN",
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">
<TruckIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.grnNumber}</p>
<p className="text-xs text-muted-foreground">{row.supplierName || "No supplier"}</p>
</div>
</div>
),
},
{ key: "received", header: "Received", cell: (row) => formatDate(row.receivedAt) },
{ key: "items", header: "Items", cell: (row) => row.items.length },
{ 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={() => setDetailGrnId(row.grnId)}>
<EyeIcon />
</Button>
}
/>
<TooltipContent>View details</TooltipContent>
</Tooltip>
),
},
]
if (grnsFetch.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">Goods Received (GRN)</h2>
<p className="text-sm text-muted-foreground">Stock coming in from a supplier.</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<PlusIcon />
Record GRN
</Button>
</div>
<DataTable
columns={columns}
rows={grns}
rowKey={(row) => row.grnId}
emptyMessage="No goods received yet."
onRowClick={(row) => setDetailGrnId(row.grnId)}
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">
<TruckIcon className="size-4" />
</div>
<div>
<p className="font-medium">{row.grnNumber}</p>
<p className="text-xs text-muted-foreground">{row.supplierName || "No supplier"} · {formatDate(row.receivedAt)}</p>
</div>
</div>
<div className="flex items-center justify-between border-t pt-2.5">
<span className="text-sm text-muted-foreground">{row.items.length} items</span>
<span className="font-medium">{formatCurrency(row.totalAmount)}</span>
</div>
</div>
)}
/>
<CreateGrnDialog open={dialogOpen} onOpenChange={setDialogOpen} onCreated={grnsFetch.reload} />
<GrnDetailDialog grnId={detailGrnId} onOpenChange={() => setDetailGrnId(null)} />
</div>
)
}
export { InventoryGrnTab }