Add more columns and column selector to product table

This commit is contained in:
2025-01-13 11:55:30 -05:00
parent ab9b5cb48c
commit dd882490c8
6 changed files with 620 additions and 150 deletions

View File

@@ -23,17 +23,17 @@ router.get('/', async (req, res) => {
const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC';
// Build the WHERE clause
const conditions = ['visible = true'];
const conditions = ['p.visible = true'];
const params = [];
if (search) {
conditions.push('(title LIKE ? OR SKU LIKE ?)');
conditions.push('(p.title LIKE ? OR p.SKU LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (category !== 'all') {
conditions.push(`
product_id IN (
p.product_id IN (
SELECT pc.product_id
FROM product_categories pc
JOIN categories c ON pc.category_id = c.id
@@ -44,42 +44,42 @@ router.get('/', async (req, res) => {
}
if (vendor !== 'all') {
conditions.push('vendor = ?');
conditions.push('p.vendor = ?');
params.push(vendor);
}
if (stockStatus !== 'all') {
switch (stockStatus) {
case 'out_of_stock':
conditions.push('stock_quantity = 0');
conditions.push('p.stock_quantity = 0');
break;
case 'low_stock':
conditions.push('stock_quantity > 0 AND stock_quantity <= 5');
conditions.push('p.stock_quantity > 0 AND p.stock_quantity <= 5');
break;
case 'in_stock':
conditions.push('stock_quantity > 5');
conditions.push('p.stock_quantity > 5');
break;
}
}
if (minPrice > 0) {
conditions.push('price >= ?');
conditions.push('p.price >= ?');
params.push(minPrice);
}
if (maxPrice) {
conditions.push('price <= ?');
conditions.push('p.price <= ?');
params.push(maxPrice);
}
// Get total count for pagination
const [countResult] = await pool.query(
`SELECT COUNT(*) as total FROM products WHERE ${conditions.join(' AND ')}`,
`SELECT COUNT(*) as total FROM products p WHERE ${conditions.join(' AND ')}`,
params
);
const total = countResult[0].total;
// Get paginated results
// Get paginated results with metrics
const query = `
SELECT
p.product_id,
@@ -89,15 +89,50 @@ router.get('/', async (req, res) => {
p.price,
p.regular_price,
p.cost_price,
p.landing_cost_price,
p.barcode,
p.vendor,
p.vendor_reference,
p.brand,
p.visible,
p.managing_stock,
p.replenishable,
p.moq,
p.uom,
p.image,
GROUP_CONCAT(c.name) as categories
GROUP_CONCAT(DISTINCT c.name) as categories,
-- Metrics from product_metrics
pm.daily_sales_avg,
pm.weekly_sales_avg,
pm.monthly_sales_avg,
pm.avg_quantity_per_order,
pm.number_of_orders,
pm.first_sale_date,
pm.last_sale_date,
pm.days_of_inventory,
pm.weeks_of_inventory,
pm.reorder_point,
pm.safety_stock,
pm.avg_margin_percent,
pm.total_revenue,
pm.inventory_value,
pm.cost_of_goods_sold,
pm.gross_profit,
pm.gmroi,
pm.avg_lead_time_days,
pm.last_purchase_date,
pm.last_received_date,
pm.abc_class,
pm.stock_status,
pm.turnover_rate,
pm.current_lead_time,
pm.target_lead_time,
pm.lead_time_status
FROM products p
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
LEFT JOIN categories c ON pc.category_id = c.id
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
WHERE ${conditions.join(' AND ')}
GROUP BY p.product_id
ORDER BY ${sortColumn} ${sortDirection}
@@ -106,10 +141,38 @@ router.get('/', async (req, res) => {
const [rows] = await pool.query(query, [...params, limit, offset]);
// Transform the categories string into an array
// Transform the categories string into an array and parse numeric values
const productsWithCategories = rows.map(product => ({
...product,
categories: product.categories ? product.categories.split(',') : []
categories: product.categories ? [...new Set(product.categories.split(','))] : [],
// Parse numeric values
price: parseFloat(product.price) || 0,
regular_price: parseFloat(product.regular_price) || 0,
cost_price: parseFloat(product.cost_price) || 0,
landing_cost_price: parseFloat(product.landing_cost_price) || 0,
stock_quantity: parseInt(product.stock_quantity) || 0,
moq: parseInt(product.moq) || 1,
uom: parseInt(product.uom) || 1,
// Parse metrics
daily_sales_avg: parseFloat(product.daily_sales_avg) || null,
weekly_sales_avg: parseFloat(product.weekly_sales_avg) || null,
monthly_sales_avg: parseFloat(product.monthly_sales_avg) || null,
avg_quantity_per_order: parseFloat(product.avg_quantity_per_order) || null,
number_of_orders: parseInt(product.number_of_orders) || null,
days_of_inventory: parseInt(product.days_of_inventory) || null,
weeks_of_inventory: parseInt(product.weeks_of_inventory) || null,
reorder_point: parseInt(product.reorder_point) || null,
safety_stock: parseInt(product.safety_stock) || null,
avg_margin_percent: parseFloat(product.avg_margin_percent) || null,
total_revenue: parseFloat(product.total_revenue) || null,
inventory_value: parseFloat(product.inventory_value) || null,
cost_of_goods_sold: parseFloat(product.cost_of_goods_sold) || null,
gross_profit: parseFloat(product.gross_profit) || null,
gmroi: parseFloat(product.gmroi) || null,
turnover_rate: parseFloat(product.turnover_rate) || null,
avg_lead_time_days: parseInt(product.avg_lead_time_days) || null,
current_lead_time: parseInt(product.current_lead_time) || null,
target_lead_time: parseInt(product.target_lead_time) || null
}));
// Get unique categories and vendors for filters

View File

@@ -79,7 +79,7 @@ const pool = initPool({
app.locals.pool = pool;
// Routes
app.use('/api/dashboard/products', productsRouter);
app.use('/api/products', productsRouter);
app.use('/api/dashboard', dashboardRouter);
app.use('/api/orders', ordersRouter);
app.use('/api/csv', csvRouter);

View File

@@ -2,7 +2,6 @@ import { Home, Package, ShoppingCart, BarChart2, Settings, Box, ClipboardList }
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,

View File

@@ -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>
{visibleColumnDefs.map((column) => (
<TableHead key={column.key}>
<Button
variant="ghost"
onClick={() => onSort('title')}
onClick={() => onSort(column.key)}
>
Product
{getSortIcon('title')}
{column.label}
{getSortIcon(column.key)}
</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>
))}
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
{products.map((product) => {
console.log('Rendering product:', product.product_id, product.title, product.categories);
return (
<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>
{visibleColumnDefs.map((column) => (
<TableCell key={`${product.product_id}-${column.key}`}>
{formatColumnValue(product, column)}
</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>
))}
</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>

View 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,
}

View File

@@ -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,9 +433,40 @@ 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="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>
<ProductFilters
@@ -317,6 +493,8 @@ export function Products() {
onSort={handleSort}
sortColumn={sortColumn}
sortDirection={sortDirection}
visibleColumns={visibleColumns}
columnDefs={AVAILABLE_COLUMNS}
/>
</div>
{renderPagination()}