423 lines
16 KiB
TypeScript
423 lines
16 KiB
TypeScript
"use client"
|
||
|
||
import * as React from "react"
|
||
import { CalendarClockIcon, DollarSignIcon, EyeIcon, PackageIcon, 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"
|
||
import { StatCard } from "@/components/hexa/stat-card"
|
||
|
||
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 totalValue = grns.reduce((sum, g) => sum + g.totalAmount, 0)
|
||
const now = new Date()
|
||
const thisMonthGrns = grns.filter((g) => {
|
||
const d = new Date(g.receivedAt)
|
||
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
|
||
})
|
||
const thisMonthValue = thisMonthGrns.reduce((sum, g) => sum + g.totalAmount, 0)
|
||
const avgValue = grns.length ? totalValue / grns.length : 0
|
||
|
||
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>
|
||
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<StatCard icon={PackageIcon} tone="blue" label="Total GRNs" value={grns.length} />
|
||
<StatCard icon={DollarSignIcon} tone="purple" label="Total Received Value" value={formatCurrency(totalValue)} />
|
||
<StatCard icon={CalendarClockIcon} tone="amber" label="This Month" value={formatCurrency(thisMonthValue)} hint={`${thisMonthGrns.length} GRNs`} />
|
||
<StatCard icon={TruckIcon} tone="green" label="Avg. GRN Value" value={formatCurrency(avgValue)} />
|
||
</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 }
|