Add more columns and column selector to product table
This commit is contained in:
@@ -2,7 +2,6 @@ import { Home, Package, ShoppingCart, BarChart2, Settings, Box, ClipboardList }
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
|
||||
@@ -19,19 +19,62 @@ interface Product {
|
||||
price: number;
|
||||
regular_price: number;
|
||||
cost_price: number;
|
||||
landing_cost_price: number;
|
||||
barcode: string;
|
||||
vendor: string;
|
||||
vendor_reference: string;
|
||||
brand: string;
|
||||
categories: string[];
|
||||
image: string | null;
|
||||
moq: number;
|
||||
uom: number;
|
||||
visible: boolean;
|
||||
managing_stock: boolean;
|
||||
image?: string;
|
||||
replenishable: boolean;
|
||||
|
||||
// Metrics
|
||||
daily_sales_avg?: number;
|
||||
weekly_sales_avg?: number;
|
||||
monthly_sales_avg?: number;
|
||||
avg_quantity_per_order?: number;
|
||||
number_of_orders?: number;
|
||||
first_sale_date?: string;
|
||||
last_sale_date?: string;
|
||||
days_of_inventory?: number;
|
||||
weeks_of_inventory?: number;
|
||||
reorder_point?: number;
|
||||
safety_stock?: number;
|
||||
avg_margin_percent?: number;
|
||||
total_revenue?: number;
|
||||
inventory_value?: number;
|
||||
cost_of_goods_sold?: number;
|
||||
gross_profit?: number;
|
||||
gmroi?: number;
|
||||
avg_lead_time_days?: number;
|
||||
last_purchase_date?: string;
|
||||
last_received_date?: string;
|
||||
abc_class?: string;
|
||||
stock_status?: string;
|
||||
turnover_rate?: number;
|
||||
current_lead_time?: number;
|
||||
target_lead_time?: number;
|
||||
lead_time_status?: string;
|
||||
}
|
||||
|
||||
interface ColumnDef {
|
||||
key: keyof Product;
|
||||
label: string;
|
||||
group: string;
|
||||
format?: (value: any) => string | number;
|
||||
}
|
||||
|
||||
interface ProductTableProps {
|
||||
products: Product[];
|
||||
onSort: (column: keyof Product) => void;
|
||||
sortColumn: keyof Product | null;
|
||||
sortColumn: keyof Product;
|
||||
sortDirection: 'asc' | 'desc';
|
||||
visibleColumns: Set<keyof Product>;
|
||||
columnDefs: ColumnDef[];
|
||||
}
|
||||
|
||||
export function ProductTable({
|
||||
@@ -39,6 +82,8 @@ export function ProductTable({
|
||||
onSort,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
visibleColumns,
|
||||
columnDefs,
|
||||
}: ProductTableProps) {
|
||||
const getSortIcon = (column: keyof Product) => {
|
||||
if (sortColumn !== column) return <ArrowUpDown className="ml-2 h-4 w-4" />;
|
||||
@@ -49,149 +94,135 @@ export function ProductTable({
|
||||
);
|
||||
};
|
||||
|
||||
const getStockStatus = (quantity: number) => {
|
||||
if (quantity === 0) {
|
||||
return <Badge variant="destructive">Out of Stock</Badge>;
|
||||
const getStockStatus = (status: string | undefined) => {
|
||||
if (!status) return null;
|
||||
switch (status.toLowerCase()) {
|
||||
case 'critical':
|
||||
return <Badge variant="destructive">Critical</Badge>;
|
||||
case 'reorder':
|
||||
return <Badge variant="outline">Reorder</Badge>;
|
||||
case 'healthy':
|
||||
return <Badge variant="secondary">Healthy</Badge>;
|
||||
case 'overstocked':
|
||||
return <Badge variant="secondary">Overstocked</Badge>;
|
||||
case 'new':
|
||||
return <Badge variant="default">New</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
if (quantity <= 5) {
|
||||
return <Badge variant="outline">Low Stock</Badge>;
|
||||
}
|
||||
return <Badge variant="secondary">In Stock</Badge>;
|
||||
};
|
||||
|
||||
const getABCClass = (abcClass: string | undefined) => {
|
||||
if (!abcClass) return null;
|
||||
switch (abcClass.toUpperCase()) {
|
||||
case 'A':
|
||||
return <Badge variant="default">A</Badge>;
|
||||
case 'B':
|
||||
return <Badge variant="secondary">B</Badge>;
|
||||
case 'C':
|
||||
return <Badge variant="outline">C</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getLeadTimeStatus = (status: string | undefined) => {
|
||||
if (!status) return null;
|
||||
switch (status.toLowerCase()) {
|
||||
case 'critical':
|
||||
return <Badge variant="destructive">Critical</Badge>;
|
||||
case 'warning':
|
||||
return <Badge variant="secondary">Warning</Badge>;
|
||||
case 'good':
|
||||
return <Badge variant="default">Good</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatColumnValue = (product: Product, column: ColumnDef) => {
|
||||
const value = product[column.key];
|
||||
|
||||
// Special formatting for specific columns
|
||||
switch (column.key) {
|
||||
case 'title':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={product.image || undefined} alt={product.title} />
|
||||
<AvatarFallback>{product.title.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{value as string}</span>
|
||||
</div>
|
||||
);
|
||||
case 'categories':
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from(new Set(value as string[])).map((category) => (
|
||||
<Badge key={`${product.product_id}-${category}`} variant="outline">{category}</Badge>
|
||||
)) || '-'}
|
||||
</div>
|
||||
);
|
||||
case 'stock_status':
|
||||
return getStockStatus(value as string);
|
||||
case 'abc_class':
|
||||
return getABCClass(value as string);
|
||||
case 'lead_time_status':
|
||||
return getLeadTimeStatus(value as string);
|
||||
case 'visible':
|
||||
return value ? (
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Hidden</Badge>
|
||||
);
|
||||
default:
|
||||
if (column.format && value !== undefined && value !== null) {
|
||||
return column.format(value);
|
||||
}
|
||||
return value || '-';
|
||||
}
|
||||
};
|
||||
|
||||
// Get visible column definitions in order
|
||||
const visibleColumnDefs = columnDefs.filter(col => visibleColumns.has(col.key));
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onSort('title')}
|
||||
>
|
||||
Product
|
||||
{getSortIcon('title')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onSort('sku')}
|
||||
>
|
||||
SKU
|
||||
{getSortIcon('sku')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onSort('stock_quantity')}
|
||||
>
|
||||
Stock
|
||||
{getSortIcon('stock_quantity')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onSort('price')}
|
||||
>
|
||||
Price
|
||||
{getSortIcon('price')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onSort('regular_price')}
|
||||
>
|
||||
Regular Price
|
||||
{getSortIcon('regular_price')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onSort('cost_price')}
|
||||
>
|
||||
Cost
|
||||
{getSortIcon('cost_price')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onSort('vendor')}
|
||||
>
|
||||
Vendor
|
||||
{getSortIcon('vendor')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onSort('brand')}
|
||||
>
|
||||
Brand
|
||||
{getSortIcon('brand')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onSort('categories')}
|
||||
>
|
||||
Categories
|
||||
{getSortIcon('categories')}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
{visibleColumnDefs.map((column) => (
|
||||
<TableHead key={column.key}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onSort(column.key)}
|
||||
>
|
||||
{column.label}
|
||||
{getSortIcon(column.key)}
|
||||
</Button>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map((product) => (
|
||||
<TableRow key={product.product_id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={product.image} alt={product.title} />
|
||||
<AvatarFallback>{product.title.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{product.title}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{product.sku}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{product.stock_quantity}</span>
|
||||
{getStockStatus(product.stock_quantity)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>${product.price.toFixed(2)}</TableCell>
|
||||
<TableCell>${product.regular_price.toFixed(2)}</TableCell>
|
||||
<TableCell>${product.cost_price.toFixed(2)}</TableCell>
|
||||
<TableCell>{product.vendor || '-'}</TableCell>
|
||||
<TableCell>{product.brand || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{product.categories?.map((category) => (
|
||||
<Badge key={category} variant="outline">{category}</Badge>
|
||||
)) || '-'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{product.visible ? (
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Hidden</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{products.map((product) => {
|
||||
console.log('Rendering product:', product.product_id, product.title, product.categories);
|
||||
return (
|
||||
<TableRow key={product.product_id}>
|
||||
{visibleColumnDefs.map((column) => (
|
||||
<TableCell key={`${product.product_id}-${column.key}`}>
|
||||
{formatColumnValue(product, column)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{!products.length && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell
|
||||
colSpan={visibleColumnDefs.length}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
No products found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
199
inventory/src/components/ui/dropdown-menu.tsx
Normal file
199
inventory/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -12,9 +12,21 @@ import {
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import config from '../config';
|
||||
|
||||
// Enhanced Product interface with all possible fields
|
||||
interface Product {
|
||||
// Basic product info (from products table)
|
||||
product_id: string;
|
||||
title: string;
|
||||
sku: string;
|
||||
@@ -22,12 +34,46 @@ interface Product {
|
||||
price: number;
|
||||
regular_price: number;
|
||||
cost_price: number;
|
||||
landing_cost_price: number;
|
||||
barcode: string;
|
||||
vendor: string;
|
||||
vendor_reference: string;
|
||||
brand: string;
|
||||
categories: string[];
|
||||
image: string | null;
|
||||
moq: number;
|
||||
uom: number;
|
||||
visible: boolean;
|
||||
managing_stock: boolean;
|
||||
image: string | null;
|
||||
replenishable: boolean;
|
||||
|
||||
// Metrics (from product_metrics table)
|
||||
daily_sales_avg?: number;
|
||||
weekly_sales_avg?: number;
|
||||
monthly_sales_avg?: number;
|
||||
avg_quantity_per_order?: number;
|
||||
number_of_orders?: number;
|
||||
first_sale_date?: string;
|
||||
last_sale_date?: string;
|
||||
days_of_inventory?: number;
|
||||
weeks_of_inventory?: number;
|
||||
reorder_point?: number;
|
||||
safety_stock?: number;
|
||||
avg_margin_percent?: number;
|
||||
total_revenue?: number;
|
||||
inventory_value?: number;
|
||||
cost_of_goods_sold?: number;
|
||||
gross_profit?: number;
|
||||
gmroi?: number;
|
||||
avg_lead_time_days?: number;
|
||||
last_purchase_date?: string;
|
||||
last_received_date?: string;
|
||||
abc_class?: string;
|
||||
stock_status?: string;
|
||||
turnover_rate?: number;
|
||||
current_lead_time?: number;
|
||||
target_lead_time?: number;
|
||||
lead_time_status?: string;
|
||||
}
|
||||
|
||||
interface ProductFiltersState {
|
||||
@@ -39,6 +85,82 @@ interface ProductFiltersState {
|
||||
maxPrice: string;
|
||||
}
|
||||
|
||||
// Column definition interface
|
||||
interface ColumnDef {
|
||||
key: keyof Product;
|
||||
label: string;
|
||||
group: string;
|
||||
format?: (value: any) => string | number;
|
||||
}
|
||||
|
||||
// Define available columns with grouping
|
||||
const AVAILABLE_COLUMNS: ColumnDef[] = [
|
||||
// Basic Info Group
|
||||
{ key: 'title', label: 'Title', group: 'Basic Info' },
|
||||
{ key: 'sku', label: 'SKU', group: 'Basic Info' },
|
||||
{ key: 'brand', label: 'Brand', group: 'Basic Info' },
|
||||
{ key: 'categories', label: 'Categories', group: 'Basic Info' },
|
||||
{ key: 'vendor', label: 'Vendor', group: 'Basic Info' },
|
||||
{ key: 'vendor_reference', label: 'Vendor Reference', group: 'Basic Info' },
|
||||
{ key: 'barcode', label: 'Barcode', group: 'Basic Info' },
|
||||
|
||||
// Inventory Group
|
||||
{ key: 'stock_quantity', label: 'Stock Quantity', group: 'Inventory' },
|
||||
{ key: 'stock_status', label: 'Stock Status', group: 'Inventory' },
|
||||
{ key: 'days_of_inventory', label: 'Days of Inventory', group: 'Inventory' },
|
||||
{ key: 'reorder_point', label: 'Reorder Point', group: 'Inventory' },
|
||||
{ key: 'safety_stock', label: 'Safety Stock', group: 'Inventory' },
|
||||
{ key: 'moq', label: 'MOQ', group: 'Inventory' },
|
||||
{ key: 'uom', label: 'UOM', group: 'Inventory' },
|
||||
|
||||
// Pricing Group
|
||||
{ key: 'price', label: 'Price', group: 'Pricing', format: (v) => v.toFixed(2) },
|
||||
{ key: 'regular_price', label: 'Regular Price', group: 'Pricing', format: (v) => v.toFixed(2) },
|
||||
{ key: 'cost_price', label: 'Cost Price', group: 'Pricing', format: (v) => v.toFixed(2) },
|
||||
{ key: 'landing_cost_price', label: 'Landing Cost', group: 'Pricing', format: (v) => v.toFixed(2) },
|
||||
|
||||
// Sales Metrics Group
|
||||
{ key: 'daily_sales_avg', label: 'Daily Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||
{ key: 'weekly_sales_avg', label: 'Weekly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||
{ key: 'monthly_sales_avg', label: 'Monthly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||
{ key: 'avg_quantity_per_order', label: 'Avg Qty per Order', group: 'Sales Metrics', format: (v) => v?.toFixed(1) ?? '-' },
|
||||
{ key: 'number_of_orders', label: 'Number of Orders', group: 'Sales Metrics' },
|
||||
{ key: 'first_sale_date', label: 'First Sale', group: 'Sales Metrics' },
|
||||
{ key: 'last_sale_date', label: 'Last Sale', group: 'Sales Metrics' },
|
||||
|
||||
// Financial Metrics Group
|
||||
{ key: 'avg_margin_percent', label: 'Avg Margin %', group: 'Financial Metrics', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'total_revenue', label: 'Total Revenue', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||
{ key: 'inventory_value', label: 'Inventory Value', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||
{ key: 'cost_of_goods_sold', label: 'COGS', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||
{ key: 'gross_profit', label: 'Gross Profit', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||
{ key: 'gmroi', label: 'GMROI', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||
{ key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' },
|
||||
|
||||
// Purchase & Lead Time Group
|
||||
{ key: 'avg_lead_time_days', label: 'Avg Lead Time (Days)', group: 'Purchase & Lead Time' },
|
||||
{ key: 'current_lead_time', label: 'Current Lead Time', group: 'Purchase & Lead Time' },
|
||||
{ key: 'target_lead_time', label: 'Target Lead Time', group: 'Purchase & Lead Time' },
|
||||
{ key: 'lead_time_status', label: 'Lead Time Status', group: 'Purchase & Lead Time' },
|
||||
{ key: 'last_purchase_date', label: 'Last Purchase', group: 'Purchase & Lead Time' },
|
||||
{ key: 'last_received_date', label: 'Last Received', group: 'Purchase & Lead Time' },
|
||||
|
||||
// Classification Group
|
||||
{ key: 'abc_class', label: 'ABC Class', group: 'Classification' },
|
||||
];
|
||||
|
||||
// Default visible columns
|
||||
const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [
|
||||
'title',
|
||||
'sku',
|
||||
'stock_quantity',
|
||||
'stock_status',
|
||||
'price',
|
||||
'vendor',
|
||||
'brand',
|
||||
'categories',
|
||||
];
|
||||
|
||||
export function Products() {
|
||||
const queryClient = useQueryClient();
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
@@ -53,6 +175,29 @@ export function Products() {
|
||||
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [visibleColumns, setVisibleColumns] = useState<Set<keyof Product>>(new Set(DEFAULT_VISIBLE_COLUMNS));
|
||||
|
||||
// Group columns by their group property
|
||||
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
|
||||
if (!acc[col.group]) {
|
||||
acc[col.group] = [];
|
||||
}
|
||||
acc[col.group].push(col);
|
||||
return acc;
|
||||
}, {} as Record<string, ColumnDef[]>);
|
||||
|
||||
// Toggle column visibility
|
||||
const toggleColumn = (columnKey: keyof Product) => {
|
||||
setVisibleColumns(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(columnKey)) {
|
||||
next.delete(columnKey);
|
||||
} else {
|
||||
next.add(columnKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Function to fetch products data
|
||||
const fetchProducts = async (pageNum: number) => {
|
||||
@@ -288,8 +433,39 @@ export function Products() {
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Products</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data?.pagination.total.toLocaleString() ?? '...'} products
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data?.pagination.total.toLocaleString() ?? '...'} products
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
Columns
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[280px]">
|
||||
<DropdownMenuLabel>Toggle Columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{Object.entries(columnsByGroup).map(([group, columns]) => (
|
||||
<div key={group}>
|
||||
<DropdownMenuLabel className="text-xs font-bold text-muted-foreground">
|
||||
{group}
|
||||
</DropdownMenuLabel>
|
||||
{columns.map((col) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.key}
|
||||
checked={visibleColumns.has(col.key)}
|
||||
onCheckedChange={() => toggleColumn(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -317,6 +493,8 @@ export function Products() {
|
||||
onSort={handleSort}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
visibleColumns={visibleColumns}
|
||||
columnDefs={AVAILABLE_COLUMNS}
|
||||
/>
|
||||
</div>
|
||||
{renderPagination()}
|
||||
|
||||
Reference in New Issue
Block a user