From 0425912d3e13844443bdd74294784aea941ed8bf Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 15 Jan 2025 00:30:25 -0500 Subject: [PATCH] Fix various issues with product table and details components --- inventory-server/scripts/calculate-metrics.js | 8 +- inventory-server/src/routes/products.js | 45 +- .../src/components/products/ProductDetail.tsx | 831 ++++++++++-------- .../src/components/products/ProductTable.tsx | 52 +- inventory/src/pages/Products.tsx | 370 ++++---- 5 files changed, 673 insertions(+), 633 deletions(-) diff --git a/inventory-server/scripts/calculate-metrics.js b/inventory-server/scripts/calculate-metrics.js index e7bf288..043c2f4 100644 --- a/inventory-server/scripts/calculate-metrics.js +++ b/inventory-server/scripts/calculate-metrics.js @@ -688,7 +688,10 @@ async function calculateMetrics() { AVG(o.quantity) as avg_quantity_per_order, -- Calculate rolling averages using configured windows SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_30_days_qty, - SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_7_days_qty, + CASE + WHEN SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) IS NULL THEN 0 + ELSE SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) + END as rolling_weekly_avg, SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_month_qty FROM orders o JOIN products p ON o.product_id = p.product_id @@ -704,13 +707,14 @@ async function calculateMetrics() { number_of_orders, avg_quantity_per_order, last_30_days_qty / ? as rolling_daily_avg, - last_7_days_qty / ? as rolling_weekly_avg, + rolling_weekly_avg / ? as rolling_weekly_avg, last_month_qty / ? as rolling_monthly_avg, total_quantity_sold as total_sales_to_date FROM sales_summary `, [ config.daily_window_days, config.weekly_window_days, + config.weekly_window_days, config.monthly_window_days, product.product_id, config.daily_window_days, diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index 2ad1b04..1cee690 100755 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -252,15 +252,29 @@ router.get('/', async (req, res) => { 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.avg_lead_time_days, + pm.turnover_rate, pm.current_lead_time, pm.target_lead_time, pm.lead_time_status, - pm.days_of_inventory as days_of_stock, COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio FROM products p LEFT JOIN product_metrics pm ON p.product_id = pm.product_id @@ -281,15 +295,34 @@ router.get('/', async (req, res) => { categories: row.categories ? row.categories.split(',') : [], price: parseFloat(row.price), cost_price: parseFloat(row.cost_price), - landing_cost_price: parseFloat(row.landing_cost_price), + landing_cost_price: row.landing_cost_price ? parseFloat(row.landing_cost_price) : null, stock_quantity: parseInt(row.stock_quantity), daily_sales_avg: parseFloat(row.daily_sales_avg) || 0, weekly_sales_avg: parseFloat(row.weekly_sales_avg) || 0, monthly_sales_avg: parseFloat(row.monthly_sales_avg) || 0, + avg_quantity_per_order: parseFloat(row.avg_quantity_per_order) || 0, + number_of_orders: parseInt(row.number_of_orders) || 0, + first_sale_date: row.first_sale_date || null, + last_sale_date: row.last_sale_date || null, + days_of_inventory: parseFloat(row.days_of_inventory) || 0, + weeks_of_inventory: parseFloat(row.weeks_of_inventory) || 0, + reorder_point: parseFloat(row.reorder_point) || 0, + safety_stock: parseFloat(row.safety_stock) || 0, avg_margin_percent: parseFloat(row.avg_margin_percent) || 0, + total_revenue: parseFloat(row.total_revenue) || 0, + inventory_value: parseFloat(row.inventory_value) || 0, + cost_of_goods_sold: parseFloat(row.cost_of_goods_sold) || 0, + gross_profit: parseFloat(row.gross_profit) || 0, gmroi: parseFloat(row.gmroi) || 0, - lead_time_days: parseInt(row.lead_time_days) || 0, - days_of_stock: parseFloat(row.days_of_stock) || 0, + avg_lead_time_days: parseFloat(row.avg_lead_time_days) || 0, + last_purchase_date: row.last_purchase_date || null, + last_received_date: row.last_received_date || null, + abc_class: row.abc_class || null, + stock_status: row.stock_status || null, + turnover_rate: parseFloat(row.turnover_rate) || 0, + current_lead_time: parseFloat(row.current_lead_time) || 0, + target_lead_time: parseFloat(row.target_lead_time) || 0, + lead_time_status: row.lead_time_status || null, stock_coverage_ratio: parseFloat(row.stock_coverage_ratio) || 0 })); @@ -617,7 +650,7 @@ router.get('/:id/metrics', async (req, res) => { LEFT JOIN product_metrics pm ON p.product_id = pm.product_id LEFT JOIN inventory_status is ON p.product_id = is.product_id WHERE p.product_id = ? - `, [id, id]); + `, [id]); if (!metrics.length) { // Return default metrics structure if no data found diff --git a/inventory/src/components/products/ProductDetail.tsx b/inventory/src/components/products/ProductDetail.tsx index cfa196d..42e6691 100644 --- a/inventory/src/components/products/ProductDetail.tsx +++ b/inventory/src/components/products/ProductDetail.tsx @@ -3,6 +3,8 @@ import { Drawer as VaulDrawer } from "vaul"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Skeleton } from "@/components/ui/skeleton"; import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import config from "@/config"; @@ -23,10 +25,10 @@ interface Product { replenishable: boolean; // Pricing fields - price: string | number; - regular_price: string | number; - cost_price: string | number; - landing_cost_price: string | number | null; + price: number; + regular_price: number; + cost_price: number; + landing_cost_price: number | null; // Categorization categories: string[]; @@ -40,7 +42,7 @@ interface Product { // URLs permalink: string; - image: string; + image: string | null; // Metrics metrics: { @@ -124,7 +126,7 @@ interface Product { } interface ProductDetailProps { - productId: string | null; + productId: number | null; onClose: () => void; } @@ -165,10 +167,19 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) { const isLoading = isLoadingProduct || isLoadingTimeSeries; // Helper function to format price values - const formatPrice = (price: string | number | null | undefined): string => { + const formatPrice = (price: number | null | undefined): string => { if (price === null || price === undefined) return 'N/A'; - const numericPrice = typeof price === 'string' ? parseFloat(price) : price; - return typeof numericPrice === 'number' ? numericPrice.toFixed(2) : 'N/A'; + return price.toFixed(2); + }; + + // Helper function to format date values + const formatDate = (date: string | null): string => { + if (!date) return '-'; + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); }; // Combine product and time series data @@ -184,27 +195,28 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) { return ( !open && onClose()} direction="right"> - -
- - {isLoading ? ( - - ) : ( - product?.title + + +
+
+ {product?.image && ( +
+ {product.title} +
)} - - - {isLoading ? ( - "\u00A0" - ) : ( - `SKU: ${product?.SKU} | Stock: ${product?.stock_quantity}` - )} - +
+

{product?.title || 'Loading...'}

+

{product?.SKU || ''}

+
+
+
-
- - + +
+ Overview Inventory Sales @@ -212,381 +224,426 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) { Financial Vendor +
- - {isLoading ? ( -
- - -
- ) : ( -
- -

Basic Information

-
-
-
Brand
-
{product?.brand || "N/A"}
-
-
-
Vendor
-
{product?.vendor || "N/A"}
-
-
-
Categories
-
- {product?.categories?.map(category => ( - - {category} - - )) || "N/A"} -
-
-
-
Tags
-
- {product?.tags?.map(tag => ( - - {tag} - - )) || "N/A"} -
-
-
-
- - -

Pricing

-
-
-
Price
-
${formatPrice(product?.price)}
-
-
-
Regular Price
-
${formatPrice(product?.regular_price)}
-
-
-
Cost Price
-
${formatPrice(product?.cost_price)}
-
-
-
Landing Cost
-
${formatPrice(product?.landing_cost_price)}
-
-
-
- - -

Stock Status

-
-
-
Current Stock
-
{product?.stock_quantity}
-
-
-
Status
-
{product?.metrics?.stock_status}
-
-
-
Days of Stock
-
{product?.metrics?.days_of_inventory} days
-
-
-
- - -

Sales Velocity

-
-
-
Daily Sales
-
{product?.metrics?.daily_sales_avg?.toFixed(1)} units
-
-
-
Weekly Sales
-
{product?.metrics?.weekly_sales_avg?.toFixed(1)} units
-
-
-
Monthly Sales
-
{product?.metrics?.monthly_sales_avg?.toFixed(1)} units
-
-
-
- - -

Sales Trend

-
- - - - - - - - - - + + {isLoading ? ( +
+ + +
+ ) : ( +
+ +

Basic Information

+
+
+
Brand
+
{product?.brand || "N/A"}
- -
- )} -
+
+
Vendor
+
{product?.vendor || "N/A"}
+
+
+
Categories
+
+ {product?.categories?.map(category => ( + + {category} + + )) || "N/A"} +
+
+
+
Tags
+
+ {product?.tags?.map(tag => ( + + {tag} + + )) || "N/A"} +
+
+ + - - {isLoading ? ( - - ) : ( -
- -

Current Stock

-
-
-
Stock Quantity
-
{product?.stock_quantity}
-
-
-
Days of Inventory
-
{product?.metrics?.days_of_inventory || 0}
-
-
-
Status
-
{product?.metrics?.stock_status || "N/A"}
-
-
-
+ +

Pricing

+
+
+
Price
+
${formatPrice(product?.price)}
+
+
+
Regular Price
+
${formatPrice(product?.regular_price)}
+
+
+
Cost Price
+
${formatPrice(product?.cost_price)}
+
+
+
Landing Cost
+
${formatPrice(product?.landing_cost_price)}
+
+
+
- -

Stock Thresholds

-
-
-
Reorder Point
-
{product?.metrics?.reorder_point || 0}
-
-
-
Safety Stock
-
{product?.metrics?.safety_stock || 0}
-
-
-
ABC Class
-
{product?.metrics?.abc_class || "N/A"}
-
-
-
-
- )} -
+ +

Stock Status

+
+
+
Current Stock
+
{product?.stock_quantity}
+
+
+
Status
+
{product?.metrics?.stock_status}
+
+
+
Days of Stock
+
{product?.metrics?.days_of_inventory} days
+
+
+
- - {isLoading ? ( - - ) : ( -
- -

Recent Orders

- - - - Date - Order # - Customer - Quantity - Price - Status + +

Sales Velocity

+
+
+
Daily Sales
+
{product?.metrics?.daily_sales_avg?.toFixed(1)} units
+
+
+
Weekly Sales
+
{product?.metrics?.weekly_sales_avg?.toFixed(1)} units
+
+
+
Monthly Sales
+
{product?.metrics?.monthly_sales_avg?.toFixed(1)} units
+
+
+
+ + +

Sales Trend

+
+ + + + + + + + + + + +
+
+ + +

Financial Metrics

+
+
+
Total Revenue
+
${formatPrice(product?.metrics.total_revenue)}
+
+
+
Gross Profit
+
${formatPrice(product?.metrics.gross_profit)}
+
+
+
Margin
+
{product?.metrics.avg_margin_percent.toFixed(2)}%
+
+
+
GMROI
+
{product?.metrics.gmroi.toFixed(2)}
+
+
+
+ + +

Lead Time

+
+
+
Current Lead Time
+
{product?.metrics.current_lead_time}
+
+
+
Target Lead Time
+
{product?.metrics.target_lead_time}
+
+
+
Lead Time Status
+
{product?.metrics.lead_time_status}
+
+
+
+ + )} + + + + {isLoading ? ( + + ) : ( +
+ +

Current Stock

+
+
+
Stock Quantity
+
{product?.stock_quantity}
+
+
+
Days of Inventory
+
{product?.metrics?.days_of_inventory || 0}
+
+
+
Status
+
{product?.metrics?.stock_status || "N/A"}
+
+
+
+ + +

Stock Thresholds

+
+
+
Reorder Point
+
{product?.metrics?.reorder_point || 0}
+
+
+
Safety Stock
+
{product?.metrics?.safety_stock || 0}
+
+
+
ABC Class
+
{product?.metrics?.abc_class || "N/A"}
+
+
+
+
+ )} +
+ + + {isLoading ? ( + + ) : ( +
+ +

Recent Orders

+
+ + + Date + Order # + Customer + Quantity + Price + Status + + + + {combinedData?.recent_orders?.map((order: NonNullable[number]) => ( + + {formatDate(order.date)} + {order.order_number} + {order.customer} + {order.quantity} + ${formatPrice(order.price)} + {order.status} - - - {combinedData?.recent_orders?.map((order: NonNullable[number]) => ( - - {order.date} - {order.order_number} - {order.customer} - {order.quantity} - ${formatPrice(order.price)} - {order.status} - - ))} - {(!combinedData?.recent_orders || combinedData.recent_orders.length === 0) && ( - - - No recent orders - - - )} - -
-
- - -

Monthly Sales Trend

-
- - - - - - - - - - - -
-
-
- )} -
- - - {isLoading ? ( - - ) : ( -
- -

Recent Purchase Orders

- - + ))} + {(!combinedData?.recent_orders || combinedData.recent_orders.length === 0) && ( - Date - PO # - Ordered - Received - Status - Lead Time + + No recent orders + - - - {combinedData?.recent_purchases?.map((po: NonNullable[number]) => ( - - {po.date} - {po.po_id} - {po.ordered} - {po.received} - {po.status} - {po.lead_time_days ? `${po.lead_time_days} days` : 'N/A'} - - ))} - {(!combinedData?.recent_purchases || combinedData.recent_purchases.length === 0) && ( - - - No recent purchase orders - - - )} - -
-
-
- )} -
+ )} + + + - - {isLoading ? ( - - ) : ( -
- -

Financial Overview

-
-
-
Gross Profit
-
${formatPrice(product?.metrics.gross_profit)}
-
-
-
GMROI
-
{product?.metrics.gmroi.toFixed(2)}
-
-
-
Margin %
-
{product?.metrics.avg_margin_percent.toFixed(2)}%
-
-
-
+ +

Monthly Sales Trend

+
+ + + + + + + + + + + +
+
+
+ )} +
- -

Cost Breakdown

-
-
-
Cost of Goods Sold
-
${formatPrice(product?.metrics.cost_of_goods_sold)}
-
-
-
Landing Cost
-
${formatPrice(product?.landing_cost_price)}
-
-
-
+ + {isLoading ? ( + + ) : ( +
+ +

Recent Purchase Orders

+ + + + Date + PO # + Ordered + Received + Status + Lead Time + + + + {combinedData?.recent_purchases?.map((po: NonNullable[number]) => ( + + {formatDate(po.date)} + {po.po_id} + {po.ordered} + {po.received} + {po.status} + {po.lead_time_days ? `${po.lead_time_days} days` : 'N/A'} + + ))} + {(!combinedData?.recent_purchases || combinedData.recent_purchases.length === 0) && ( + + + No recent purchase orders + + + )} + +
+
+
+ )} +
- -

Profit Margin Trend

-
- - - - - - - - - + + {isLoading ? ( + + ) : ( +
+ +

Financial Overview

+
+
+
Gross Profit
+
${formatPrice(product?.metrics.gross_profit)}
- -
- )} -
+
+
GMROI
+
{product?.metrics.gmroi.toFixed(2)}
+
+
+
Margin %
+
{product?.metrics.avg_margin_percent.toFixed(2)}%
+
+ + - - {isLoading ? ( - - ) : product?.vendor_performance ? ( -
- -

Vendor Performance

-
-
-
On-Time Delivery
-
{product.vendor_performance.on_time_delivery_rate.toFixed(1)}%
-
-
-
Order Fill Rate
-
{product.vendor_performance.order_fill_rate.toFixed(1)}%
-
-
-
Avg Lead Time
-
{product.vendor_performance.avg_lead_time_days} days
-
-
-
+ +

Cost Breakdown

+
+
+
Cost of Goods Sold
+
${formatPrice(product?.metrics.cost_of_goods_sold)}
+
+
+
Landing Cost
+
${formatPrice(product?.landing_cost_price)}
+
+
+
- -

Order History

-
-
-
Total Orders
-
{product.vendor_performance.total_orders}
-
-
-
Late Orders
-
{product.vendor_performance.total_late_orders}
-
-
-
Total Purchase Value
-
${formatPrice(product.vendor_performance.total_purchase_value)}
-
-
-
Avg Order Value
-
${formatPrice(product.vendor_performance.avg_order_value)}
-
-
-
-
- ) : ( -
No vendor performance data available
- )} -
- -
+ +

Profit Margin Trend

+
+ + + + + + + + + +
+
+
+ )} + + + + {isLoading ? ( + + ) : product?.vendor_performance ? ( +
+ +

Vendor Performance

+
+
+
Lead Time
+
{product?.vendor_performance?.avg_lead_time_days?.toFixed(1) || "N/A"} days
+
+
+
On-Time Delivery
+
{product?.vendor_performance?.on_time_delivery_rate?.toFixed(1) || "N/A"}%
+
+
+
Order Fill Rate
+
{product?.vendor_performance?.order_fill_rate?.toFixed(1) || "N/A"}%
+
+
+
Total Orders
+
{product?.vendor_performance?.total_orders || 0}
+
+
+
+ + +

Order History

+
+
+
Total Orders
+
{product?.vendor_performance?.total_orders}
+
+
+
Late Orders
+
{product?.vendor_performance?.total_late_orders}
+
+
+
Total Purchase Value
+
${formatPrice(product?.vendor_performance?.total_purchase_value)}
+
+
+
Avg Order Value
+
${formatPrice(product?.vendor_performance?.avg_order_value)}
+
+
+
+
+ ) : ( +
No vendor performance data available
+ )} +
+ ); -} \ No newline at end of file +} \ No newline at end of file diff --git a/inventory/src/components/products/ProductTable.tsx b/inventory/src/components/products/ProductTable.tsx index 5312151..fe6cb91 100644 --- a/inventory/src/components/products/ProductTable.tsx +++ b/inventory/src/components/products/ProductTable.tsx @@ -28,25 +28,29 @@ import { import { CSS } from "@dnd-kit/utilities"; interface Product { - product_id: string; + product_id: number; title: string; - sku: string; + SKU: string; stock_quantity: number; price: number; regular_price: number; cost_price: number; - landing_cost_price: number; + landing_cost_price: number | null; barcode: string; vendor: string; vendor_reference: string; brand: string; categories: string[]; + tags: string[]; + options: Record; image: string | null; moq: number; uom: number; visible: boolean; managing_stock: boolean; replenishable: boolean; + created_at: string; + updated_at: string; // Metrics daily_sales_avg?: number; @@ -77,8 +81,10 @@ interface Product { lead_time_status?: string; } +type ColumnKey = keyof Product | 'image'; + interface ColumnDef { - key: keyof Product | 'image'; + key: ColumnKey; label: string; group: string; format?: (value: any) => string | number; @@ -88,21 +94,21 @@ interface ColumnDef { interface ProductTableProps { products: Product[]; - onSort: (column: keyof Product) => void; - sortColumn: keyof Product; + onSort: (column: ColumnKey) => void; + sortColumn: ColumnKey; sortDirection: 'asc' | 'desc'; - visibleColumns: Set; + visibleColumns: Set; columnDefs: ColumnDef[]; - columnOrder: (keyof Product | 'image')[]; - onColumnOrderChange?: (columns: (keyof Product | 'image')[]) => void; + columnOrder: ColumnKey[]; + onColumnOrderChange?: (columns: ColumnKey[]) => void; onRowClick?: (product: Product) => void; } interface SortableHeaderProps { - column: keyof Product; + column: ColumnKey; columnDef?: ColumnDef; - onSort: (column: keyof Product) => void; - sortColumn: keyof Product; + onSort: (column: ColumnKey) => void; + sortColumn: ColumnKey; sortDirection: 'asc' | 'desc'; } @@ -164,7 +170,7 @@ export function ProductTable({ onColumnOrderChange, onRowClick, }: ProductTableProps) { - const [, setActiveId] = React.useState(null); + const [, setActiveId] = React.useState(null); const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { @@ -185,7 +191,7 @@ export function ProductTable({ }, [columnOrder, visibleColumns]); const handleDragStart = (event: DragStartEvent) => { - setActiveId(event.active.id as keyof Product); + setActiveId(event.active.id as ColumnKey); }; const handleDragEnd = (event: DragEndEvent) => { @@ -193,8 +199,8 @@ export function ProductTable({ setActiveId(null); if (over && active.id !== over.id) { - const oldIndex = orderedColumns.indexOf(active.id as keyof Product); - const newIndex = orderedColumns.indexOf(over.id as keyof Product); + const oldIndex = orderedColumns.indexOf(active.id as ColumnKey); + const newIndex = orderedColumns.indexOf(over.id as ColumnKey); const newOrder = arrayMove(orderedColumns, oldIndex, newIndex); onColumnOrderChange?.(newOrder); @@ -248,9 +254,9 @@ export function ProductTable({ } }; - const formatColumnValue = (product: Product, column: keyof Product | 'image') => { - const value = column === 'image' ? product.image : product[column as keyof Product]; + const formatColumnValue = (product: Product, column: ColumnKey) => { const columnDef = columnDefs.find(def => def.key === column); + let value: any = product[column as keyof Product]; switch (column) { case 'image': @@ -267,7 +273,7 @@ export function ProductTable({ return (
{product.title}
-
{product.sku}
+
{product.SKU}
); case 'categories': @@ -279,11 +285,11 @@ export function ProductTable({
); case 'stock_status': - return getStockStatus(value as string); + return getStockStatus(product.stock_status); case 'abc_class': - return getABCClass(value as string); + return getABCClass(product.abc_class); case 'lead_time_status': - return getLeadTimeStatus(value as string); + return getLeadTimeStatus(product.lead_time_status); case 'visible': return value ? ( Active @@ -301,7 +307,7 @@ export function ProductTable({ } return columnDef.format(value); } - return value || '-'; + return value ?? '-'; } }; diff --git a/inventory/src/pages/Products.tsx b/inventory/src/pages/Products.tsx index 61ef3fe..1a53ffd 100644 --- a/inventory/src/pages/Products.tsx +++ b/inventory/src/pages/Products.tsx @@ -1,17 +1,10 @@ -import { useState, useEffect } from 'react'; -import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { ProductFilters } from '@/components/products/ProductFilters'; import { ProductTable } from '@/components/products/ProductTable'; import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton'; import { ProductDetail } from '@/components/products/ProductDetail'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; +import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -19,35 +12,38 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; -import { Settings2 } from "lucide-react"; -import config from '../config'; -import { motion } from 'motion/react'; +} from '@/components/ui/dropdown-menu'; +import { Settings2, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react'; +import { motion } from 'framer-motion'; + // Enhanced Product interface with all possible fields interface Product { - // Basic product info (from products table) - product_id: string; + // Basic product info + product_id: number; title: string; - sku: string; + SKU: string; stock_quantity: number; price: number; regular_price: number; cost_price: number; - landing_cost_price: number; + landing_cost_price: number | null; barcode: string; vendor: string; vendor_reference: string; brand: string; categories: string[]; + tags: string[]; + options: Record; image: string | null; moq: number; uom: number; visible: boolean; managing_stock: boolean; replenishable: boolean; + created_at: string; + updated_at: string; - // Metrics (from product_metrics table) + // Metrics daily_sales_avg?: number; weekly_sales_avg?: number; monthly_sales_avg?: number; @@ -76,7 +72,6 @@ interface Product { lead_time_status?: string; } - // Column definition interface interface ColumnDef { key: keyof Product | 'image'; @@ -94,7 +89,6 @@ 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' }, @@ -150,7 +144,6 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [ const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [ 'image', 'title', - 'sku', 'stock_quantity', 'stock_status', 'price', @@ -160,7 +153,6 @@ const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [ ]; export function Products() { - const queryClient = useQueryClient(); const [filters, setFilters] = useState>({}); const [sortColumn, setSortColumn] = useState('title'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); @@ -170,7 +162,7 @@ export function Products() { ...DEFAULT_VISIBLE_COLUMNS, ...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key)) ]); - const [selectedProductId, setSelectedProductId] = useState(null); + const [selectedProductId, setSelectedProductId] = useState(null); // Group columns by their group property const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => { @@ -179,88 +171,55 @@ export function Products() { } acc[col.group].push(col); return acc; - }, {} as Record); + }, {} as Record); - // Toggle column visibility - const toggleColumn = (columnKey: keyof Product | 'image') => { - setVisibleColumns(prev => { - const next = new Set(prev); - if (next.has(columnKey)) { - next.delete(columnKey); - } else { - next.add(columnKey); - } - return next; - }); + // Handle column reordering from drag and drop + const handleColumnOrderChange = (newOrder: (keyof Product | 'image')[]) => { + setColumnOrder(newOrder); }; // Function to fetch products data - const fetchProducts = async (pageNum: number) => { - const searchParams = new URLSearchParams({ - page: pageNum.toString(), - limit: '100', - sortColumn: sortColumn.toString(), - sortDirection, - ...filters, + const fetchProducts = async () => { + const params = new URLSearchParams(); + + // Add pagination params + params.append('page', page.toString()); + params.append('limit', '50'); + + // Add sorting params + if (sortColumn) { + params.append('sortColumn', sortColumn); + params.append('sortDirection', sortDirection); + } + + // Add filter params + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + params.append(key, value.toString()); + } }); - const response = await fetch(`${config.apiUrl}/products?${searchParams}`); + const response = await fetch(`/api/products?${params.toString()}`); if (!response.ok) { - throw new Error('Network response was not ok'); + throw new Error('Failed to fetch products'); } - const result = await response.json(); - - return result; + return response.json(); }; - const { data, isLoading, isFetching } = useQuery({ - queryKey: ['products', filters, sortColumn, sortDirection, page], - queryFn: () => fetchProducts(page), + // Query for products data + const { data, isFetching } = useQuery({ + queryKey: ['products', page, sortColumn, sortDirection, filters], + queryFn: fetchProducts, placeholderData: keepPreviousData, - staleTime: 30000, }); - // Enhanced prefetching strategy - useEffect(() => { - if (data?.pagination) { - const prefetchPage = async (pageNum: number) => { - // Don't prefetch if the page is out of bounds - if (pageNum < 1 || pageNum > data.pagination.pages) return; - - await queryClient.prefetchQuery({ - queryKey: ['products', filters, sortColumn, sortDirection, pageNum], - queryFn: () => fetchProducts(pageNum), - staleTime: 30000, - }); - }; - - // Prefetch priority: - // 1. Next page (most likely to be clicked) - // 2. Previous page (second most likely) - // 3. Jump forward 5 pages (for quick navigation) - // 4. Jump backward 5 pages - const prefetchPriority = async () => { - if (page < data.pagination.pages) { - await prefetchPage(page + 1); - } - if (page > 1) { - await prefetchPage(page - 1); - } - await prefetchPage(page + 5); - await prefetchPage(page - 5); - }; - - prefetchPriority(); - } - }, [page, data?.pagination, queryClient, filters, sortColumn, sortDirection]); - + // Handle sort column change const handleSort = (column: keyof Product) => { - if (sortColumn === column) { - setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); - } else { - setSortColumn(column); - setSortDirection('asc'); - } + setSortDirection(prev => { + if (sortColumn !== column) return 'asc'; + return prev === 'asc' ? 'desc' : 'asc'; + }); + setSortColumn(column); }; // Handle filter changes @@ -275,109 +234,118 @@ export function Products() { }; const handlePageChange = (newPage: number) => { - window.scrollTo({ top: 0 }); setPage(newPage); - }; - - // Handle column reordering from drag and drop - const handleColumnOrderChange = (newOrder: (keyof Product | 'image')[]) => { - setColumnOrder(prev => { - // Keep hidden columns in their current positions - const newOrderSet = new Set(newOrder); - const hiddenColumns = prev.filter(col => !newOrderSet.has(col)); - return [...newOrder, ...hiddenColumns]; - }); + window.scrollTo({ top: 0, behavior: 'smooth' }); }; const renderPagination = () => { - if (!data?.pagination.pages || data.pagination.pages <= 1) return null; + if (!data) return null; - const currentPage = data.pagination.currentPage; - const totalPages = data.pagination.pages; - const maxVisiblePages = 7; - - let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); - let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); - - if (endPage - startPage + 1 < maxVisiblePages) { - startPage = Math.max(1, endPage - maxVisiblePages + 1); - } - - const pages = Array.from( - { length: endPage - startPage + 1 }, - (_, i) => startPage + i - ); + const { total, pages } = data.pagination; + if (total === 0) return null; return ( - - - - - handlePageChange(Math.max(1, page - 1))} - /> - - - {startPage > 1 && ( - <> - - handlePageChange(1)} - aria-disabled={isFetching} - className={isFetching ? 'pointer-events-none opacity-50' : ''} - > - 1 - - - {startPage > 2 && ...} - - )} - - {pages.map(p => ( - - handlePageChange(p)} - isActive={p === currentPage} - aria-disabled={isFetching} - className={isFetching ? 'pointer-events-none opacity-50' : ''} - > - {p} - - - ))} - - {endPage < totalPages && ( - <> - {endPage < totalPages - 1 && ...} - - handlePageChange(totalPages)} - aria-disabled={isFetching} - className={isFetching ? 'pointer-events-none opacity-50' : ''} - > - {totalPages} - - - - )} - - - handlePageChange(Math.min(data.pagination.pages, page + 1))} - /> - - - - +
+
+ Page {page} of {pages} +
+
+
+ + + + +
+
+
); }; + const renderColumnToggle = () => ( + + + + + e.preventDefault()} + > + Toggle columns + + {Object.entries(columnsByGroup).map(([group, columns]) => ( +
+ + {group} + + {columns.map((column) => ( + { + e.preventDefault(); + const newVisibleColumns = new Set(visibleColumns); + if (newVisibleColumns.has(column.key)) { + newVisibleColumns.delete(column.key); + } else { + newVisibleColumns.add(column.key); + } + setVisibleColumns(newVisibleColumns); + }} + > + {column.label} + + ))} + +
+ ))} +
+
+ ); + return ( - +

Products

@@ -396,39 +364,11 @@ export function Products() { {data.pagination.total.toLocaleString()} products
)} - - - - - - Toggle Columns - - {Object.entries(columnsByGroup).map(([group, columns]) => ( -
- - {group} - - {columns.map((col) => ( - toggleColumn(col.key)} - > - {col.label} - - ))} - -
- ))} -
-
+ {renderColumnToggle()}
- {isLoading || isFetching ? ( + {isFetching ? ( ) : (