Fix various issues with product table and details components

This commit is contained in:
2025-01-15 00:30:25 -05:00
parent 3efd171ec6
commit 0425912d3e
5 changed files with 673 additions and 633 deletions

View File

@@ -688,7 +688,10 @@ async function calculateMetrics() {
AVG(o.quantity) as avg_quantity_per_order, AVG(o.quantity) as avg_quantity_per_order,
-- Calculate rolling averages using configured windows -- 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_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 SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_month_qty
FROM orders o FROM orders o
JOIN products p ON o.product_id = p.product_id JOIN products p ON o.product_id = p.product_id
@@ -704,13 +707,14 @@ async function calculateMetrics() {
number_of_orders, number_of_orders,
avg_quantity_per_order, avg_quantity_per_order,
last_30_days_qty / ? as rolling_daily_avg, 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, last_month_qty / ? as rolling_monthly_avg,
total_quantity_sold as total_sales_to_date total_quantity_sold as total_sales_to_date
FROM sales_summary FROM sales_summary
`, [ `, [
config.daily_window_days, config.daily_window_days,
config.weekly_window_days, config.weekly_window_days,
config.weekly_window_days,
config.monthly_window_days, config.monthly_window_days,
product.product_id, product.product_id,
config.daily_window_days, config.daily_window_days,

View File

@@ -252,15 +252,29 @@ router.get('/', async (req, res) => {
pm.daily_sales_avg, pm.daily_sales_avg,
pm.weekly_sales_avg, pm.weekly_sales_avg,
pm.monthly_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.avg_margin_percent,
pm.total_revenue,
pm.inventory_value,
pm.cost_of_goods_sold,
pm.gross_profit,
pm.gmroi, pm.gmroi,
pm.avg_lead_time_days,
pm.last_purchase_date,
pm.last_received_date,
pm.abc_class, pm.abc_class,
pm.stock_status, pm.stock_status,
pm.avg_lead_time_days, pm.turnover_rate,
pm.current_lead_time, pm.current_lead_time,
pm.target_lead_time, pm.target_lead_time,
pm.lead_time_status, 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 COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
FROM products p FROM products p
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id 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(',') : [], categories: row.categories ? row.categories.split(',') : [],
price: parseFloat(row.price), price: parseFloat(row.price),
cost_price: parseFloat(row.cost_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), stock_quantity: parseInt(row.stock_quantity),
daily_sales_avg: parseFloat(row.daily_sales_avg) || 0, daily_sales_avg: parseFloat(row.daily_sales_avg) || 0,
weekly_sales_avg: parseFloat(row.weekly_sales_avg) || 0, weekly_sales_avg: parseFloat(row.weekly_sales_avg) || 0,
monthly_sales_avg: parseFloat(row.monthly_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, 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, gmroi: parseFloat(row.gmroi) || 0,
lead_time_days: parseInt(row.lead_time_days) || 0, avg_lead_time_days: parseFloat(row.avg_lead_time_days) || 0,
days_of_stock: parseFloat(row.days_of_stock) || 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 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 product_metrics pm ON p.product_id = pm.product_id
LEFT JOIN inventory_status is ON p.product_id = is.product_id LEFT JOIN inventory_status is ON p.product_id = is.product_id
WHERE p.product_id = ? WHERE p.product_id = ?
`, [id, id]); `, [id]);
if (!metrics.length) { if (!metrics.length) {
// Return default metrics structure if no data found // Return default metrics structure if no data found

View File

@@ -3,6 +3,8 @@ import { Drawer as VaulDrawer } from "vaul";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Card } from "@/components/ui/card"; 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import config from "@/config"; import config from "@/config";
@@ -23,10 +25,10 @@ interface Product {
replenishable: boolean; replenishable: boolean;
// Pricing fields // Pricing fields
price: string | number; price: number;
regular_price: string | number; regular_price: number;
cost_price: string | number; cost_price: number;
landing_cost_price: string | number | null; landing_cost_price: number | null;
// Categorization // Categorization
categories: string[]; categories: string[];
@@ -40,7 +42,7 @@ interface Product {
// URLs // URLs
permalink: string; permalink: string;
image: string; image: string | null;
// Metrics // Metrics
metrics: { metrics: {
@@ -124,7 +126,7 @@ interface Product {
} }
interface ProductDetailProps { interface ProductDetailProps {
productId: string | null; productId: number | null;
onClose: () => void; onClose: () => void;
} }
@@ -165,10 +167,19 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
const isLoading = isLoadingProduct || isLoadingTimeSeries; const isLoading = isLoadingProduct || isLoadingTimeSeries;
// Helper function to format price values // 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'; if (price === null || price === undefined) return 'N/A';
const numericPrice = typeof price === 'string' ? parseFloat(price) : price; return price.toFixed(2);
return typeof numericPrice === 'number' ? numericPrice.toFixed(2) : 'N/A'; };
// 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 // Combine product and time series data
@@ -184,27 +195,28 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
return ( return (
<VaulDrawer.Root open={!!productId} onOpenChange={(open) => !open && onClose()} direction="right"> <VaulDrawer.Root open={!!productId} onOpenChange={(open) => !open && onClose()} direction="right">
<VaulDrawer.Portal> <VaulDrawer.Portal>
<VaulDrawer.Content className="fixed right-0 top-0 h-full w-[90%] max-w-[800px] bg-background p-6 shadow-lg"> <VaulDrawer.Overlay className="fixed inset-0 bg-black/40" />
<div className="mb-8"> <VaulDrawer.Content className="fixed right-0 top-0 h-full w-[90%] max-w-[800px] bg-background p-6 shadow-lg flex flex-col">
<VaulDrawer.Title className="text-2xl font-bold"> <div className="flex items-start justify-between p-4 border-b">
{isLoading ? ( <div className="flex items-center gap-4">
<Skeleton className="h-8 w-[200px]" /> {product?.image && (
) : ( <div className="h-16 w-16 rounded-lg border bg-white p-1">
product?.title <img src={product.image} alt={product.title} className="h-full w-full object-contain" />
</div>
)} )}
</VaulDrawer.Title> <div>
<VaulDrawer.Description className="text-muted-foreground"> <h2 className="text-xl font-semibold">{product?.title || 'Loading...'}</h2>
{isLoading ? ( <p className="text-sm text-muted-foreground">{product?.SKU || ''}</p>
"\u00A0" </div>
) : ( </div>
`SKU: ${product?.SKU} | Stock: ${product?.stock_quantity}` <Button variant="ghost" size="icon" onClick={onClose}>
)} <X className="h-4 w-4" />
</VaulDrawer.Description> </Button>
</div> </div>
<div className="pb-8"> <Tabs defaultValue="overview" className="flex-1 overflow-auto">
<Tabs defaultValue="overview" className="w-full"> <div className="px-4 py-2 border-b bg-background sticky top-0 z-10">
<TabsList className="w-full justify-start mb-4 sticky top-0 bg-background z-10"> <TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="inventory">Inventory</TabsTrigger> <TabsTrigger value="inventory">Inventory</TabsTrigger>
<TabsTrigger value="sales">Sales</TabsTrigger> <TabsTrigger value="sales">Sales</TabsTrigger>
@@ -212,8 +224,9 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<TabsTrigger value="financial">Financial</TabsTrigger> <TabsTrigger value="financial">Financial</TabsTrigger>
<TabsTrigger value="vendor">Vendor</TabsTrigger> <TabsTrigger value="vendor">Vendor</TabsTrigger>
</TabsList> </TabsList>
</div>
<TabsContent value="overview" className="space-y-4"> <TabsContent value="overview" className="p-4">
{isLoading ? ( {isLoading ? (
<div className="space-y-4"> <div className="space-y-4">
<Skeleton className="h-24 w-full" /> <Skeleton className="h-24 w-full" />
@@ -313,26 +326,67 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
</dl> </dl>
</Card> </Card>
<Card className="p-4"> <Card className="p-4 col-span-2">
<h3 className="font-semibold mb-2">Sales Trend</h3> <h3 className="font-semibold mb-2">Sales Trend</h3>
<div className="h-64"> <div className="h-64">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={combinedData?.monthly_sales || []}> <LineChart data={combinedData?.monthly_sales || []}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" /> <XAxis dataKey="month" />
<YAxis /> <YAxis yAxisId="left" />
<YAxis yAxisId="right" orientation="right" />
<Tooltip /> <Tooltip />
<Line type="monotone" dataKey="quantity" stroke="#8884d8" name="Quantity" /> <Line yAxisId="left" type="monotone" dataKey="quantity" stroke="#8884d8" name="Quantity" />
<Line type="monotone" dataKey="revenue" stroke="#82ca9d" name="Revenue" /> <Line yAxisId="right" type="monotone" dataKey="revenue" stroke="#82ca9d" name="Revenue" />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</Card> </Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Financial Metrics</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Total Revenue</dt>
<dd>${formatPrice(product?.metrics.total_revenue)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Gross Profit</dt>
<dd>${formatPrice(product?.metrics.gross_profit)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Margin</dt>
<dd>{product?.metrics.avg_margin_percent.toFixed(2)}%</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">GMROI</dt>
<dd>{product?.metrics.gmroi.toFixed(2)}</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Lead Time</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Current Lead Time</dt>
<dd>{product?.metrics.current_lead_time}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Target Lead Time</dt>
<dd>{product?.metrics.target_lead_time}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Lead Time Status</dt>
<dd>{product?.metrics.lead_time_status}</dd>
</div>
</dl>
</Card>
</div> </div>
)} )}
</TabsContent> </TabsContent>
<TabsContent value="inventory" className="space-y-4"> <TabsContent value="inventory" className="p-4">
{isLoading ? ( {isLoading ? (
<Skeleton className="h-48 w-full" /> <Skeleton className="h-48 w-full" />
) : ( ) : (
@@ -376,7 +430,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="sales" className="space-y-4"> <TabsContent value="sales" className="p-4">
{isLoading ? ( {isLoading ? (
<Skeleton className="h-96 w-full" /> <Skeleton className="h-96 w-full" />
) : ( ) : (
@@ -397,7 +451,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<TableBody> <TableBody>
{combinedData?.recent_orders?.map((order: NonNullable<Product['recent_orders']>[number]) => ( {combinedData?.recent_orders?.map((order: NonNullable<Product['recent_orders']>[number]) => (
<TableRow key={order.order_number}> <TableRow key={order.order_number}>
<TableCell>{order.date}</TableCell> <TableCell>{formatDate(order.date)}</TableCell>
<TableCell>{order.order_number}</TableCell> <TableCell>{order.order_number}</TableCell>
<TableCell>{order.customer}</TableCell> <TableCell>{order.customer}</TableCell>
<TableCell>{order.quantity}</TableCell> <TableCell>{order.quantity}</TableCell>
@@ -436,7 +490,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="purchase" className="space-y-4"> <TabsContent value="purchase" className="p-4">
{isLoading ? ( {isLoading ? (
<Skeleton className="h-96 w-full" /> <Skeleton className="h-96 w-full" />
) : ( ) : (
@@ -457,7 +511,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<TableBody> <TableBody>
{combinedData?.recent_purchases?.map((po: NonNullable<Product['recent_purchases']>[number]) => ( {combinedData?.recent_purchases?.map((po: NonNullable<Product['recent_purchases']>[number]) => (
<TableRow key={po.po_id}> <TableRow key={po.po_id}>
<TableCell>{po.date}</TableCell> <TableCell>{formatDate(po.date)}</TableCell>
<TableCell>{po.po_id}</TableCell> <TableCell>{po.po_id}</TableCell>
<TableCell>{po.ordered}</TableCell> <TableCell>{po.ordered}</TableCell>
<TableCell>{po.received}</TableCell> <TableCell>{po.received}</TableCell>
@@ -479,7 +533,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="financial" className="space-y-4"> <TabsContent value="financial" className="p-4">
{isLoading ? ( {isLoading ? (
<Skeleton className="h-48 w-full" /> <Skeleton className="h-48 w-full" />
) : ( ) : (
@@ -534,25 +588,29 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="vendor" className="space-y-4"> <TabsContent value="vendor" className="p-4">
{isLoading ? ( {isLoading ? (
<Skeleton className="h-48 w-full" /> <Skeleton className="h-48 w-full" />
) : product?.vendor_performance ? ( ) : product?.vendor_performance ? (
<div className="space-y-4"> <div className="space-y-4">
<Card className="p-4"> <Card className="p-4">
<h3 className="font-semibold mb-2">Vendor Performance</h3> <h3 className="font-semibold mb-2">Vendor Performance</h3>
<dl className="grid grid-cols-3 gap-4"> <dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Lead Time</dt>
<dd>{product?.vendor_performance?.avg_lead_time_days?.toFixed(1) || "N/A"} days</dd>
</div>
<div> <div>
<dt className="text-sm text-muted-foreground">On-Time Delivery</dt> <dt className="text-sm text-muted-foreground">On-Time Delivery</dt>
<dd className="text-2xl font-semibold">{product.vendor_performance.on_time_delivery_rate.toFixed(1)}%</dd> <dd>{product?.vendor_performance?.on_time_delivery_rate?.toFixed(1) || "N/A"}%</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Order Fill Rate</dt> <dt className="text-sm text-muted-foreground">Order Fill Rate</dt>
<dd className="text-2xl font-semibold">{product.vendor_performance.order_fill_rate.toFixed(1)}%</dd> <dd>{product?.vendor_performance?.order_fill_rate?.toFixed(1) || "N/A"}%</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Avg Lead Time</dt> <dt className="text-sm text-muted-foreground">Total Orders</dt>
<dd className="text-2xl font-semibold">{product.vendor_performance.avg_lead_time_days} days</dd> <dd>{product?.vendor_performance?.total_orders || 0}</dd>
</div> </div>
</dl> </dl>
</Card> </Card>
@@ -562,19 +620,19 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<dl className="grid grid-cols-2 gap-4"> <dl className="grid grid-cols-2 gap-4">
<div> <div>
<dt className="text-sm text-muted-foreground">Total Orders</dt> <dt className="text-sm text-muted-foreground">Total Orders</dt>
<dd>{product.vendor_performance.total_orders}</dd> <dd>{product?.vendor_performance?.total_orders}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Late Orders</dt> <dt className="text-sm text-muted-foreground">Late Orders</dt>
<dd>{product.vendor_performance.total_late_orders}</dd> <dd>{product?.vendor_performance?.total_late_orders}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Total Purchase Value</dt> <dt className="text-sm text-muted-foreground">Total Purchase Value</dt>
<dd>${formatPrice(product.vendor_performance.total_purchase_value)}</dd> <dd>${formatPrice(product?.vendor_performance?.total_purchase_value)}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Avg Order Value</dt> <dt className="text-sm text-muted-foreground">Avg Order Value</dt>
<dd>${formatPrice(product.vendor_performance.avg_order_value)}</dd> <dd>${formatPrice(product?.vendor_performance?.avg_order_value)}</dd>
</div> </div>
</dl> </dl>
</Card> </Card>
@@ -584,7 +642,6 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
)} )}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div>
</VaulDrawer.Content> </VaulDrawer.Content>
</VaulDrawer.Portal> </VaulDrawer.Portal>
</VaulDrawer.Root> </VaulDrawer.Root>

View File

@@ -28,25 +28,29 @@ import {
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
interface Product { interface Product {
product_id: string; product_id: number;
title: string; title: string;
sku: string; SKU: string;
stock_quantity: number; stock_quantity: number;
price: number; price: number;
regular_price: number; regular_price: number;
cost_price: number; cost_price: number;
landing_cost_price: number; landing_cost_price: number | null;
barcode: string; barcode: string;
vendor: string; vendor: string;
vendor_reference: string; vendor_reference: string;
brand: string; brand: string;
categories: string[]; categories: string[];
tags: string[];
options: Record<string, any>;
image: string | null; image: string | null;
moq: number; moq: number;
uom: number; uom: number;
visible: boolean; visible: boolean;
managing_stock: boolean; managing_stock: boolean;
replenishable: boolean; replenishable: boolean;
created_at: string;
updated_at: string;
// Metrics // Metrics
daily_sales_avg?: number; daily_sales_avg?: number;
@@ -77,8 +81,10 @@ interface Product {
lead_time_status?: string; lead_time_status?: string;
} }
type ColumnKey = keyof Product | 'image';
interface ColumnDef { interface ColumnDef {
key: keyof Product | 'image'; key: ColumnKey;
label: string; label: string;
group: string; group: string;
format?: (value: any) => string | number; format?: (value: any) => string | number;
@@ -88,21 +94,21 @@ interface ColumnDef {
interface ProductTableProps { interface ProductTableProps {
products: Product[]; products: Product[];
onSort: (column: keyof Product) => void; onSort: (column: ColumnKey) => void;
sortColumn: keyof Product; sortColumn: ColumnKey;
sortDirection: 'asc' | 'desc'; sortDirection: 'asc' | 'desc';
visibleColumns: Set<keyof Product | 'image'>; visibleColumns: Set<ColumnKey>;
columnDefs: ColumnDef[]; columnDefs: ColumnDef[];
columnOrder: (keyof Product | 'image')[]; columnOrder: ColumnKey[];
onColumnOrderChange?: (columns: (keyof Product | 'image')[]) => void; onColumnOrderChange?: (columns: ColumnKey[]) => void;
onRowClick?: (product: Product) => void; onRowClick?: (product: Product) => void;
} }
interface SortableHeaderProps { interface SortableHeaderProps {
column: keyof Product; column: ColumnKey;
columnDef?: ColumnDef; columnDef?: ColumnDef;
onSort: (column: keyof Product) => void; onSort: (column: ColumnKey) => void;
sortColumn: keyof Product; sortColumn: ColumnKey;
sortDirection: 'asc' | 'desc'; sortDirection: 'asc' | 'desc';
} }
@@ -164,7 +170,7 @@ export function ProductTable({
onColumnOrderChange, onColumnOrderChange,
onRowClick, onRowClick,
}: ProductTableProps) { }: ProductTableProps) {
const [, setActiveId] = React.useState<keyof Product | null>(null); const [, setActiveId] = React.useState<ColumnKey | null>(null);
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, { useSensor(MouseSensor, {
activationConstraint: { activationConstraint: {
@@ -185,7 +191,7 @@ export function ProductTable({
}, [columnOrder, visibleColumns]); }, [columnOrder, visibleColumns]);
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as keyof Product); setActiveId(event.active.id as ColumnKey);
}; };
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
@@ -193,8 +199,8 @@ export function ProductTable({
setActiveId(null); setActiveId(null);
if (over && active.id !== over.id) { if (over && active.id !== over.id) {
const oldIndex = orderedColumns.indexOf(active.id as keyof Product); const oldIndex = orderedColumns.indexOf(active.id as ColumnKey);
const newIndex = orderedColumns.indexOf(over.id as keyof Product); const newIndex = orderedColumns.indexOf(over.id as ColumnKey);
const newOrder = arrayMove(orderedColumns, oldIndex, newIndex); const newOrder = arrayMove(orderedColumns, oldIndex, newIndex);
onColumnOrderChange?.(newOrder); onColumnOrderChange?.(newOrder);
@@ -248,9 +254,9 @@ export function ProductTable({
} }
}; };
const formatColumnValue = (product: Product, column: keyof Product | 'image') => { const formatColumnValue = (product: Product, column: ColumnKey) => {
const value = column === 'image' ? product.image : product[column as keyof Product];
const columnDef = columnDefs.find(def => def.key === column); const columnDef = columnDefs.find(def => def.key === column);
let value: any = product[column as keyof Product];
switch (column) { switch (column) {
case 'image': case 'image':
@@ -267,7 +273,7 @@ export function ProductTable({
return ( return (
<div className="min-w-[200px]"> <div className="min-w-[200px]">
<div className="font-medium">{product.title}</div> <div className="font-medium">{product.title}</div>
<div className="text-sm text-muted-foreground">{product.sku}</div> <div className="text-sm text-muted-foreground">{product.SKU}</div>
</div> </div>
); );
case 'categories': case 'categories':
@@ -279,11 +285,11 @@ export function ProductTable({
</div> </div>
); );
case 'stock_status': case 'stock_status':
return getStockStatus(value as string); return getStockStatus(product.stock_status);
case 'abc_class': case 'abc_class':
return getABCClass(value as string); return getABCClass(product.abc_class);
case 'lead_time_status': case 'lead_time_status':
return getLeadTimeStatus(value as string); return getLeadTimeStatus(product.lead_time_status);
case 'visible': case 'visible':
return value ? ( return value ? (
<Badge variant="secondary">Active</Badge> <Badge variant="secondary">Active</Badge>
@@ -301,7 +307,7 @@ export function ProductTable({
} }
return columnDef.format(value); return columnDef.format(value);
} }
return value || '-'; return value ?? '-';
} }
}; };

View File

@@ -1,17 +1,10 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { ProductFilters } from '@/components/products/ProductFilters'; import { ProductFilters } from '@/components/products/ProductFilters';
import { ProductTable } from '@/components/products/ProductTable'; import { ProductTable } from '@/components/products/ProductTable';
import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton'; import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton';
import { ProductDetail } from '@/components/products/ProductDetail'; import { ProductDetail } from '@/components/products/ProductDetail';
import { import { Button } from '@/components/ui/button';
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
@@ -19,35 +12,38 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from '@/components/ui/dropdown-menu';
import { Button } from "@/components/ui/button"; import { Settings2, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react';
import { Settings2 } from "lucide-react"; import { motion } from 'framer-motion';
import config from '../config';
import { motion } from 'motion/react';
// Enhanced Product interface with all possible fields // Enhanced Product interface with all possible fields
interface Product { interface Product {
// Basic product info (from products table) // Basic product info
product_id: string; product_id: number;
title: string; title: string;
sku: string; SKU: string;
stock_quantity: number; stock_quantity: number;
price: number; price: number;
regular_price: number; regular_price: number;
cost_price: number; cost_price: number;
landing_cost_price: number; landing_cost_price: number | null;
barcode: string; barcode: string;
vendor: string; vendor: string;
vendor_reference: string; vendor_reference: string;
brand: string; brand: string;
categories: string[]; categories: string[];
tags: string[];
options: Record<string, any>;
image: string | null; image: string | null;
moq: number; moq: number;
uom: number; uom: number;
visible: boolean; visible: boolean;
managing_stock: boolean; managing_stock: boolean;
replenishable: boolean; replenishable: boolean;
created_at: string;
updated_at: string;
// Metrics (from product_metrics table) // Metrics
daily_sales_avg?: number; daily_sales_avg?: number;
weekly_sales_avg?: number; weekly_sales_avg?: number;
monthly_sales_avg?: number; monthly_sales_avg?: number;
@@ -76,7 +72,6 @@ interface Product {
lead_time_status?: string; lead_time_status?: string;
} }
// Column definition interface // Column definition interface
interface ColumnDef { interface ColumnDef {
key: keyof Product | 'image'; key: keyof Product | 'image';
@@ -94,7 +89,6 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
// Basic Info Group // Basic Info Group
{ key: 'title', label: 'Title', group: 'Basic Info' }, { key: 'title', label: 'Title', group: 'Basic Info' },
{ key: 'sku', label: 'SKU', group: 'Basic Info' },
{ key: 'brand', label: 'Brand', group: 'Basic Info' }, { key: 'brand', label: 'Brand', group: 'Basic Info' },
{ key: 'categories', label: 'Categories', group: 'Basic Info' }, { key: 'categories', label: 'Categories', group: 'Basic Info' },
{ key: 'vendor', label: 'Vendor', 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')[] = [ const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [
'image', 'image',
'title', 'title',
'sku',
'stock_quantity', 'stock_quantity',
'stock_status', 'stock_status',
'price', 'price',
@@ -160,7 +153,6 @@ const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [
]; ];
export function Products() { export function Products() {
const queryClient = useQueryClient();
const [filters, setFilters] = useState<Record<string, string | number | boolean>>({}); const [filters, setFilters] = useState<Record<string, string | number | boolean>>({});
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');
@@ -170,7 +162,7 @@ export function Products() {
...DEFAULT_VISIBLE_COLUMNS, ...DEFAULT_VISIBLE_COLUMNS,
...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key)) ...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key))
]); ]);
const [selectedProductId, setSelectedProductId] = useState<string | null>(null); const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
// Group columns by their group property // Group columns by their group property
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => { const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
@@ -179,88 +171,55 @@ export function Products() {
} }
acc[col.group].push(col); acc[col.group].push(col);
return acc; return acc;
}, {} as Record<string, ColumnDef[]>); }, {} as Record<string, typeof AVAILABLE_COLUMNS>);
// Toggle column visibility // Handle column reordering from drag and drop
const toggleColumn = (columnKey: keyof Product | 'image') => { const handleColumnOrderChange = (newOrder: (keyof Product | 'image')[]) => {
setVisibleColumns(prev => { setColumnOrder(newOrder);
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 () => {
const searchParams = new URLSearchParams({ const params = new URLSearchParams();
page: pageNum.toString(),
limit: '100', // Add pagination params
sortColumn: sortColumn.toString(), params.append('page', page.toString());
sortDirection, params.append('limit', '50');
...filters,
// 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) { if (!response.ok) {
throw new Error('Network response was not ok'); throw new Error('Failed to fetch products');
} }
const result = await response.json(); return response.json();
return result;
}; };
const { data, isLoading, isFetching } = useQuery({ // Query for products data
queryKey: ['products', filters, sortColumn, sortDirection, page], const { data, isFetching } = useQuery({
queryFn: () => fetchProducts(page), queryKey: ['products', page, sortColumn, sortDirection, filters],
queryFn: fetchProducts,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 30000,
}); });
// Enhanced prefetching strategy // Handle sort column change
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]);
const handleSort = (column: keyof Product) => { const handleSort = (column: keyof Product) => {
if (sortColumn === column) { setSortDirection(prev => {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); if (sortColumn !== column) return 'asc';
} else { return prev === 'asc' ? 'desc' : 'asc';
});
setSortColumn(column); setSortColumn(column);
setSortDirection('asc');
}
}; };
// Handle filter changes // Handle filter changes
@@ -275,109 +234,118 @@ export function Products() {
}; };
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
window.scrollTo({ top: 0 });
setPage(newPage); setPage(newPage);
}; window.scrollTo({ top: 0, behavior: 'smooth' });
// 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];
});
}; };
const renderPagination = () => { const renderPagination = () => {
if (!data?.pagination.pages || data.pagination.pages <= 1) return null; if (!data) return null;
const currentPage = data.pagination.currentPage; const { total, pages } = data.pagination;
const totalPages = data.pagination.pages; if (total === 0) return null;
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
);
return ( return (
<div className="flex items-center justify-between px-2">
<Pagination> <div className="flex w-[100px] items-center justify-center text-sm font-medium">
<PaginationContent> Page {page} of {pages}
<PaginationItem> </div>
<PaginationPrevious <div className="flex items-center space-x-6 lg:space-x-8">
aria-disabled={page === 1 || isFetching} <div className="flex items-center space-x-2">
className={page === 1 || isFetching ? 'pointer-events-none opacity-50' : ''} <Button
onClick={() => handlePageChange(Math.max(1, page - 1))} variant="outline"
/> className="hidden h-8 w-8 p-0 lg:flex"
</PaginationItem>
{startPage > 1 && (
<>
<PaginationItem>
<PaginationLink
onClick={() => handlePageChange(1)} onClick={() => handlePageChange(1)}
aria-disabled={isFetching} disabled={page === 1}
className={isFetching ? 'pointer-events-none opacity-50' : ''}
> >
1 <span className="sr-only">Go to first page</span>
</PaginationLink> <ChevronsLeft className="h-4 w-4" />
</PaginationItem> </Button>
{startPage > 2 && <PaginationItem>...</PaginationItem>} <Button
</> variant="outline"
)} className="h-8 w-8 p-0"
onClick={() => handlePageChange(page - 1)}
{pages.map(p => ( disabled={page === 1}
<PaginationItem key={p}>
<PaginationLink
onClick={() => handlePageChange(p)}
isActive={p === currentPage}
aria-disabled={isFetching}
className={isFetching ? 'pointer-events-none opacity-50' : ''}
> >
{p} <span className="sr-only">Go to previous page</span>
</PaginationLink> <ChevronLeft className="h-4 w-4" />
</PaginationItem> </Button>
))} <Button
variant="outline"
{endPage < totalPages && ( className="h-8 w-8 p-0"
<> onClick={() => handlePageChange(page + 1)}
{endPage < totalPages - 1 && <PaginationItem>...</PaginationItem>} disabled={page === pages}
<PaginationItem>
<PaginationLink
onClick={() => handlePageChange(totalPages)}
aria-disabled={isFetching}
className={isFetching ? 'pointer-events-none opacity-50' : ''}
> >
{totalPages} <span className="sr-only">Go to next page</span>
</PaginationLink> <ChevronRight className="h-4 w-4" />
</PaginationItem> </Button>
</> <Button
)} variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
<PaginationItem> onClick={() => handlePageChange(pages)}
<PaginationNext disabled={page === pages}
aria-disabled={page === data.pagination.pages || isFetching} >
className={page === data.pagination.pages || isFetching ? 'pointer-events-none opacity-50' : ''} <span className="sr-only">Go to last page</span>
onClick={() => handlePageChange(Math.min(data.pagination.pages, page + 1))} <ChevronsRight className="h-4 w-4" />
/> </Button>
</PaginationItem> </div>
</PaginationContent> </div>
</Pagination> </div>
); );
}; };
const renderColumnToggle = () => (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
<Settings2 className="mr-2 h-4 w-4" />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-[280px] max-h-[calc(100vh-4rem)] overflow-y-auto"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuLabel className="sticky top-0 bg-background z-10">Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator className="sticky top-[29px] bg-background z-10" />
{Object.entries(columnsByGroup).map(([group, columns]) => (
<div key={group}>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
{group}
</DropdownMenuLabel>
{columns.map((column) => (
<DropdownMenuCheckboxItem
key={column.key}
className="capitalize"
checked={visibleColumns.has(column.key)}
onSelect={(e) => {
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}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
);
return ( return (
<motion.div layout className="p-8 space-y-8"> <motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="p-8 space-y-8"
>
<h1 className="text-2xl font-bold">Products</h1> <h1 className="text-2xl font-bold">Products</h1>
<div> <div>
@@ -396,39 +364,11 @@ export function Products() {
{data.pagination.total.toLocaleString()} products {data.pagination.total.toLocaleString()} products
</div> </div>
)} )}
<DropdownMenu> {renderColumnToggle()}
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Settings2 className="mr-2 h-4 w-4" />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px] max-h-[calc(100vh-4rem)] overflow-y-auto">
<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>
<div className="mt-4"> <div className="mt-4">
{isLoading || isFetching ? ( {isFetching ? (
<ProductTableSkeleton /> <ProductTableSkeleton />
) : ( ) : (
<ProductTable <ProductTable