feat: enhance inventory tabs with statistical cards for better insights
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { EyeIcon, PlusIcon, TrashIcon, TruckIcon } from "lucide-react"
|
import { CalendarClockIcon, DollarSignIcon, EyeIcon, PackageIcon, PlusIcon, TrashIcon, TruckIcon } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { createGrn, getGrn, getGrns, getProducts, getSuppliers } from "@/lib/api/inventory"
|
import { createGrn, getGrn, getGrns, getProducts, getSuppliers } from "@/lib/api/inventory"
|
||||||
@@ -26,6 +26,7 @@ import { Textarea } from "@/components/ui/textarea"
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
function formatDate(iso: string) {
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
@@ -316,6 +317,15 @@ function InventoryGrnTab() {
|
|||||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||||
const [detailGrnId, setDetailGrnId] = React.useState<string | null>(null)
|
const [detailGrnId, setDetailGrnId] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
const totalValue = grns.reduce((sum, g) => sum + g.totalAmount, 0)
|
||||||
|
const now = new Date()
|
||||||
|
const thisMonthGrns = grns.filter((g) => {
|
||||||
|
const d = new Date(g.receivedAt)
|
||||||
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
|
||||||
|
})
|
||||||
|
const thisMonthValue = thisMonthGrns.reduce((sum, g) => sum + g.totalAmount, 0)
|
||||||
|
const avgValue = grns.length ? totalValue / grns.length : 0
|
||||||
|
|
||||||
const columns: DataTableColumn<GoodsReceivedNote>[] = [
|
const columns: DataTableColumn<GoodsReceivedNote>[] = [
|
||||||
{
|
{
|
||||||
key: "grnNumber",
|
key: "grnNumber",
|
||||||
@@ -371,6 +381,13 @@ function InventoryGrnTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard icon={PackageIcon} tone="blue" label="Total GRNs" value={grns.length} />
|
||||||
|
<StatCard icon={DollarSignIcon} tone="purple" label="Total Received Value" value={formatCurrency(totalValue)} />
|
||||||
|
<StatCard icon={CalendarClockIcon} tone="amber" label="This Month" value={formatCurrency(thisMonthValue)} hint={`${thisMonthGrns.length} GRNs`} />
|
||||||
|
<StatCard icon={TruckIcon} tone="green" label="Avg. GRN Value" value={formatCurrency(avgValue)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={grns}
|
rows={grns}
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { CheckIcon, EyeIcon, ImagePlusIcon, PackageIcon, PencilIcon, PlusIcon, XIcon } from "lucide-react"
|
import {
|
||||||
|
CheckCircle2Icon,
|
||||||
|
CheckIcon,
|
||||||
|
DollarSignIcon,
|
||||||
|
EyeIcon,
|
||||||
|
ImagePlusIcon,
|
||||||
|
PackageIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { createProduct, getProduct, getProducts, updateProduct, uploadProductImage } from "@/lib/api/inventory"
|
import { createProduct, getProduct, getProducts, updateProduct, uploadProductImage } from "@/lib/api/inventory"
|
||||||
@@ -25,6 +36,7 @@ import { Spinner } from "@/components/ui/spinner"
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { InfoBanner } from "@/components/hexa/info-banner"
|
import { InfoBanner } from "@/components/hexa/info-banner"
|
||||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
import { StatusBadge } from "@/components/hexa/status-badge"
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||||
|
|
||||||
interface ProductFormValues {
|
interface ProductFormValues {
|
||||||
@@ -324,6 +336,10 @@ function InventoryProductsTab() {
|
|||||||
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
|
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
|
||||||
const [detailProductId, setDetailProductId] = React.useState<string | null>(null)
|
const [detailProductId, setDetailProductId] = React.useState<string | null>(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) {
|
async function handleCreate(values: ProductFormValues, imageFile: File | null) {
|
||||||
const input: ProductInput = {
|
const input: ProductInput = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
@@ -393,6 +409,13 @@ function InventoryProductsTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard icon={PackageIcon} tone="blue" label="Total Products" value={products.length} />
|
||||||
|
<StatCard icon={CheckCircle2Icon} tone="green" label="Active" value={activeProducts} />
|
||||||
|
<StatCard icon={TriangleAlertIcon} tone="amber" label="Out of Stock" value={outOfStock} />
|
||||||
|
<StatCard icon={DollarSignIcon} tone="purple" label="Inventory Value" value={formatCurrency(inventoryValue)} hint="At cost price" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border bg-card py-16 text-center">
|
<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" />
|
<PackageIcon className="size-8 text-muted-foreground" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { EyeIcon, PlusIcon, RotateCcwIcon } from "lucide-react"
|
import { CalendarClockIcon, EyeIcon, PackagePlusIcon, PlusIcon, RotateCcwIcon, TriangleAlertIcon } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { createStockReturn, getGrns, getProducts, getSales, getStockReturn, getStockReturns } from "@/lib/api/inventory"
|
import { createStockReturn, getGrns, getProducts, getSales, getStockReturn, getStockReturns } from "@/lib/api/inventory"
|
||||||
@@ -30,6 +30,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
import { InfoBanner } from "@/components/hexa/info-banner"
|
import { InfoBanner } from "@/components/hexa/info-banner"
|
||||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
|
|
||||||
const RETURN_CATEGORY_OPTIONS: { label: string; value: ReturnCategory }[] = [
|
const RETURN_CATEGORY_OPTIONS: { label: string; value: ReturnCategory }[] = [
|
||||||
{ label: "Expired", value: "Expired" },
|
{ label: "Expired", value: "Expired" },
|
||||||
@@ -367,6 +368,17 @@ function InventoryReturnsTab() {
|
|||||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||||
const [detailReturnId, setDetailReturnId] = React.useState<string | null>(null)
|
const [detailReturnId, setDetailReturnId] = React.useState<string | null>(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<StockReturn>[] = [
|
const columns: DataTableColumn<StockReturn>[] = [
|
||||||
{
|
{
|
||||||
key: "product",
|
key: "product",
|
||||||
@@ -429,6 +441,13 @@ function InventoryReturnsTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard icon={RotateCcwIcon} tone="blue" label="Total Returns" value={returns.length} />
|
||||||
|
<StatCard icon={TriangleAlertIcon} tone="amber" label="Write-off Value" value={formatCurrency(writeOffValue)} />
|
||||||
|
<StatCard icon={PackagePlusIcon} tone="green" label="Customer Returns" value={customerReturns.length} hint="Added back to stock" />
|
||||||
|
<StatCard icon={CalendarClockIcon} tone="purple" label="This Month Write-offs" value={formatCurrency(thisMonthWriteOffValue)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={returns}
|
rows={returns}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { EyeIcon, PlusIcon, ReceiptIcon, TrashIcon } from "lucide-react"
|
import { CalendarClockIcon, DollarSignIcon, EyeIcon, PlusIcon, ReceiptIcon, TrashIcon, TrendingUpIcon } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { createSale, getProducts, getSale, getSales } from "@/lib/api/inventory"
|
import { createSale, getProducts, getSale, getSales } from "@/lib/api/inventory"
|
||||||
@@ -28,6 +28,7 @@ import { Spinner } from "@/components/ui/spinner"
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
|
|
||||||
const PAYMENT_METHOD_OPTIONS: { label: string; value: PaymentMethod }[] = [
|
const PAYMENT_METHOD_OPTIONS: { label: string; value: PaymentMethod }[] = [
|
||||||
{ label: "Cash", value: "Cash" },
|
{ label: "Cash", value: "Cash" },
|
||||||
@@ -358,6 +359,15 @@ function InventorySalesTab() {
|
|||||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||||
const [detailSaleId, setDetailSaleId] = React.useState<string | null>(null)
|
const [detailSaleId, setDetailSaleId] = React.useState<string | null>(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<Sale>[] = [
|
const columns: DataTableColumn<Sale>[] = [
|
||||||
{
|
{
|
||||||
key: "saleNumber",
|
key: "saleNumber",
|
||||||
@@ -414,6 +424,13 @@ function InventorySalesTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard icon={ReceiptIcon} tone="blue" label="Total Sales" value={sales.length} />
|
||||||
|
<StatCard icon={DollarSignIcon} tone="purple" label="Total Revenue" value={formatCurrency(totalRevenue)} />
|
||||||
|
<StatCard icon={CalendarClockIcon} tone="amber" label="Today" value={formatCurrency(todayRevenue)} hint={`${todaySales.length} sales`} />
|
||||||
|
<StatCard icon={TrendingUpIcon} tone="green" label="Avg. Sale Value" value={formatCurrency(avgSaleValue)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={sales}
|
rows={sales}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { ArrowDownCircleIcon, ArrowUpCircleIcon, ListIcon, RotateCcwIcon } from "lucide-react"
|
import { ArrowDownCircleIcon, ArrowUpCircleIcon, ListIcon, ListOrderedIcon, RotateCcwIcon } from "lucide-react"
|
||||||
|
|
||||||
import { getProducts, getStockMovements } from "@/lib/api/inventory"
|
import { getProducts, getStockMovements } from "@/lib/api/inventory"
|
||||||
import type { ReturnCategory, StockMovement } from "@/lib/models/inventory"
|
import type { ReturnCategory, StockMovement } from "@/lib/models/inventory"
|
||||||
@@ -15,6 +15,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
import { InfoBanner } from "@/components/hexa/info-banner"
|
import { InfoBanner } from "@/components/hexa/info-banner"
|
||||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
|
|
||||||
const TYPE_OPTIONS: { label: string; value: "all" | StockMovement["type"] }[] = [
|
const TYPE_OPTIONS: { label: string; value: "all" | StockMovement["type"] }[] = [
|
||||||
{ label: "All types", value: "all" },
|
{ label: "All types", value: "all" },
|
||||||
@@ -92,6 +93,10 @@ function InventoryStockTrackingTab() {
|
|||||||
|
|
||||||
const productOptions = [{ label: "All products", value: "all" }, ...products.map((p) => ({ label: p.name, value: p.productId }))]
|
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<StockMovement>[] = [
|
const columns: DataTableColumn<StockMovement>[] = [
|
||||||
{ key: "date", header: "Date", cell: (row) => formatDate(row.date) },
|
{ key: "date", header: "Date", cell: (row) => formatDate(row.date) },
|
||||||
{
|
{
|
||||||
@@ -138,6 +143,13 @@ function InventoryStockTrackingTab() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard icon={ListOrderedIcon} tone="blue" label="Movements Shown" value={filtered.length} />
|
||||||
|
<StatCard icon={ArrowDownCircleIcon} tone="green" label="Stock In" value={formatCurrency(stockInValue)} hint="GRN value" />
|
||||||
|
<StatCard icon={ArrowUpCircleIcon} tone="purple" label="Stock Out" value={formatCurrency(stockOutValue)} hint="Sales value" />
|
||||||
|
<StatCard icon={RotateCcwIcon} tone="amber" label="Write-offs" value={formatCurrency(writeOffValue)} hint="Returns value" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border bg-card p-3 sm:flex-row sm:items-end sm:flex-wrap">
|
<div className="flex flex-col gap-3 rounded-xl border bg-card p-3 sm:flex-row sm:items-end sm:flex-wrap">
|
||||||
<div className="flex min-w-36 flex-col gap-1.5">
|
<div className="flex min-w-36 flex-col gap-1.5">
|
||||||
<Label>Type</Label>
|
<Label>Type</Label>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { PlusIcon, TruckIcon } from "lucide-react"
|
import { CheckCircle2Icon, PlusIcon, TruckIcon, XCircleIcon } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { createSupplier, getSuppliers } from "@/lib/api/inventory"
|
import { createSupplier, getSuppliers } from "@/lib/api/inventory"
|
||||||
@@ -22,6 +22,7 @@ import { Label } from "@/components/ui/label"
|
|||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
import { StatusBadge } from "@/components/hexa/status-badge"
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||||
import type { Supplier } from "@/lib/models/inventory"
|
import type { Supplier } from "@/lib/models/inventory"
|
||||||
|
|
||||||
@@ -115,6 +116,9 @@ function InventorySuppliersTab() {
|
|||||||
const suppliers = suppliersFetch.data ?? []
|
const suppliers = suppliersFetch.data ?? []
|
||||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||||
|
|
||||||
|
const activeSuppliers = suppliers.filter((s) => s.isActive).length
|
||||||
|
const inactiveSuppliers = suppliers.length - activeSuppliers
|
||||||
|
|
||||||
async function handleCreate(values: SupplierFormValues) {
|
async function handleCreate(values: SupplierFormValues) {
|
||||||
const input: SupplierInput = {
|
const input: SupplierInput = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
@@ -171,6 +175,12 @@ function InventorySuppliersTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<StatCard icon={TruckIcon} tone="blue" label="Total Suppliers" value={suppliers.length} />
|
||||||
|
<StatCard icon={CheckCircle2Icon} tone="green" label="Active" value={activeSuppliers} />
|
||||||
|
<StatCard icon={XCircleIcon} tone="amber" label="Inactive" value={inactiveSuppliers} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={suppliers}
|
rows={suppliers}
|
||||||
|
|||||||
Reference in New Issue
Block a user