Files
Client_Fe/src/components/hexa/views/inventory-grn-tab.tsx
T

423 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 { 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&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 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 }