Add more columns and column selector to product table
This commit is contained in:
@@ -23,17 +23,17 @@ router.get('/', async (req, res) => {
|
|||||||
const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC';
|
const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
// Build the WHERE clause
|
// Build the WHERE clause
|
||||||
const conditions = ['visible = true'];
|
const conditions = ['p.visible = true'];
|
||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push('(title LIKE ? OR SKU LIKE ?)');
|
conditions.push('(p.title LIKE ? OR p.SKU LIKE ?)');
|
||||||
params.push(`%${search}%`, `%${search}%`);
|
params.push(`%${search}%`, `%${search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category !== 'all') {
|
if (category !== 'all') {
|
||||||
conditions.push(`
|
conditions.push(`
|
||||||
product_id IN (
|
p.product_id IN (
|
||||||
SELECT pc.product_id
|
SELECT pc.product_id
|
||||||
FROM product_categories pc
|
FROM product_categories pc
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.category_id = c.id
|
||||||
@@ -44,42 +44,42 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (vendor !== 'all') {
|
if (vendor !== 'all') {
|
||||||
conditions.push('vendor = ?');
|
conditions.push('p.vendor = ?');
|
||||||
params.push(vendor);
|
params.push(vendor);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stockStatus !== 'all') {
|
if (stockStatus !== 'all') {
|
||||||
switch (stockStatus) {
|
switch (stockStatus) {
|
||||||
case 'out_of_stock':
|
case 'out_of_stock':
|
||||||
conditions.push('stock_quantity = 0');
|
conditions.push('p.stock_quantity = 0');
|
||||||
break;
|
break;
|
||||||
case 'low_stock':
|
case 'low_stock':
|
||||||
conditions.push('stock_quantity > 0 AND stock_quantity <= 5');
|
conditions.push('p.stock_quantity > 0 AND p.stock_quantity <= 5');
|
||||||
break;
|
break;
|
||||||
case 'in_stock':
|
case 'in_stock':
|
||||||
conditions.push('stock_quantity > 5');
|
conditions.push('p.stock_quantity > 5');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (minPrice > 0) {
|
if (minPrice > 0) {
|
||||||
conditions.push('price >= ?');
|
conditions.push('p.price >= ?');
|
||||||
params.push(minPrice);
|
params.push(minPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxPrice) {
|
if (maxPrice) {
|
||||||
conditions.push('price <= ?');
|
conditions.push('p.price <= ?');
|
||||||
params.push(maxPrice);
|
params.push(maxPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get total count for pagination
|
// Get total count for pagination
|
||||||
const [countResult] = await pool.query(
|
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
|
params
|
||||||
);
|
);
|
||||||
const total = countResult[0].total;
|
const total = countResult[0].total;
|
||||||
|
|
||||||
// Get paginated results
|
// Get paginated results with metrics
|
||||||
const query = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.product_id,
|
||||||
@@ -89,15 +89,50 @@ router.get('/', async (req, res) => {
|
|||||||
p.price,
|
p.price,
|
||||||
p.regular_price,
|
p.regular_price,
|
||||||
p.cost_price,
|
p.cost_price,
|
||||||
|
p.landing_cost_price,
|
||||||
|
p.barcode,
|
||||||
p.vendor,
|
p.vendor,
|
||||||
|
p.vendor_reference,
|
||||||
p.brand,
|
p.brand,
|
||||||
p.visible,
|
p.visible,
|
||||||
p.managing_stock,
|
p.managing_stock,
|
||||||
|
p.replenishable,
|
||||||
|
p.moq,
|
||||||
|
p.uom,
|
||||||
p.image,
|
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
|
FROM products p
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
||||||
LEFT JOIN categories c ON pc.category_id = c.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 ')}
|
WHERE ${conditions.join(' AND ')}
|
||||||
GROUP BY p.product_id
|
GROUP BY p.product_id
|
||||||
ORDER BY ${sortColumn} ${sortDirection}
|
ORDER BY ${sortColumn} ${sortDirection}
|
||||||
@@ -106,10 +141,38 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
const [rows] = await pool.query(query, [...params, limit, offset]);
|
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 => ({
|
const productsWithCategories = rows.map(product => ({
|
||||||
...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
|
// Get unique categories and vendors for filters
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const pool = initPool({
|
|||||||
app.locals.pool = pool;
|
app.locals.pool = pool;
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/dashboard/products', productsRouter);
|
app.use('/api/products', productsRouter);
|
||||||
app.use('/api/dashboard', dashboardRouter);
|
app.use('/api/dashboard', dashboardRouter);
|
||||||
app.use('/api/orders', ordersRouter);
|
app.use('/api/orders', ordersRouter);
|
||||||
app.use('/api/csv', csvRouter);
|
app.use('/api/csv', csvRouter);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Home, Package, ShoppingCart, BarChart2, Settings, Box, ClipboardList }
|
|||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
|
|||||||
@@ -19,19 +19,62 @@ interface Product {
|
|||||||
price: number;
|
price: number;
|
||||||
regular_price: number;
|
regular_price: number;
|
||||||
cost_price: number;
|
cost_price: number;
|
||||||
|
landing_cost_price: number;
|
||||||
|
barcode: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
|
vendor_reference: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
image: string | null;
|
||||||
|
moq: number;
|
||||||
|
uom: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
managing_stock: 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 {
|
interface ProductTableProps {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
onSort: (column: keyof Product) => void;
|
onSort: (column: keyof Product) => void;
|
||||||
sortColumn: keyof Product | null;
|
sortColumn: keyof Product;
|
||||||
sortDirection: 'asc' | 'desc';
|
sortDirection: 'asc' | 'desc';
|
||||||
|
visibleColumns: Set<keyof Product>;
|
||||||
|
columnDefs: ColumnDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProductTable({
|
export function ProductTable({
|
||||||
@@ -39,6 +82,8 @@ export function ProductTable({
|
|||||||
onSort,
|
onSort,
|
||||||
sortColumn,
|
sortColumn,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
visibleColumns,
|
||||||
|
columnDefs,
|
||||||
}: ProductTableProps) {
|
}: ProductTableProps) {
|
||||||
const getSortIcon = (column: keyof Product) => {
|
const getSortIcon = (column: keyof Product) => {
|
||||||
if (sortColumn !== column) return <ArrowUpDown className="ml-2 h-4 w-4" />;
|
if (sortColumn !== column) return <ArrowUpDown className="ml-2 h-4 w-4" />;
|
||||||
@@ -49,149 +94,135 @@ export function ProductTable({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStockStatus = (quantity: number) => {
|
const getStockStatus = (status: string | undefined) => {
|
||||||
if (quantity === 0) {
|
if (!status) return null;
|
||||||
return <Badge variant="destructive">Out of Stock</Badge>;
|
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 (
|
return (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>
|
{visibleColumnDefs.map((column) => (
|
||||||
<Button
|
<TableHead key={column.key}>
|
||||||
variant="ghost"
|
<Button
|
||||||
onClick={() => onSort('title')}
|
variant="ghost"
|
||||||
>
|
onClick={() => onSort(column.key)}
|
||||||
Product
|
>
|
||||||
{getSortIcon('title')}
|
{column.label}
|
||||||
</Button>
|
{getSortIcon(column.key)}
|
||||||
</TableHead>
|
</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products.map((product) => (
|
{products.map((product) => {
|
||||||
<TableRow key={product.product_id}>
|
console.log('Rendering product:', product.product_id, product.title, product.categories);
|
||||||
<TableCell>
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<TableRow key={product.product_id}>
|
||||||
<Avatar className="h-8 w-8">
|
{visibleColumnDefs.map((column) => (
|
||||||
<AvatarImage src={product.image} alt={product.title} />
|
<TableCell key={`${product.product_id}-${column.key}`}>
|
||||||
<AvatarFallback>{product.title.charAt(0).toUpperCase()}</AvatarFallback>
|
{formatColumnValue(product, column)}
|
||||||
</Avatar>
|
</TableCell>
|
||||||
<span className="font-medium">{product.title}</span>
|
))}
|
||||||
</div>
|
</TableRow>
|
||||||
</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.length && (
|
{!products.length && (
|
||||||
<TableRow>
|
<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
|
No products found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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,
|
PaginationNext,
|
||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
} from "@/components/ui/pagination";
|
} 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';
|
import config from '../config';
|
||||||
|
|
||||||
|
// Enhanced Product interface with all possible fields
|
||||||
interface Product {
|
interface Product {
|
||||||
|
// Basic product info (from products table)
|
||||||
product_id: string;
|
product_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
@@ -22,12 +34,46 @@ interface Product {
|
|||||||
price: number;
|
price: number;
|
||||||
regular_price: number;
|
regular_price: number;
|
||||||
cost_price: number;
|
cost_price: number;
|
||||||
|
landing_cost_price: number;
|
||||||
|
barcode: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
|
vendor_reference: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
image: string | null;
|
||||||
|
moq: number;
|
||||||
|
uom: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
managing_stock: 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 {
|
interface ProductFiltersState {
|
||||||
@@ -39,6 +85,82 @@ interface ProductFiltersState {
|
|||||||
maxPrice: string;
|
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() {
|
export function Products() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const tableRef = useRef<HTMLDivElement>(null);
|
const tableRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -53,6 +175,29 @@ export function Products() {
|
|||||||
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
|
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const [page, setPage] = useState(1);
|
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
|
// Function to fetch products data
|
||||||
const fetchProducts = async (pageNum: number) => {
|
const fetchProducts = async (pageNum: number) => {
|
||||||
@@ -288,8 +433,39 @@ export function Products() {
|
|||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Products</h1>
|
<h1 className="text-2xl font-bold">Products</h1>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-4">
|
||||||
{data?.pagination.total.toLocaleString() ?? '...'} products
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -317,6 +493,8 @@ export function Products() {
|
|||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
sortColumn={sortColumn}
|
sortColumn={sortColumn}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
|
columnDefs={AVAILABLE_COLUMNS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{renderPagination()}
|
{renderPagination()}
|
||||||
|
|||||||
Reference in New Issue
Block a user