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,5 @@
|
||||
import { InventoryView } from "@/components/hexa/views/inventory-view"
|
||||
|
||||
export default function InventoryPage() {
|
||||
return <InventoryView />
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,516 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, EyeIcon, ImagePlusIcon, PackageIcon, PencilIcon, PlusIcon, XIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { createProduct, getProduct, getProducts, updateProduct, uploadProductImage } from "@/lib/api/inventory"
|
||||
import type { Product, ProductDetail, ProductInput } from "@/lib/models/inventory"
|
||||
import { formatCurrency } from "@/lib/format"
|
||||
import { useFetch } from "@/lib/use-fetch"
|
||||
import { useMutation, runMutation } 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 { Spinner } from "@/components/ui/spinner"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { InfoBanner } from "@/components/hexa/info-banner"
|
||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||
|
||||
interface ProductFormValues {
|
||||
name: string
|
||||
unitOfMeasure: string
|
||||
sellingPrice: string
|
||||
}
|
||||
|
||||
const EMPTY_VALUES: ProductFormValues = {
|
||||
name: "",
|
||||
unitOfMeasure: "pcs",
|
||||
sellingPrice: "0",
|
||||
}
|
||||
|
||||
interface ProductDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (values: ProductFormValues, imageFile: File | null) => Promise<void> | void
|
||||
mode: "create" | "edit"
|
||||
initialValues?: Partial<ProductFormValues>
|
||||
/** The product's current photo (edit mode only) -- shown as the starting preview. */
|
||||
currentImageUrl?: string | null
|
||||
}
|
||||
|
||||
function ProductDialog({ open, onOpenChange, onSubmit, mode, initialValues, currentImageUrl }: ProductDialogProps) {
|
||||
const [values, setValues] = React.useState<ProductFormValues>(EMPTY_VALUES)
|
||||
const [imageFile, setImageFile] = React.useState<File | null>(null)
|
||||
const [imagePreview, setImagePreview] = React.useState<string | null>(null)
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const { busy, run } = useMutation()
|
||||
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
if (open) {
|
||||
setValues({ ...EMPTY_VALUES, ...initialValues })
|
||||
setImageFile(null)
|
||||
setImagePreview(currentImageUrl ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
function set<K extends keyof ProductFormValues>(key: K, value: ProductFormValues[K]) {
|
||||
setValues((v) => ({ ...v, [key]: value }))
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setImageFile(file)
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => setImagePreview(reader.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const canSubmit = !!values.name.trim() && !!values.unitOfMeasure.trim() && Number(values.sellingPrice) >= 0 && !busy
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
try {
|
||||
await run(async () => { await onSubmit(values, imageFile) })
|
||||
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-md">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "create" ? "Add Product" : "Edit Product"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
A stocked item sold at the gym. SKU, cost price and quantity on hand are all
|
||||
maintained automatically — SKU is assigned on creation, the other two from GRNs,
|
||||
sales and returns.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Photo</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted"
|
||||
aria-label="Choose product photo"
|
||||
>
|
||||
{imagePreview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- a locally-chosen file / already-fetched data URL, not a Next-optimizable remote image
|
||||
<img src={imagePreview} alt="" className="size-full object-cover" />
|
||||
) : (
|
||||
<ImagePlusIcon className="size-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
{imagePreview ? "Change photo" : "Upload photo"}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">JPG, PNG or WEBP, up to 5MB.</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="product-name">
|
||||
Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="product-name"
|
||||
placeholder="e.g. Protein Bar"
|
||||
value={values.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="product-unit">
|
||||
Unit <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="product-unit"
|
||||
placeholder="pcs"
|
||||
value={values.unitOfMeasure}
|
||||
onChange={(e) => set("unitOfMeasure", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="product-price">
|
||||
Selling Price (Rs.) <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="product-price"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={values.sellingPrice}
|
||||
onChange={(e) => set("sellingPrice", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{busy && <Spinner className="size-4" />}
|
||||
{mode === "create" ? "Add Product" : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const MOVEMENT_LABEL: Record<ProductDetail["movements"][number]["type"], string> = {
|
||||
GRN: "Stock In (GRN)",
|
||||
Sale: "Sale",
|
||||
Return: "Return / Write-off",
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
interface ProductDetailDialogProps {
|
||||
productId: string | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/** Product detail page -- full record plus its stock-movement history (every GRN, sale and
|
||||
* return that touched this product), fetched fresh per open via GET .../products/{id}. */
|
||||
function ProductDetailDialog({ productId, onOpenChange }: ProductDetailDialogProps) {
|
||||
const detailFetch = useFetch(() => (productId ? getProduct(productId) : Promise.resolve(null)))
|
||||
const detail = detailFetch.data
|
||||
|
||||
// This dialog stays mounted across opens (only `productId` changes) -- useFetch only runs
|
||||
// its fetcher on mount and on an explicit reload(), so a fresh id by itself wouldn't hit the
|
||||
// API again without this.
|
||||
React.useEffect(() => {
|
||||
if (productId) detailFetch.reload()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to the id changing
|
||||
}, [productId, detailFetch.reload])
|
||||
|
||||
return (
|
||||
<Dialog open={!!productId} onOpenChange={(open) => !open && onOpenChange(false)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Product Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{detailFetch.loading || !detail ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted">
|
||||
{detail.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- a base64 data URL from our own API, not a Next-optimizable remote image
|
||||
<img src={detail.imageUrl} alt={detail.name} className="size-full object-cover" />
|
||||
) : (
|
||||
<PackageIcon className="size-7 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{detail.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{detail.sku || "No SKU"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Name</p>
|
||||
<p className="font-medium">{detail.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">SKU</p>
|
||||
<p className="font-medium">{detail.sku || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Unit</p>
|
||||
<p className="font-medium">{detail.unitOfMeasure}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Quantity On Hand</p>
|
||||
<p className="font-medium">{detail.quantityOnHand}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Cost Price</p>
|
||||
<p className="font-medium">{formatCurrency(detail.costPrice)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Selling Price</p>
|
||||
<p className="font-medium">{formatCurrency(detail.sellingPrice)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">Stock Movements</p>
|
||||
{detail.movements.length === 0 ? (
|
||||
<p className="rounded-lg border p-4 text-center text-sm text-muted-foreground">
|
||||
No GRNs, sales or returns yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y rounded-lg border">
|
||||
{detail.movements.map((m, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between gap-3 p-2.5 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">{MOVEMENT_LABEL[m.type]}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.reference} · {formatDate(m.date)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={m.quantityChange >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
|
||||
{m.quantityChange >= 0 ? "+" : ""}{m.quantityChange}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function InventoryProductsTab() {
|
||||
const productsFetch = useFetch(() => getProducts(true))
|
||||
const products = productsFetch.data ?? []
|
||||
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||
const [dialogMode, setDialogMode] = React.useState<"create" | "edit">("create")
|
||||
const [editTarget, setEditTarget] = React.useState<Product | null>(null)
|
||||
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
|
||||
const [detailProductId, setDetailProductId] = React.useState<string | null>(null)
|
||||
|
||||
async function handleCreate(values: ProductFormValues, imageFile: File | null) {
|
||||
const input: ProductInput = {
|
||||
name: values.name.trim(),
|
||||
unitOfMeasure: values.unitOfMeasure.trim(),
|
||||
sellingPrice: Number(values.sellingPrice) || 0,
|
||||
}
|
||||
const created = await createProduct(input)
|
||||
// Image is optional -- a product can be created with no photo and one added later via edit.
|
||||
if (imageFile) await uploadProductImage(created.productId, imageFile)
|
||||
toast.success("Product created", { description: `"${input.name}" was added.` })
|
||||
productsFetch.reload()
|
||||
}
|
||||
|
||||
async function handleEdit(values: ProductFormValues, imageFile: File | null) {
|
||||
if (!editTarget) return
|
||||
await updateProduct(editTarget.productId, {
|
||||
name: values.name.trim(),
|
||||
unitOfMeasure: values.unitOfMeasure.trim(),
|
||||
sellingPrice: Number(values.sellingPrice) || 0,
|
||||
})
|
||||
if (imageFile) await uploadProductImage(editTarget.productId, imageFile)
|
||||
toast.success("Product updated")
|
||||
productsFetch.reload()
|
||||
}
|
||||
|
||||
function startEdit(product: Product) {
|
||||
setDialogMode("edit")
|
||||
setEditTarget(product)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function toggleStatus(product: Product) {
|
||||
setStatusPendingId(product.productId)
|
||||
try {
|
||||
await runMutation(() => updateProduct(product.productId, { isActive: !product.isActive }))
|
||||
toast.success(product.isActive ? "Product deactivated" : "Product activated")
|
||||
productsFetch.reload()
|
||||
} catch {
|
||||
// runMutation() already toasted the real error.
|
||||
} finally {
|
||||
setStatusPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (productsFetch.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">Products</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Stocked items sold at the gym — snacks, supplements, merchandise.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDialogMode("create")
|
||||
setEditTarget(null)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
Add Product
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border bg-card py-16 text-center">
|
||||
<PackageIcon className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No products yet. Add one to start receiving stock.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{products.map((row) => (
|
||||
<div
|
||||
key={row.productId}
|
||||
className="flex flex-col overflow-hidden rounded-xl border bg-card transition-colors hover:border-primary/40"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="relative aspect-square w-full bg-muted"
|
||||
onClick={() => setDetailProductId(row.productId)}
|
||||
aria-label={`View ${row.name} details`}
|
||||
>
|
||||
{row.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- a base64 data URL from our own API, not a Next-optimizable remote image
|
||||
<img src={row.imageUrl} alt={row.name} className="size-full object-cover" />
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<PackageIcon className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<StatusBadge status={row.isActive ? "success" : "neutral"} className="absolute top-2 right-2">
|
||||
{row.isActive ? "Active" : "Inactive"}
|
||||
</StatusBadge>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2 p-3">
|
||||
<div className="min-w-0 cursor-pointer" onClick={() => setDetailProductId(row.productId)}>
|
||||
<p className="truncate font-medium">{row.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{row.sku || "No SKU"}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{formatCurrency(row.sellingPrice)}</span>
|
||||
<Badge variant={row.quantityOnHand <= 0 ? "destructive" : "outline"}>
|
||||
{row.quantityOnHand} {row.unitOfMeasure}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-end gap-1.5 border-t pt-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" aria-label="View details" onClick={() => setDetailProductId(row.productId)}>
|
||||
<EyeIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>View details</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" aria-label="Edit product" onClick={() => startEdit(row)}>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Edit product</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={row.isActive ? "Deactivate product" : "Activate product"}
|
||||
disabled={statusPendingId === row.productId}
|
||||
onClick={() => toggleStatus(row)}
|
||||
>
|
||||
{statusPendingId === row.productId ? (
|
||||
<Spinner className="size-4" />
|
||||
) : row.isActive ? (
|
||||
<XIcon className="text-muted-foreground" />
|
||||
) : (
|
||||
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{row.isActive ? "Deactivate product" : "Activate product"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfoBanner>
|
||||
Product photos are optional — a product can be created without one and a photo can be
|
||||
added or changed later from Edit.
|
||||
</InfoBanner>
|
||||
|
||||
<ProductDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSubmit={dialogMode === "create" ? handleCreate : handleEdit}
|
||||
mode={dialogMode}
|
||||
initialValues={
|
||||
dialogMode === "edit" && editTarget
|
||||
? {
|
||||
name: editTarget.name,
|
||||
unitOfMeasure: editTarget.unitOfMeasure,
|
||||
sellingPrice: String(editTarget.sellingPrice),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
currentImageUrl={dialogMode === "edit" ? editTarget?.imageUrl : undefined}
|
||||
/>
|
||||
|
||||
<ProductDetailDialog productId={detailProductId} onOpenChange={() => setDetailProductId(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InventoryProductsTab }
|
||||
@@ -0,0 +1,474 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { EyeIcon, PlusIcon, RotateCcwIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { createStockReturn, getGrns, getProducts, getSales, getStockReturn, getStockReturns } from "@/lib/api/inventory"
|
||||
import type { CreateStockReturnInput, ReturnCategory, StockReturn } from "@/lib/models/inventory"
|
||||
import { formatCurrency } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
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 { InfoBanner } from "@/components/hexa/info-banner"
|
||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||
|
||||
const RETURN_CATEGORY_OPTIONS: { label: string; value: ReturnCategory }[] = [
|
||||
{ label: "Expired", value: "Expired" },
|
||||
{ label: "Damaged", value: "Damaged" },
|
||||
{ label: "Lost", value: "Lost" },
|
||||
{ label: "Sent back to supplier", value: "SupplierReturn" },
|
||||
{ label: "Handed back by customer", value: "CustomerReturn" },
|
||||
{ label: "Other", value: "Other" },
|
||||
]
|
||||
|
||||
function categoryLabel(category: ReturnCategory) {
|
||||
return RETURN_CATEGORY_OPTIONS.find((o) => o.value === category)?.label ?? category
|
||||
}
|
||||
|
||||
/** Only "CustomerReturn" adds the item back to stock -- it physically comes back after an
|
||||
* earlier sale already removed it. Every other category removes from stock currently held. */
|
||||
function addsBackToStock(category: ReturnCategory) {
|
||||
return category === "CustomerReturn"
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
interface CreateReturnDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
function CreateReturnDialog({ open, onOpenChange, onCreated }: CreateReturnDialogProps) {
|
||||
const productsFetch = useFetch(() => getProducts(false))
|
||||
const products = productsFetch.data ?? []
|
||||
const salesFetch = useFetch(() => getSales())
|
||||
const sales = salesFetch.data ?? []
|
||||
const grnsFetch = useFetch(() => getGrns())
|
||||
const grns = grnsFetch.data ?? []
|
||||
|
||||
const [productId, setProductId] = React.useState("")
|
||||
const [quantity, setQuantity] = React.useState("1")
|
||||
const [category, setCategory] = React.useState<ReturnCategory>("Damaged")
|
||||
const [linkType, setLinkType] = React.useState<"none" | "sale" | "grn">("none")
|
||||
const [saleId, setSaleId] = React.useState("")
|
||||
const [grnId, setGrnId] = React.useState("")
|
||||
const [notes, setNotes] = React.useState("")
|
||||
const { busy, run } = useMutation()
|
||||
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
if (open) {
|
||||
setProductId("")
|
||||
setQuantity("1")
|
||||
setCategory("Damaged")
|
||||
setLinkType("none")
|
||||
setSaleId("")
|
||||
setGrnId("")
|
||||
setNotes("")
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProduct = products.find((p) => p.productId === productId)
|
||||
const increasesStock = addsBackToStock(category)
|
||||
|
||||
// Only sales/GRNs that actually contain the selected product are worth offering -- most
|
||||
// returns (e.g. damaged shelf stock) trace back to neither, hence "None" in both.
|
||||
const matchingSales = productId ? sales.filter((s) => s.items.some((i) => i.productId === productId)) : []
|
||||
const matchingGrns = productId ? grns.filter((g) => g.items.some((i) => i.productId === productId)) : []
|
||||
|
||||
const canSubmit =
|
||||
!!productId &&
|
||||
Number(quantity) > 0 &&
|
||||
!busy &&
|
||||
(linkType === "none" || (linkType === "sale" && !!saleId) || (linkType === "grn" && !!grnId))
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
const input: CreateStockReturnInput = {
|
||||
productId,
|
||||
quantity: Number(quantity),
|
||||
category,
|
||||
saleId: saleId || undefined,
|
||||
grnId: grnId || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
}
|
||||
try {
|
||||
await run(async () => {
|
||||
await createStockReturn(input)
|
||||
toast.success("Return 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-md">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Record Return / Write-off</DialogTitle>
|
||||
<DialogDescription>
|
||||
Only a customer return adds the item back to stock — every other category removes
|
||||
from stock you currently hold.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>
|
||||
Category <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select items={RETURN_CATEGORY_OPTIONS} value={category} onValueChange={(v) => setCategory((v as ReturnCategory) ?? "Other")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RETURN_CATEGORY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>
|
||||
Product <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
items={products.map((p) => ({ label: p.name, value: p.productId }))}
|
||||
value={productId}
|
||||
onValueChange={(v) => {
|
||||
setProductId(v ?? "")
|
||||
setLinkType("none")
|
||||
setSaleId("")
|
||||
setGrnId("")
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={productsFetch.loading ? "Loading…" : "Select a product"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products.map((p) => (
|
||||
<SelectItem key={p.productId} value={p.productId} disabled={!increasesStock && p.quantityOnHand <= 0}>
|
||||
{p.name} ({p.quantityOnHand} in stock)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{increasesStock && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This adds {quantity || 0} unit(s) back to stock — available even if current stock is 0.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="return-quantity">
|
||||
Quantity <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="return-quantity"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step="0.01"
|
||||
max={increasesStock ? undefined : selectedProduct?.quantityOnHand}
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Linked to</Label>
|
||||
<RadioGroup
|
||||
value={linkType}
|
||||
onValueChange={(v) => {
|
||||
const next = (v as "none" | "sale" | "grn") ?? "none"
|
||||
setLinkType(next)
|
||||
if (next !== "sale") setSaleId("")
|
||||
if (next !== "grn") setGrnId("")
|
||||
}}
|
||||
className="grid-cols-3"
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="none" /> None
|
||||
</label>
|
||||
<label className={cn("flex items-center gap-2 text-sm", !productId && "opacity-50")}>
|
||||
<RadioGroupItem value="sale" disabled={!productId} /> A Sale
|
||||
</label>
|
||||
<label className={cn("flex items-center gap-2 text-sm", !productId && "opacity-50")}>
|
||||
<RadioGroupItem value="grn" disabled={!productId} /> A GRN
|
||||
</label>
|
||||
</RadioGroup>
|
||||
|
||||
{linkType === "sale" && (
|
||||
<Select items={matchingSales.map((s) => ({ label: s.saleNumber, value: s.saleId }))} value={saleId} onValueChange={(v) => setSaleId(v ?? "")}>
|
||||
<SelectTrigger className="mt-1 w-full">
|
||||
<SelectValue placeholder={matchingSales.length ? "Select a sale" : "No sales found for this product"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{matchingSales.map((s) => (
|
||||
<SelectItem key={s.saleId} value={s.saleId}>
|
||||
{s.saleNumber}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{linkType === "grn" && (
|
||||
<Select items={matchingGrns.map((g) => ({ label: g.grnNumber, value: g.grnId }))} value={grnId} onValueChange={(v) => setGrnId(v ?? "")}>
|
||||
<SelectTrigger className="mt-1 w-full">
|
||||
<SelectValue placeholder={matchingGrns.length ? "Select a GRN" : "No GRNs found for this product"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{matchingGrns.map((g) => (
|
||||
<SelectItem key={g.grnId} value={g.grnId}>
|
||||
{g.grnNumber}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional — most returns (e.g. damaged shelf stock) don't trace back to a
|
||||
specific sale or GRN. Only ones containing this product are listed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="return-notes">Notes</Label>
|
||||
<Textarea id="return-notes" maxLength={300} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{busy && <Spinner className="size-4" />}
|
||||
Record Return
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
interface ReturnDetailDialogProps {
|
||||
stockReturnId: string | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function ReturnDetailDialog({ stockReturnId, onOpenChange }: ReturnDetailDialogProps) {
|
||||
const detailFetch = useFetch(() => (stockReturnId ? getStockReturn(stockReturnId) : Promise.resolve(null)))
|
||||
const stockReturn = detailFetch.data
|
||||
|
||||
// This dialog stays mounted across opens (only `stockReturnId` changes) -- useFetch only
|
||||
// runs its fetcher on mount and on an explicit reload(), so a fresh id needs a reload too.
|
||||
React.useEffect(() => {
|
||||
if (stockReturnId) detailFetch.reload()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to the id changing
|
||||
}, [stockReturnId, detailFetch.reload])
|
||||
|
||||
return (
|
||||
<Dialog open={!!stockReturnId} onOpenChange={(open) => !open && onOpenChange(false)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Return Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{detailFetch.loading || !stockReturn ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Product</p>
|
||||
<p className="font-medium">{stockReturn.productName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Stock Impact</p>
|
||||
<p className={addsBackToStock(stockReturn.category) ? "font-medium text-emerald-600 dark:text-emerald-400" : "font-medium text-destructive"}>
|
||||
{addsBackToStock(stockReturn.category) ? "+" : "-"}{stockReturn.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Category</p>
|
||||
<Badge variant="outline">{categoryLabel(stockReturn.category)}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Returned</p>
|
||||
<p className="font-medium">{formatDate(stockReturn.returnedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Related Sale</p>
|
||||
<p className="font-medium">{stockReturn.saleNumber || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Related GRN</p>
|
||||
<p className="font-medium">{stockReturn.grnNumber || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Amount (P&L impact)</p>
|
||||
<p className="font-medium">{formatCurrency(stockReturn.amount)}</p>
|
||||
</div>
|
||||
{stockReturn.notes && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Notes</p>
|
||||
<p className="font-medium">{stockReturn.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function InventoryReturnsTab() {
|
||||
const returnsFetch = useFetch(() => getStockReturns())
|
||||
const returns = returnsFetch.data ?? []
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||
const [detailReturnId, setDetailReturnId] = React.useState<string | null>(null)
|
||||
|
||||
const columns: DataTableColumn<StockReturn>[] = [
|
||||
{
|
||||
key: "product",
|
||||
header: "Product",
|
||||
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">
|
||||
<RotateCcwIcon className="size-4" />
|
||||
</div>
|
||||
<p className="font-medium">{row.productName}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "quantity",
|
||||
header: "Stock Impact",
|
||||
cell: (row) => (
|
||||
<span className={addsBackToStock(row.category) ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
|
||||
{addsBackToStock(row.category) ? "+" : "-"}{row.quantity}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "category", header: "Category", cell: (row) => <Badge variant="outline">{categoryLabel(row.category)}</Badge> },
|
||||
{ key: "reference", header: "Reference", cell: (row) => row.saleNumber || row.grnNumber || "—" },
|
||||
{ key: "returned", header: "Date", cell: (row) => formatDate(row.returnedAt) },
|
||||
{ key: "amount", header: "Amount", cell: (row) => formatCurrency(row.amount) },
|
||||
{
|
||||
key: "actions",
|
||||
header: "Actions",
|
||||
className: "text-center",
|
||||
cell: (row) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" aria-label="View details" onClick={() => setDetailReturnId(row.stockReturnId)}>
|
||||
<EyeIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>View details</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (returnsFetch.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">Returns</h2>
|
||||
<p className="text-sm text-muted-foreground">Stock adjustments — expired, damaged, lost, sent back, or handed back by a customer.</p>
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<PlusIcon />
|
||||
Record Return
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={returns}
|
||||
rowKey={(row) => row.stockReturnId}
|
||||
emptyMessage="No returns recorded yet."
|
||||
onRowClick={(row) => setDetailReturnId(row.stockReturnId)}
|
||||
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">
|
||||
<RotateCcwIcon className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{row.productName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className={addsBackToStock(row.category) ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
|
||||
{addsBackToStock(row.category) ? "+" : "-"}{row.quantity}
|
||||
</span>{" "}
|
||||
· {formatDate(row.returnedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t pt-2.5">
|
||||
<Badge variant="outline">{categoryLabel(row.category)}</Badge>
|
||||
<span className="font-medium">{formatCurrency(row.amount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<InfoBanner>
|
||||
A customer return adds the item back to stock; every other category removes it. The
|
||||
Amount shown is calculated automatically from the product's cost price and booked
|
||||
as an expense in Profit & Loss.
|
||||
</InfoBanner>
|
||||
|
||||
<CreateReturnDialog open={dialogOpen} onOpenChange={setDialogOpen} onCreated={returnsFetch.reload} />
|
||||
<ReturnDetailDialog stockReturnId={detailReturnId} onOpenChange={() => setDetailReturnId(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InventoryReturnsTab }
|
||||
@@ -0,0 +1,448 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { PlusIcon, TruckIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { createSupplier, getSuppliers } from "@/lib/api/inventory"
|
||||
import type { SupplierInput } from "@/lib/models/inventory"
|
||||
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 { Spinner } from "@/components/ui/spinner"
|
||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||
import type { Supplier } from "@/lib/models/inventory"
|
||||
|
||||
interface SupplierFormValues {
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
address: string
|
||||
}
|
||||
|
||||
const EMPTY_VALUES: SupplierFormValues = { name: "", phone: "", email: "", address: "" }
|
||||
|
||||
interface SupplierDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (values: SupplierFormValues) => Promise<void> | void
|
||||
}
|
||||
|
||||
function SupplierDialog({ open, onOpenChange, onSubmit }: SupplierDialogProps) {
|
||||
const [values, setValues] = React.useState<SupplierFormValues>(EMPTY_VALUES)
|
||||
const { busy, run } = useMutation()
|
||||
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open)
|
||||
if (open) setValues(EMPTY_VALUES)
|
||||
}
|
||||
|
||||
function set<K extends keyof SupplierFormValues>(key: K, value: SupplierFormValues[K]) {
|
||||
setValues((v) => ({ ...v, [key]: value }))
|
||||
}
|
||||
|
||||
const canSubmit = !!values.name.trim() && !busy
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
try {
|
||||
await run(() => Promise.resolve(onSubmit(values)))
|
||||
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-md">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Supplier</DialogTitle>
|
||||
<DialogDescription>Who stock is received from on a GRN.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="supplier-name">
|
||||
Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="supplier-name" value={values.name} onChange={(e) => set("name", e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="supplier-phone">Phone</Label>
|
||||
<Input id="supplier-phone" value={values.phone} onChange={(e) => set("phone", e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="supplier-email">Email</Label>
|
||||
<Input id="supplier-email" type="email" value={values.email} onChange={(e) => set("email", e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="supplier-address">Address</Label>
|
||||
<Input id="supplier-address" value={values.address} onChange={(e) => set("address", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{busy && <Spinner className="size-4" />}
|
||||
Add Supplier
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function InventorySuppliersTab() {
|
||||
const suppliersFetch = useFetch(() => getSuppliers(true))
|
||||
const suppliers = suppliersFetch.data ?? []
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||
|
||||
async function handleCreate(values: SupplierFormValues) {
|
||||
const input: SupplierInput = {
|
||||
name: values.name.trim(),
|
||||
phone: values.phone.trim() || undefined,
|
||||
email: values.email.trim() || undefined,
|
||||
address: values.address.trim() || undefined,
|
||||
}
|
||||
await createSupplier(input)
|
||||
toast.success("Supplier added", { description: `"${input.name}" was added.` })
|
||||
suppliersFetch.reload()
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<Supplier>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Supplier",
|
||||
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>
|
||||
<p className="font-medium">{row.name}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "phone", header: "Phone", cell: (row) => row.phone || "—" },
|
||||
{ key: "email", header: "Email", cell: (row) => row.email || "—" },
|
||||
{ key: "address", header: "Address", cell: (row) => row.address || "—" },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
cell: (row) => (
|
||||
<StatusBadge status={row.isActive ? "success" : "neutral"}>
|
||||
{row.isActive ? "Active" : "Inactive"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (suppliersFetch.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">Suppliers</h2>
|
||||
<p className="text-sm text-muted-foreground">Who stock is received from on a GRN.</p>
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<PlusIcon />
|
||||
Add Supplier
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={suppliers}
|
||||
rowKey={(row) => row.supplierId}
|
||||
emptyMessage="No suppliers yet."
|
||||
renderMobileCard={(row) => (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<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.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.phone || row.email || "No contact info"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={row.isActive ? "success" : "neutral"}>
|
||||
{row.isActive ? "Active" : "Inactive"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SupplierDialog open={dialogOpen} onOpenChange={setDialogOpen} onSubmit={handleCreate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InventorySuppliersTab }
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import { InventoryGrnTab } from "@/components/hexa/views/inventory-grn-tab"
|
||||
import { InventoryProductsTab } from "@/components/hexa/views/inventory-products-tab"
|
||||
import { InventoryReturnsTab } from "@/components/hexa/views/inventory-returns-tab"
|
||||
import { InventorySalesTab } from "@/components/hexa/views/inventory-sales-tab"
|
||||
import { InventorySuppliersTab } from "@/components/hexa/views/inventory-suppliers-tab"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
function InventoryView() {
|
||||
return (
|
||||
<Tabs defaultValue="products" className="gap-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="products">Products</TabsTrigger>
|
||||
<TabsTrigger value="suppliers">Suppliers</TabsTrigger>
|
||||
<TabsTrigger value="grn">GRN</TabsTrigger>
|
||||
<TabsTrigger value="sales">Sales</TabsTrigger>
|
||||
<TabsTrigger value="returns">Returns</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="products">
|
||||
<InventoryProductsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="suppliers">
|
||||
<InventorySuppliersTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="grn">
|
||||
<InventoryGrnTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="sales">
|
||||
<InventorySalesTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="returns">
|
||||
<InventoryReturnsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export { InventoryView }
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CalendarClock,
|
||||
CreditCard,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Receipt,
|
||||
Rss,
|
||||
Settings,
|
||||
@@ -27,6 +28,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
CalendarClock,
|
||||
CreditCard,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Receipt,
|
||||
Rss,
|
||||
Settings,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { apiRequest } from "@/lib/api/core-http"
|
||||
import type {
|
||||
CreateGrnInput,
|
||||
CreateSaleInput,
|
||||
CreateStockReturnInput,
|
||||
GoodsReceivedNote,
|
||||
Product,
|
||||
ProductDetail,
|
||||
ProductInput,
|
||||
Sale,
|
||||
StockReturn,
|
||||
Supplier,
|
||||
SupplierInput,
|
||||
} from "@/lib/models/inventory"
|
||||
|
||||
function getProducts(includeInactive = true) {
|
||||
return apiRequest<Product[]>("client/inventory/products", { query: { includeInactive } })
|
||||
}
|
||||
|
||||
function getProduct(id: string) {
|
||||
return apiRequest<ProductDetail>(`client/inventory/products/${id}`)
|
||||
}
|
||||
|
||||
function createProduct(input: ProductInput) {
|
||||
return apiRequest<Product>("client/inventory/products", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function updateProduct(id: string, input: Partial<ProductInput> & { isActive?: boolean }) {
|
||||
return apiRequest<Product>(`client/inventory/products/${id}`, { method: "PUT", body: input })
|
||||
}
|
||||
|
||||
function uploadProductImage(id: string, file: File) {
|
||||
const fd = new FormData()
|
||||
fd.append("image", file)
|
||||
return apiRequest<Product>(`client/inventory/products/${id}/image`, { method: "POST", formData: fd })
|
||||
}
|
||||
|
||||
function getSuppliers(includeInactive = true) {
|
||||
return apiRequest<Supplier[]>("client/inventory/suppliers", { query: { includeInactive } })
|
||||
}
|
||||
|
||||
function createSupplier(input: SupplierInput) {
|
||||
return apiRequest<Supplier>("client/inventory/suppliers", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function getGrns() {
|
||||
return apiRequest<GoodsReceivedNote[]>("client/inventory/grn")
|
||||
}
|
||||
|
||||
function getGrn(id: string) {
|
||||
return apiRequest<GoodsReceivedNote>(`client/inventory/grn/${id}`)
|
||||
}
|
||||
|
||||
function createGrn(input: CreateGrnInput) {
|
||||
return apiRequest<GoodsReceivedNote>("client/inventory/grn", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function getSales() {
|
||||
return apiRequest<Sale[]>("client/inventory/sales")
|
||||
}
|
||||
|
||||
function getSale(id: string) {
|
||||
return apiRequest<Sale>(`client/inventory/sales/${id}`)
|
||||
}
|
||||
|
||||
function createSale(input: CreateSaleInput) {
|
||||
return apiRequest<Sale>("client/inventory/sales", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
function getStockReturns() {
|
||||
return apiRequest<StockReturn[]>("client/inventory/returns")
|
||||
}
|
||||
|
||||
function getStockReturn(id: string) {
|
||||
return apiRequest<StockReturn>(`client/inventory/returns/${id}`)
|
||||
}
|
||||
|
||||
function createStockReturn(input: CreateStockReturnInput) {
|
||||
return apiRequest<StockReturn>("client/inventory/returns", { method: "POST", body: input })
|
||||
}
|
||||
|
||||
export {
|
||||
getProducts,
|
||||
getProduct,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
uploadProductImage,
|
||||
getSuppliers,
|
||||
createSupplier,
|
||||
getGrns,
|
||||
getGrn,
|
||||
createGrn,
|
||||
getSales,
|
||||
getSale,
|
||||
createSale,
|
||||
getStockReturns,
|
||||
getStockReturn,
|
||||
createStockReturn,
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { PaymentMethod } from "@/lib/models/booking"
|
||||
|
||||
/** Mirrors ProductDto. */
|
||||
export interface Product {
|
||||
productId: string
|
||||
name: string
|
||||
/** System-generated at creation (e.g. "PRD-0004") -- never user-entered or editable. */
|
||||
sku?: string | null
|
||||
/** Base64 data URL, or null when no photo has been uploaded -- set via a separate upload call. */
|
||||
imageUrl?: string | null
|
||||
unitOfMeasure: string
|
||||
/** Set from the most recent GRN line's unit cost -- read-only, system-maintained. */
|
||||
costPrice: number
|
||||
sellingPrice: number
|
||||
/** A maintained running balance -- read-only, system-maintained. */
|
||||
quantityOnHand: number
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ProductInput {
|
||||
name: string
|
||||
unitOfMeasure: string
|
||||
sellingPrice: number
|
||||
}
|
||||
|
||||
/** One row in a Product's detail-page stock-movement history. Mirrors ProductMovementDto. */
|
||||
export interface ProductMovement {
|
||||
type: "GRN" | "Sale" | "Return"
|
||||
reference: string
|
||||
/** Signed: positive for a GRN (stock in), negative for a Sale or Return (stock out). */
|
||||
quantityChange: number
|
||||
date: string
|
||||
}
|
||||
|
||||
/** Mirrors ProductDetailDto. */
|
||||
export interface ProductDetail extends Product {
|
||||
movements: ProductMovement[]
|
||||
}
|
||||
|
||||
/** Mirrors SupplierDto. */
|
||||
export interface Supplier {
|
||||
supplierId: string
|
||||
name: string
|
||||
phone?: string | null
|
||||
email?: string | null
|
||||
address?: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface SupplierInput {
|
||||
name: string
|
||||
phone?: string
|
||||
email?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
export interface GoodsReceivedNoteItem {
|
||||
productId: string
|
||||
productName: string
|
||||
quantity: number
|
||||
unitCost: number
|
||||
lineTotal: number
|
||||
}
|
||||
|
||||
/** Mirrors GoodsReceivedNoteDto. */
|
||||
export interface GoodsReceivedNote {
|
||||
grnId: string
|
||||
grnNumber: string
|
||||
/** Null when this GRN was recorded without a supplier. */
|
||||
supplierId?: string | null
|
||||
supplierName?: string | null
|
||||
receivedAt: string
|
||||
notes?: string | null
|
||||
totalAmount: number
|
||||
items: GoodsReceivedNoteItem[]
|
||||
}
|
||||
|
||||
export interface GrnItemInput {
|
||||
productId: string
|
||||
quantity: number
|
||||
unitCost: number
|
||||
}
|
||||
|
||||
export interface CreateGrnInput {
|
||||
/** Optional -- some GRNs don't have a supplier on file. */
|
||||
supplierId?: string
|
||||
receivedAt?: string
|
||||
notes?: string
|
||||
items: GrnItemInput[]
|
||||
}
|
||||
|
||||
export interface SaleItem {
|
||||
productId: string
|
||||
productName: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
lineTotal: number
|
||||
}
|
||||
|
||||
/** Mirrors SaleDto. */
|
||||
export interface Sale {
|
||||
saleId: string
|
||||
saleNumber: string
|
||||
soldAt: string
|
||||
paymentMethod: PaymentMethod
|
||||
customerId?: string | null
|
||||
customerName?: string | null
|
||||
totalAmount: number
|
||||
items: SaleItem[]
|
||||
}
|
||||
|
||||
export interface SaleItemInput {
|
||||
productId: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
}
|
||||
|
||||
export interface CreateSaleInput {
|
||||
soldAt?: string
|
||||
paymentMethod: PaymentMethod
|
||||
customerId?: string
|
||||
customerName?: string
|
||||
items: SaleItemInput[]
|
||||
}
|
||||
|
||||
export type ReturnCategory = "Expired" | "Damaged" | "Lost" | "SupplierReturn" | "CustomerReturn" | "Other"
|
||||
|
||||
/** Mirrors StockReturnDto. Only "CustomerReturn" adds back to stock; every other category
|
||||
* removes from it. */
|
||||
export interface StockReturn {
|
||||
stockReturnId: string
|
||||
productId: string
|
||||
productName: string
|
||||
quantity: number
|
||||
category: ReturnCategory
|
||||
/** Optional traceability link -- which sale this return came from, if any. */
|
||||
saleId?: string | null
|
||||
saleNumber?: string | null
|
||||
/** Optional traceability link -- which GRN this return relates to, if any. */
|
||||
grnId?: string | null
|
||||
grnNumber?: string | null
|
||||
notes?: string | null
|
||||
returnedAt: string
|
||||
/** Quantity * the product's cost price at the moment of the return -- auto-computed. */
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface CreateStockReturnInput {
|
||||
productId: string
|
||||
quantity: number
|
||||
category: ReturnCategory
|
||||
/** Optional traceability link -- e.g. a customer return that came from a specific sale. */
|
||||
saleId?: string
|
||||
/** Optional traceability link -- e.g. stock sent back to the supplier from a specific GRN. */
|
||||
grnId?: string
|
||||
notes?: string
|
||||
returnedAt?: string
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CalendarClock,
|
||||
CreditCard,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Receipt,
|
||||
Rss,
|
||||
Settings,
|
||||
@@ -34,6 +35,7 @@ export const NAV_ITEMS: DashboardNavItem[] = [
|
||||
{ key: "members", label: "Members", href: "/dashboard/members", icon: Users },
|
||||
{ key: "memberships", label: "Memberships", href: "/dashboard/memberships", icon: CreditCard },
|
||||
{ key: "customer-payments", label: "Customer Payments", shortLabel: "Payments", href: "/dashboard/customer-payments", icon: Receipt },
|
||||
{ key: "inventory", label: "Inventory", href: "/dashboard/inventory", icon: Package },
|
||||
{ key: "bookings", label: "Bookings", href: "/dashboard/bookings", icon: CalendarClock },
|
||||
{ key: "workout-plans", label: "Workout Plans", href: "/dashboard/workout-plans", icon: Activity },
|
||||
{ key: "meal-plans", label: "Meal Plans", href: "/dashboard/meal-plans", icon: Utensils },
|
||||
|
||||
Reference in New Issue
Block a user