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,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'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 }
|
||||
Reference in New Issue
Block a user