+ }
+ />
+
View details
+
+ ),
+ },
+ ]
+
+ if (grnsFetch.loading) {
+ return
+ }
+
+ return (
+
+
+
+
Goods Received (GRN)
+
Stock coming in from a supplier.
+
+
+
+
+
+
+
+
+
+
+
+
row.grnId}
+ emptyMessage="No goods received yet."
+ onRowClick={(row) => setDetailGrnId(row.grnId)}
+ renderMobileCard={(row) => (
+
+
+
+
+
+
+
{row.grnNumber}
+
{row.supplierName || "No supplier"} · {formatDate(row.receivedAt)}
+
+
+
+ {row.items.length} items
+ {formatCurrency(row.totalAmount)}
+
+
+ )}
+ />
+
+
+ setDetailGrnId(null)} />
+
+ )
+}
+
+export { InventoryGrnTab }
diff --git a/src/components/hexa/views/inventory-products-tab.tsx b/src/components/hexa/views/inventory-products-tab.tsx
new file mode 100644
index 0000000..f4974bc
--- /dev/null
+++ b/src/components/hexa/views/inventory-products-tab.tsx
@@ -0,0 +1,539 @@
+"use client"
+
+import * as React from "react"
+import {
+ CheckCircle2Icon,
+ CheckIcon,
+ DollarSignIcon,
+ EyeIcon,
+ ImagePlusIcon,
+ PackageIcon,
+ PencilIcon,
+ PlusIcon,
+ TriangleAlertIcon,
+ 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 { StatCard } from "@/components/hexa/stat-card"
+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
+ mode: "create" | "edit"
+ initialValues?: Partial
+ /** 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(EMPTY_VALUES)
+ const [imageFile, setImageFile] = React.useState(null)
+ const [imagePreview, setImagePreview] = React.useState(null)
+ const fileInputRef = React.useRef(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(key: K, value: ProductFormValues[K]) {
+ setValues((v) => ({ ...v, [key]: value }))
+ }
+
+ function handleFileChange(e: React.ChangeEvent) {
+ 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 (
+
+ )
+}
+
+const MOVEMENT_LABEL: Record = {
+ 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 (
+
+ )
+}
+
+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(null)
+ const [statusPendingId, setStatusPendingId] = React.useState(null)
+ const [detailProductId, setDetailProductId] = React.useState(null)
+
+ const activeProducts = products.filter((p) => p.isActive).length
+ const outOfStock = products.filter((p) => p.quantityOnHand <= 0).length
+ const inventoryValue = products.reduce((sum, p) => sum + p.costPrice * p.quantityOnHand, 0)
+
+ 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
+ }
+
+ return (
+
+
+
+
Products
+
+ Stocked items sold at the gym — snacks, supplements, merchandise.
+
+
+
+
+
+
+
+
+
+
+
+
+ {products.length === 0 ? (
+
+
+
No products yet. Add one to start receiving stock.
+
+ ) : (
+
+ {products.map((row) => (
+
+
+
+
+
setDetailProductId(row.productId)}>
+
{row.name}
+
{row.sku || "No SKU"}
+
+
+ {formatCurrency(row.sellingPrice)}
+
+ {row.quantityOnHand} {row.unitOfMeasure}
+
+
+
+
+
+ setDetailProductId(row.productId)}>
+
+
+ }
+ />
+ View details
+
+
+ startEdit(row)}>
+
+
+ }
+ />
+ Edit product
+
+
+ toggleStatus(row)}
+ >
+ {statusPendingId === row.productId ? (
+
+ ) : row.isActive ? (
+
+ ) : (
+
+ )}
+
+ }
+ />
+ {row.isActive ? "Deactivate product" : "Activate product"}
+
+
+
+
+ ))}
+
+ )}
+
+
+ Product photos are optional — a product can be created without one and a photo can be
+ added or changed later from Edit.
+
+
+
+
+
setDetailProductId(null)} />
+
+ )
+}
+
+export { InventoryProductsTab }
diff --git a/src/components/hexa/views/inventory-returns-tab.tsx b/src/components/hexa/views/inventory-returns-tab.tsx
new file mode 100644
index 0000000..9725953
--- /dev/null
+++ b/src/components/hexa/views/inventory-returns-tab.tsx
@@ -0,0 +1,493 @@
+"use client"
+
+import * as React from "react"
+import { CalendarClockIcon, EyeIcon, PackagePlusIcon, PlusIcon, RotateCcwIcon, TriangleAlertIcon } 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"
+import { StatCard } from "@/components/hexa/stat-card"
+
+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("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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+function InventoryReturnsTab() {
+ const returnsFetch = useFetch(() => getStockReturns())
+ const returns = returnsFetch.data ?? []
+ const [dialogOpen, setDialogOpen] = React.useState(false)
+ const [detailReturnId, setDetailReturnId] = React.useState(null)
+
+ const writeOffs = returns.filter((r) => !addsBackToStock(r.category))
+ const customerReturns = returns.filter((r) => addsBackToStock(r.category))
+ const writeOffValue = writeOffs.reduce((sum, r) => sum + r.amount, 0)
+ const now = new Date()
+ const thisMonthWriteOffValue = writeOffs
+ .filter((r) => {
+ const d = new Date(r.returnedAt)
+ return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
+ })
+ .reduce((sum, r) => sum + r.amount, 0)
+
+ const columns: DataTableColumn[] = [
+ {
+ key: "product",
+ header: "Product",
+ cell: (row) => (
+
+
+
+
+
{row.productName}
+
+ ),
+ },
+ {
+ key: "quantity",
+ header: "Stock Impact",
+ cell: (row) => (
+
+ {addsBackToStock(row.category) ? "+" : "-"}{row.quantity}
+
+ ),
+ },
+ { key: "category", header: "Category", cell: (row) => {categoryLabel(row.category)} },
+ { 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) => (
+
+ setDetailReturnId(row.stockReturnId)}>
+
+
+ }
+ />
+ View details
+
+ ),
+ },
+ ]
+
+ if (returnsFetch.loading) {
+ return
+ }
+
+ return (
+
+
+
+
Returns
+
Stock adjustments — expired, damaged, lost, sent back, or handed back by a customer.
+
+
+
+
+
+
+
+
+
+
+
+
row.stockReturnId}
+ emptyMessage="No returns recorded yet."
+ onRowClick={(row) => setDetailReturnId(row.stockReturnId)}
+ renderMobileCard={(row) => (
+
+
+
+
+
+
+
{row.productName}
+
+
+ {addsBackToStock(row.category) ? "+" : "-"}{row.quantity}
+ {" "}
+ · {formatDate(row.returnedAt)}
+
+
+
+
+ {categoryLabel(row.category)}
+ {formatCurrency(row.amount)}
+
+
+ )}
+ />
+
+
+ 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.
+
+
+
+ setDetailReturnId(null)} />
+
+ )
+}
+
+export { InventoryReturnsTab }
diff --git a/src/components/hexa/views/inventory-sales-tab.tsx b/src/components/hexa/views/inventory-sales-tab.tsx
new file mode 100644
index 0000000..c6140a9
--- /dev/null
+++ b/src/components/hexa/views/inventory-sales-tab.tsx
@@ -0,0 +1,465 @@
+"use client"
+
+import * as React from "react"
+import { CalendarClockIcon, DollarSignIcon, EyeIcon, PlusIcon, ReceiptIcon, TrashIcon, TrendingUpIcon } 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"
+import { StatCard } from "@/components/hexa/stat-card"
+
+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("Cash")
+ const [lines, setLines] = React.useState([{ 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) {
+ 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((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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+function InventorySalesTab() {
+ const salesFetch = useFetch(() => getSales())
+ const sales = salesFetch.data ?? []
+ const [dialogOpen, setDialogOpen] = React.useState(false)
+ const [detailSaleId, setDetailSaleId] = React.useState(null)
+
+ const totalRevenue = sales.reduce((sum, s) => sum + s.totalAmount, 0)
+ const today = new Date()
+ const todaySales = sales.filter((s) => {
+ const d = new Date(s.soldAt)
+ return d.toDateString() === today.toDateString()
+ })
+ const todayRevenue = todaySales.reduce((sum, s) => sum + s.totalAmount, 0)
+ const avgSaleValue = sales.length ? totalRevenue / sales.length : 0
+
+ const columns: DataTableColumn[] = [
+ {
+ key: "saleNumber",
+ header: "Sale",
+ cell: (row) => (
+
+
+
+
+
+
{row.saleNumber}
+
{row.customerName || "Walk-in"}
+
+
+ ),
+ },
+ { key: "sold", header: "Sold", cell: (row) => formatDate(row.soldAt) },
+ { key: "items", header: "Items", cell: (row) => row.items.length },
+ { key: "method", header: "Payment", cell: (row) => {row.paymentMethod} },
+ { key: "total", header: "Total", cell: (row) => formatCurrency(row.totalAmount) },
+ {
+ key: "actions",
+ header: "Actions",
+ className: "text-center",
+ cell: (row) => (
+
+ setDetailSaleId(row.saleId)}>
+
+
+ }
+ />
+ View details
+
+ ),
+ },
+ ]
+
+ if (salesFetch.loading) {
+ return
+ }
+
+ return (
+
+
+
+
Sales
+
Stock going out to a customer.
+
+
+
+
+
+
+
+
+
+
+
+
row.saleId}
+ emptyMessage="No sales yet."
+ onRowClick={(row) => setDetailSaleId(row.saleId)}
+ renderMobileCard={(row) => (
+
+
+
+
+
+
+
{row.saleNumber}
+
{row.customerName || "Walk-in"} · {formatDate(row.soldAt)}
+
+
+
+ {row.paymentMethod}
+ {formatCurrency(row.totalAmount)}
+
+
+ )}
+ />
+
+
+ setDetailSaleId(null)} />
+
+ )
+}
+
+export { InventorySalesTab }
diff --git a/src/components/hexa/views/inventory-stock-tracking-tab.tsx b/src/components/hexa/views/inventory-stock-tracking-tab.tsx
new file mode 100644
index 0000000..5a7ee1a
--- /dev/null
+++ b/src/components/hexa/views/inventory-stock-tracking-tab.tsx
@@ -0,0 +1,256 @@
+"use client"
+
+import * as React from "react"
+import { ArrowDownCircleIcon, ArrowUpCircleIcon, ListIcon, ListOrderedIcon, RotateCcwIcon } from "lucide-react"
+
+import { getProducts, getStockMovements } from "@/lib/api/inventory"
+import type { ReturnCategory, StockMovement } from "@/lib/models/inventory"
+import { formatCurrency } from "@/lib/format"
+import { useFetch } from "@/lib/use-fetch"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
+import { InfoBanner } from "@/components/hexa/info-banner"
+import { PageSkeleton } from "@/components/hexa/page-skeleton"
+import { StatCard } from "@/components/hexa/stat-card"
+
+const TYPE_OPTIONS: { label: string; value: "all" | StockMovement["type"] }[] = [
+ { label: "All types", value: "all" },
+ { label: "Stock in (GRN)", value: "GRN" },
+ { label: "Stock out (Sale)", value: "Sale" },
+ { label: "Returns / write-offs", value: "Return" },
+]
+
+const CATEGORY_OPTIONS: { label: string; value: "all" | ReturnCategory }[] = [
+ { label: "All categories", value: "all" },
+ { 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 CATEGORY_OPTIONS.find((o) => o.value === category)?.label ?? category
+}
+
+function typeLabel(type: StockMovement["type"]) {
+ return TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type
+}
+
+function formatDate(iso: string) {
+ return new Date(iso).toLocaleString(undefined, {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+}
+
+function typeIcon(type: StockMovement["type"]) {
+ if (type === "GRN") return
+ if (type === "Sale") return
+ return
+}
+
+function InventoryStockTrackingTab() {
+ const movementsFetch = useFetch(() => getStockMovements())
+ const movements = movementsFetch.data ?? []
+ const productsFetch = useFetch(() => getProducts(true))
+ const products = productsFetch.data ?? []
+
+ const [typeFilter, setTypeFilter] = React.useState<"all" | StockMovement["type"]>("all")
+ const [productFilter, setProductFilter] = React.useState("all")
+ const [categoryFilter, setCategoryFilter] = React.useState<"all" | ReturnCategory>("all")
+ const [dateFrom, setDateFrom] = React.useState("")
+ const [dateTo, setDateTo] = React.useState("")
+
+ const hasActiveFilters =
+ typeFilter !== "all" || productFilter !== "all" || categoryFilter !== "all" || !!dateFrom || !!dateTo
+
+ function resetFilters() {
+ setTypeFilter("all")
+ setProductFilter("all")
+ setCategoryFilter("all")
+ setDateFrom("")
+ setDateTo("")
+ }
+
+ // Shows every stock movement that ever happened by default -- all filters are opt-in.
+ const filtered = movements.filter((m) => {
+ if (typeFilter !== "all" && m.type !== typeFilter) return false
+ if (productFilter !== "all" && m.productId !== productFilter) return false
+ if (categoryFilter !== "all" && m.category !== categoryFilter) return false
+ if (dateFrom && new Date(m.date) < new Date(dateFrom)) return false
+ if (dateTo && new Date(m.date) > new Date(`${dateTo}T23:59:59`)) return false
+ return true
+ })
+
+ const productOptions = [{ label: "All products", value: "all" }, ...products.map((p) => ({ label: p.name, value: p.productId }))]
+
+ const stockInValue = filtered.filter((m) => m.type === "GRN").reduce((sum, m) => sum + m.amount, 0)
+ const stockOutValue = filtered.filter((m) => m.type === "Sale").reduce((sum, m) => sum + m.amount, 0)
+ const writeOffValue = filtered.filter((m) => m.type === "Return" && m.quantityChange < 0).reduce((sum, m) => sum + m.amount, 0)
+
+ const columns: DataTableColumn[] = [
+ { key: "date", header: "Date", cell: (row) => formatDate(row.date) },
+ {
+ key: "type",
+ header: "Type",
+ cell: (row) => (
+
+ {typeIcon(row.type)}
+ {typeLabel(row.type)}
+
+ ),
+ },
+ { key: "product", header: "Product", cell: (row) => row.productName },
+ { key: "reference", header: "Reference", cell: (row) => row.reference },
+ {
+ key: "category",
+ header: "Category",
+ cell: (row) => (row.category ? {categoryLabel(row.category)} : "—"),
+ },
+ {
+ key: "quantity",
+ header: "Quantity",
+ cell: (row) => (
+ = 0 ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
+ {row.quantityChange >= 0 ? "+" : ""}
+ {row.quantityChange}
+
+ ),
+ },
+ { key: "amount", header: "Amount", cell: (row) => formatCurrency(row.amount) },
+ ]
+
+ if (movementsFetch.loading) {
+ return
+ }
+
+ return (
+
+
+
Stock Tracking
+
+ Every stock movement that ever happened — GRNs in, sales out, and returns — in one
+ ledger. Shows everything by default; use the filters to narrow it down.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setDateFrom(e.target.value)} />
+
+
+
+ setDateTo(e.target.value)} />
+
+
+ {hasActiveFilters && (
+
+ )}
+
+
+
`${row.type}-${row.productId}-${row.reference}-${row.date}-${row.amount}`}
+ emptyMessage={hasActiveFilters ? "No movements match these filters." : "No stock movements recorded yet."}
+ renderMobileCard={(row) => (
+
+
+
+ {typeIcon(row.type)}
+
+
+
{row.productName}
+
+ {typeLabel(row.type)} · {row.reference} · {formatDate(row.date)}
+
+
+
+
+ = 0 ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
+ {row.quantityChange >= 0 ? "+" : ""}
+ {row.quantityChange}
+
+ {formatCurrency(row.amount)}
+
+
+ )}
+ />
+
+
+
+ Read-only — this tab is a combined view of the GRN, Sales and Returns tabs. To record a
+ new movement, use the relevant tab directly.
+
+
+ )
+}
+
+export { InventoryStockTrackingTab }
diff --git a/src/components/hexa/views/inventory-suppliers-tab.tsx b/src/components/hexa/views/inventory-suppliers-tab.tsx
new file mode 100644
index 0000000..cfd010a
--- /dev/null
+++ b/src/components/hexa/views/inventory-suppliers-tab.tsx
@@ -0,0 +1,214 @@
+"use client"
+
+import * as React from "react"
+import { CheckCircle2Icon, PlusIcon, TruckIcon, XCircleIcon } 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 { StatCard } from "@/components/hexa/stat-card"
+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
+}
+
+function SupplierDialog({ open, onOpenChange, onSubmit }: SupplierDialogProps) {
+ const [values, setValues] = React.useState(EMPTY_VALUES)
+ const { busy, run } = useMutation()
+ const [prevOpen, setPrevOpen] = React.useState(open)
+
+ if (open !== prevOpen) {
+ setPrevOpen(open)
+ if (open) setValues(EMPTY_VALUES)
+ }
+
+ function set(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 (
+
+ )
+}
+
+function InventorySuppliersTab() {
+ const suppliersFetch = useFetch(() => getSuppliers(true))
+ const suppliers = suppliersFetch.data ?? []
+ const [dialogOpen, setDialogOpen] = React.useState(false)
+
+ const activeSuppliers = suppliers.filter((s) => s.isActive).length
+ const inactiveSuppliers = suppliers.length - activeSuppliers
+
+ 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[] = [
+ {
+ key: "name",
+ header: "Supplier",
+ cell: (row) => (
+
+ ),
+ },
+ { 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) => (
+
+ {row.isActive ? "Active" : "Inactive"}
+
+ ),
+ },
+ ]
+
+ if (suppliersFetch.loading) {
+ return
+ }
+
+ return (
+
+
+
+
Suppliers
+
Who stock is received from on a GRN.
+
+
+
+
+
+
+
+
+
+
+
row.supplierId}
+ emptyMessage="No suppliers yet."
+ renderMobileCard={(row) => (
+
+
+
+
+
+
+
+
{row.name}
+
{row.phone || row.email || "No contact info"}
+
+
+
+ {row.isActive ? "Active" : "Inactive"}
+
+
+
+ )}
+ />
+
+
+
+ )
+}
+
+export { InventorySuppliersTab }
diff --git a/src/components/hexa/views/inventory-view.tsx b/src/components/hexa/views/inventory-view.tsx
new file mode 100644
index 0000000..ab87120
--- /dev/null
+++ b/src/components/hexa/views/inventory-view.tsx
@@ -0,0 +1,45 @@
+"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 { InventoryStockTrackingTab } from "@/components/hexa/views/inventory-stock-tracking-tab"
+import { InventorySuppliersTab } from "@/components/hexa/views/inventory-suppliers-tab"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+
+function InventoryView() {
+ return (
+
+
+ Products
+ Suppliers
+ GRN
+ Sales
+ Returns
+ Stock Tracking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export { InventoryView }
diff --git a/src/components/hexa/views/memberships-view.tsx b/src/components/hexa/views/memberships-view.tsx
index d9e97a8..d581947 100644
--- a/src/components/hexa/views/memberships-view.tsx
+++ b/src/components/hexa/views/memberships-view.tsx
@@ -1,5 +1,6 @@
"use client"
+import { ClassesView } from "@/components/hexa/views/classes-view"
import { FacilitiesView } from "@/components/hexa/views/facilities-view"
import { GymPlansView } from "@/components/hexa/views/gym-plans-view"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -10,6 +11,7 @@ function MembershipsView() {
Plans
Facilities
+ Classes
@@ -18,6 +20,9 @@ function MembershipsView() {
+
+
+
)
}
diff --git a/src/components/hexa/views/adhoc-booking-tab.tsx b/src/components/hexa/views/walkin-booking-tab.tsx
similarity index 77%
rename from src/components/hexa/views/adhoc-booking-tab.tsx
rename to src/components/hexa/views/walkin-booking-tab.tsx
index 3e70807..f515dc0 100644
--- a/src/components/hexa/views/adhoc-booking-tab.tsx
+++ b/src/components/hexa/views/walkin-booking-tab.tsx
@@ -5,7 +5,7 @@ import { CopyIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
import { getFacilityAvailability } from "@/lib/api/facilities"
-import { createGuestAdhocBooking } from "@/lib/api/bookings"
+import { createGuestWalkInBooking } from "@/lib/api/bookings"
import { getMembers } from "@/lib/api/customers"
import { ApiError } from "@/lib/api/core-http"
import type { FacilityAvailability } from "@/lib/models/facility"
@@ -32,18 +32,44 @@ function toLocalInputValue(date: Date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
-interface AdhocBookingTabProps {
+interface WalkInBookingTabProps {
facilities: Facility[]
+ /** Set when the Dashboard calendar's drag-to-quick-book hands up a dragged time range --
+ * seeds the start/duration fields once, then must be cleared by the caller via
+ * onPrefillConsumed so it isn't re-applied on an unrelated re-render. */
+ prefill?: { startTime: string; endTime: string } | null
+ onPrefillConsumed?: () => void
}
-function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
- const adhocFacilities = facilities.filter((f) => f.bookingMode === "AdHoc" && f.isActive)
+function WalkInBookingTab({ facilities, prefill, onPrefillConsumed }: WalkInBookingTabProps) {
+ const walkInFacilities = facilities.filter((f) => f.supportsBooking && f.isActive)
- const [facilityId, setFacilityId] = React.useState(adhocFacilities[0]?.facilityId ?? "")
+ const [facilityId, setFacilityId] = React.useState(walkInFacilities[0]?.facilityId ?? "")
const [startTime, setStartTime] = React.useState(() => toLocalInputValue(new Date()))
const [hours, setHours] = React.useState("1")
const [minutes, setMinutes] = React.useState("0")
- const [pricePerHour, setPricePerHour] = React.useState(() => String(adhocFacilities[0]?.pricePerHour ?? 0))
+
+ // Same "reset on prop change" convention used by every dialog's prevOpen/prevBooking state in
+ // this codebase -- a plain render-body comparison, not an effect, so it can set local state
+ // directly without a cascading-render lint warning.
+ const [prevPrefill, setPrevPrefill] = React.useState(prefill)
+ if (prefill !== prevPrefill) {
+ setPrevPrefill(prefill)
+ if (prefill) {
+ setStartTime(toLocalInputValue(new Date(prefill.startTime)))
+ const totalMinutes = Math.max(15, Math.round((new Date(prefill.endTime).getTime() - new Date(prefill.startTime).getTime()) / 60000))
+ setHours(String(Math.floor(totalMinutes / 60)))
+ setMinutes(String(totalMinutes % 60))
+ }
+ }
+
+ // Notifying the parent to clear its draft is a genuine side effect (a different component's
+ // state), so it stays in an effect rather than the render body above.
+ React.useEffect(() => {
+ if (prefill) onPrefillConsumed?.()
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- only fire when a new prefill arrives
+ }, [prefill])
+ const [pricePerHour, setPricePerHour] = React.useState(() => String(walkInFacilities[0]?.pricePerHour ?? 0))
const [partySize, setPartySize] = React.useState("")
const [customerId, setCustomerId] = React.useState("")
const [guestName, setGuestName] = React.useState("")
@@ -73,14 +99,14 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
const durationMinutes = (Number(hours) || 0) * 60 + (Number(minutes) || 0)
const bookingPrice = ((Number(pricePerHour) || 0) * durationMinutes) / 60
- const facilityOptions = adhocFacilities.map((f) => ({ label: f.name, value: f.facilityId }))
+ const facilityOptions = walkInFacilities.map((f) => ({ label: f.name, value: f.facilityId }))
const canCheck = !!facilityId && !!startTime && durationMinutes >= 1 && !checking
const canSubmit = canCheck && Number(pricePerHour) >= 0 && !!guestName.trim() && !!guestPhone.trim() && !booking
function selectFacility(id: string) {
setFacilityId(id)
setAvailability(null)
- const facility = adhocFacilities.find((f) => f.facilityId === id)
+ const facility = walkInFacilities.find((f) => f.facilityId === id)
setPricePerHour(String(facility?.pricePerHour ?? 0))
}
@@ -107,7 +133,7 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
if (!canSubmit) return
try {
const bookingResult = await runBook(() =>
- createGuestAdhocBooking({
+ createGuestWalkInBooking({
facilityId,
startTime: new Date(startTime).toISOString(),
durationMinutes,
@@ -139,10 +165,10 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
}
}
- if (adhocFacilities.length === 0) {
+ if (walkInFacilities.length === 0) {
return (
- No ad-hoc facilities yet. Add a facility with Booking Mode set to "Ad-hoc" under
+ No walk-in facilities yet. Add a facility and check "Walk-in booking" under
Memberships → Facilities (e.g. courts or a pool with multiple units) to book here.
)
@@ -170,11 +196,11 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {