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

@@ -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 (
<VaulDrawer.Root open={!!productId} onOpenChange={(open) => !open && onClose()} direction="right">
<VaulDrawer.Portal>
<VaulDrawer.Content className="fixed right-0 top-0 h-full w-[90%] max-w-[800px] bg-background p-6 shadow-lg">
<div className="mb-8">
<VaulDrawer.Title className="text-2xl font-bold">
{isLoading ? (
<Skeleton className="h-8 w-[200px]" />
) : (
product?.title
<VaulDrawer.Overlay className="fixed inset-0 bg-black/40" />
<VaulDrawer.Content className="fixed right-0 top-0 h-full w-[90%] max-w-[800px] bg-background p-6 shadow-lg flex flex-col">
<div className="flex items-start justify-between p-4 border-b">
<div className="flex items-center gap-4">
{product?.image && (
<div className="h-16 w-16 rounded-lg border bg-white p-1">
<img src={product.image} alt={product.title} className="h-full w-full object-contain" />
</div>
)}
</VaulDrawer.Title>
<VaulDrawer.Description className="text-muted-foreground">
{isLoading ? (
"\u00A0"
) : (
`SKU: ${product?.SKU} | Stock: ${product?.stock_quantity}`
)}
</VaulDrawer.Description>
<div>
<h2 className="text-xl font-semibold">{product?.title || 'Loading...'}</h2>
<p className="text-sm text-muted-foreground">{product?.SKU || ''}</p>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="pb-8">
<Tabs defaultValue="overview" className="w-full">
<TabsList className="w-full justify-start mb-4 sticky top-0 bg-background z-10">
<Tabs defaultValue="overview" className="flex-1 overflow-auto">
<div className="px-4 py-2 border-b bg-background sticky top-0 z-10">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="inventory">Inventory</TabsTrigger>
<TabsTrigger value="sales">Sales</TabsTrigger>
@@ -212,381 +224,426 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<TabsTrigger value="financial">Financial</TabsTrigger>
<TabsTrigger value="vendor">Vendor</TabsTrigger>
</TabsList>
</div>
<TabsContent value="overview" className="space-y-4">
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Basic Information</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Brand</dt>
<dd>{product?.brand || "N/A"}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Vendor</dt>
<dd>{product?.vendor || "N/A"}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Categories</dt>
<dd className="flex flex-wrap gap-2">
{product?.categories?.map(category => (
<span key={category} className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted">
{category}
</span>
)) || "N/A"}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Tags</dt>
<dd className="flex flex-wrap gap-2">
{product?.tags?.map(tag => (
<span key={tag} className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted">
{tag}
</span>
)) || "N/A"}
</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Pricing</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Price</dt>
<dd>${formatPrice(product?.price)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Regular Price</dt>
<dd>${formatPrice(product?.regular_price)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Cost Price</dt>
<dd>${formatPrice(product?.cost_price)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
<dd>${formatPrice(product?.landing_cost_price)}</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Stock Status</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Current Stock</dt>
<dd className="text-2xl font-semibold">{product?.stock_quantity}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Status</dt>
<dd>{product?.metrics?.stock_status}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Days of Stock</dt>
<dd>{product?.metrics?.days_of_inventory} days</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Sales Velocity</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Daily Sales</dt>
<dd>{product?.metrics?.daily_sales_avg?.toFixed(1)} units</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Weekly Sales</dt>
<dd>{product?.metrics?.weekly_sales_avg?.toFixed(1)} units</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Monthly Sales</dt>
<dd>{product?.metrics?.monthly_sales_avg?.toFixed(1)} units</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Sales Trend</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={combinedData?.monthly_sales || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="quantity" stroke="#8884d8" name="Quantity" />
<Line type="monotone" dataKey="revenue" stroke="#82ca9d" name="Revenue" />
</LineChart>
</ResponsiveContainer>
<TabsContent value="overview" className="p-4">
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Basic Information</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Brand</dt>
<dd>{product?.brand || "N/A"}</dd>
</div>
</Card>
</div>
)}
</TabsContent>
<div>
<dt className="text-sm text-muted-foreground">Vendor</dt>
<dd>{product?.vendor || "N/A"}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Categories</dt>
<dd className="flex flex-wrap gap-2">
{product?.categories?.map(category => (
<span key={category} className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted">
{category}
</span>
)) || "N/A"}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Tags</dt>
<dd className="flex flex-wrap gap-2">
{product?.tags?.map(tag => (
<span key={tag} className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted">
{tag}
</span>
)) || "N/A"}
</dd>
</div>
</dl>
</Card>
<TabsContent value="inventory" className="space-y-4">
{isLoading ? (
<Skeleton className="h-48 w-full" />
) : (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Current Stock</h3>
<dl className="grid grid-cols-3 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Stock Quantity</dt>
<dd className="text-2xl font-semibold">{product?.stock_quantity}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Days of Inventory</dt>
<dd className="text-2xl font-semibold">{product?.metrics?.days_of_inventory || 0}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Status</dt>
<dd className="text-2xl font-semibold">{product?.metrics?.stock_status || "N/A"}</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Pricing</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Price</dt>
<dd>${formatPrice(product?.price)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Regular Price</dt>
<dd>${formatPrice(product?.regular_price)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Cost Price</dt>
<dd>${formatPrice(product?.cost_price)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
<dd>${formatPrice(product?.landing_cost_price)}</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Stock Thresholds</h3>
<dl className="grid grid-cols-3 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Reorder Point</dt>
<dd>{product?.metrics?.reorder_point || 0}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Safety Stock</dt>
<dd>{product?.metrics?.safety_stock || 0}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">ABC Class</dt>
<dd>{product?.metrics?.abc_class || "N/A"}</dd>
</div>
</dl>
</Card>
</div>
)}
</TabsContent>
<Card className="p-4">
<h3 className="font-semibold mb-2">Stock Status</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Current Stock</dt>
<dd className="text-2xl font-semibold">{product?.stock_quantity}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Status</dt>
<dd>{product?.metrics?.stock_status}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Days of Stock</dt>
<dd>{product?.metrics?.days_of_inventory} days</dd>
</div>
</dl>
</Card>
<TabsContent value="sales" className="space-y-4">
{isLoading ? (
<Skeleton className="h-96 w-full" />
) : (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Recent Orders</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Order #</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Price</TableHead>
<TableHead>Status</TableHead>
<Card className="p-4">
<h3 className="font-semibold mb-2">Sales Velocity</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Daily Sales</dt>
<dd>{product?.metrics?.daily_sales_avg?.toFixed(1)} units</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Weekly Sales</dt>
<dd>{product?.metrics?.weekly_sales_avg?.toFixed(1)} units</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Monthly Sales</dt>
<dd>{product?.metrics?.monthly_sales_avg?.toFixed(1)} units</dd>
</div>
</dl>
</Card>
<Card className="p-4 col-span-2">
<h3 className="font-semibold mb-2">Sales Trend</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={combinedData?.monthly_sales || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis yAxisId="left" />
<YAxis yAxisId="right" orientation="right" />
<Tooltip />
<Line yAxisId="left" type="monotone" dataKey="quantity" stroke="#8884d8" name="Quantity" />
<Line yAxisId="right" type="monotone" dataKey="revenue" stroke="#82ca9d" name="Revenue" />
</LineChart>
</ResponsiveContainer>
</div>
</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>
)}
</TabsContent>
<TabsContent value="inventory" className="p-4">
{isLoading ? (
<Skeleton className="h-48 w-full" />
) : (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Current Stock</h3>
<dl className="grid grid-cols-3 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Stock Quantity</dt>
<dd className="text-2xl font-semibold">{product?.stock_quantity}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Days of Inventory</dt>
<dd className="text-2xl font-semibold">{product?.metrics?.days_of_inventory || 0}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Status</dt>
<dd className="text-2xl font-semibold">{product?.metrics?.stock_status || "N/A"}</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Stock Thresholds</h3>
<dl className="grid grid-cols-3 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Reorder Point</dt>
<dd>{product?.metrics?.reorder_point || 0}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Safety Stock</dt>
<dd>{product?.metrics?.safety_stock || 0}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">ABC Class</dt>
<dd>{product?.metrics?.abc_class || "N/A"}</dd>
</div>
</dl>
</Card>
</div>
)}
</TabsContent>
<TabsContent value="sales" className="p-4">
{isLoading ? (
<Skeleton className="h-96 w-full" />
) : (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Recent Orders</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Order #</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Price</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{combinedData?.recent_orders?.map((order: NonNullable<Product['recent_orders']>[number]) => (
<TableRow key={order.order_number}>
<TableCell>{formatDate(order.date)}</TableCell>
<TableCell>{order.order_number}</TableCell>
<TableCell>{order.customer}</TableCell>
<TableCell>{order.quantity}</TableCell>
<TableCell>${formatPrice(order.price)}</TableCell>
<TableCell>{order.status}</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{combinedData?.recent_orders?.map((order: NonNullable<Product['recent_orders']>[number]) => (
<TableRow key={order.order_number}>
<TableCell>{order.date}</TableCell>
<TableCell>{order.order_number}</TableCell>
<TableCell>{order.customer}</TableCell>
<TableCell>{order.quantity}</TableCell>
<TableCell>${formatPrice(order.price)}</TableCell>
<TableCell>{order.status}</TableCell>
</TableRow>
))}
{(!combinedData?.recent_orders || combinedData.recent_orders.length === 0) && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No recent orders
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Monthly Sales Trend</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={combinedData?.monthly_sales || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis yAxisId="left" />
<YAxis yAxisId="right" orientation="right" />
<Tooltip />
<Line yAxisId="left" type="monotone" dataKey="quantity" stroke="#8884d8" name="Quantity" />
<Line yAxisId="right" type="monotone" dataKey="revenue" stroke="#82ca9d" name="Revenue" />
</LineChart>
</ResponsiveContainer>
</div>
</Card>
</div>
)}
</TabsContent>
<TabsContent value="purchase" className="space-y-4">
{isLoading ? (
<Skeleton className="h-96 w-full" />
) : (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Recent Purchase Orders</h3>
<Table>
<TableHeader>
))}
{(!combinedData?.recent_orders || combinedData.recent_orders.length === 0) && (
<TableRow>
<TableHead>Date</TableHead>
<TableHead>PO #</TableHead>
<TableHead>Ordered</TableHead>
<TableHead>Received</TableHead>
<TableHead>Status</TableHead>
<TableHead>Lead Time</TableHead>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No recent orders
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{combinedData?.recent_purchases?.map((po: NonNullable<Product['recent_purchases']>[number]) => (
<TableRow key={po.po_id}>
<TableCell>{po.date}</TableCell>
<TableCell>{po.po_id}</TableCell>
<TableCell>{po.ordered}</TableCell>
<TableCell>{po.received}</TableCell>
<TableCell>{po.status}</TableCell>
<TableCell>{po.lead_time_days ? `${po.lead_time_days} days` : 'N/A'}</TableCell>
</TableRow>
))}
{(!combinedData?.recent_purchases || combinedData.recent_purchases.length === 0) && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No recent purchase orders
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
</div>
)}
</TabsContent>
)}
</TableBody>
</Table>
</Card>
<TabsContent value="financial" className="space-y-4">
{isLoading ? (
<Skeleton className="h-48 w-full" />
) : (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Financial Overview</h3>
<dl className="grid grid-cols-3 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Gross Profit</dt>
<dd className="text-2xl font-semibold">${formatPrice(product?.metrics.gross_profit)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">GMROI</dt>
<dd className="text-2xl font-semibold">{product?.metrics.gmroi.toFixed(2)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Margin %</dt>
<dd className="text-2xl font-semibold">{product?.metrics.avg_margin_percent.toFixed(2)}%</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Monthly Sales Trend</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={combinedData?.monthly_sales || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis yAxisId="left" />
<YAxis yAxisId="right" orientation="right" />
<Tooltip />
<Line yAxisId="left" type="monotone" dataKey="quantity" stroke="#8884d8" name="Quantity" />
<Line yAxisId="right" type="monotone" dataKey="revenue" stroke="#82ca9d" name="Revenue" />
</LineChart>
</ResponsiveContainer>
</div>
</Card>
</div>
)}
</TabsContent>
<Card className="p-4">
<h3 className="font-semibold mb-2">Cost Breakdown</h3>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Cost of Goods Sold</dt>
<dd>${formatPrice(product?.metrics.cost_of_goods_sold)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
<dd>${formatPrice(product?.landing_cost_price)}</dd>
</div>
</dl>
</Card>
<TabsContent value="purchase" className="p-4">
{isLoading ? (
<Skeleton className="h-96 w-full" />
) : (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Recent Purchase Orders</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>PO #</TableHead>
<TableHead>Ordered</TableHead>
<TableHead>Received</TableHead>
<TableHead>Status</TableHead>
<TableHead>Lead Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{combinedData?.recent_purchases?.map((po: NonNullable<Product['recent_purchases']>[number]) => (
<TableRow key={po.po_id}>
<TableCell>{formatDate(po.date)}</TableCell>
<TableCell>{po.po_id}</TableCell>
<TableCell>{po.ordered}</TableCell>
<TableCell>{po.received}</TableCell>
<TableCell>{po.status}</TableCell>
<TableCell>{po.lead_time_days ? `${po.lead_time_days} days` : 'N/A'}</TableCell>
</TableRow>
))}
{(!combinedData?.recent_purchases || combinedData.recent_purchases.length === 0) && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No recent purchase orders
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
</div>
)}
</TabsContent>
<Card className="p-4">
<h3 className="font-semibold mb-2">Profit Margin Trend</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={combinedData?.monthly_sales || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line type="monotone" dataKey="profit_margin" stroke="#82ca9d" name="Profit Margin %" />
</LineChart>
</ResponsiveContainer>
<TabsContent value="financial" className="p-4">
{isLoading ? (
<Skeleton className="h-48 w-full" />
) : (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Financial Overview</h3>
<dl className="grid grid-cols-3 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Gross Profit</dt>
<dd className="text-2xl font-semibold">${formatPrice(product?.metrics.gross_profit)}</dd>
</div>
</Card>
</div>
)}
</TabsContent>
<div>
<dt className="text-sm text-muted-foreground">GMROI</dt>
<dd className="text-2xl font-semibold">{product?.metrics.gmroi.toFixed(2)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Margin %</dt>
<dd className="text-2xl font-semibold">{product?.metrics.avg_margin_percent.toFixed(2)}%</dd>
</div>
</dl>
</Card>
<TabsContent value="vendor" className="space-y-4">
{isLoading ? (
<Skeleton className="h-48 w-full" />
) : product?.vendor_performance ? (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Vendor Performance</h3>
<dl className="grid grid-cols-3 gap-4">
<div>
<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>
</div>
<div>
<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>
</div>
<div>
<dt className="text-sm text-muted-foreground">Avg Lead Time</dt>
<dd className="text-2xl font-semibold">{product.vendor_performance.avg_lead_time_days} days</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Cost Breakdown</h3>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Cost of Goods Sold</dt>
<dd>${formatPrice(product?.metrics.cost_of_goods_sold)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
<dd>${formatPrice(product?.landing_cost_price)}</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Order History</h3>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Total Orders</dt>
<dd>{product.vendor_performance.total_orders}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Late Orders</dt>
<dd>{product.vendor_performance.total_late_orders}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Total Purchase Value</dt>
<dd>${formatPrice(product.vendor_performance.total_purchase_value)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Avg Order Value</dt>
<dd>${formatPrice(product.vendor_performance.avg_order_value)}</dd>
</div>
</dl>
</Card>
</div>
) : (
<div className="text-center text-muted-foreground">No vendor performance data available</div>
)}
</TabsContent>
</Tabs>
</div>
<Card className="p-4">
<h3 className="font-semibold mb-2">Profit Margin Trend</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={combinedData?.monthly_sales || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line type="monotone" dataKey="profit_margin" stroke="#82ca9d" name="Profit Margin %" />
</LineChart>
</ResponsiveContainer>
</div>
</Card>
</div>
)}
</TabsContent>
<TabsContent value="vendor" className="p-4">
{isLoading ? (
<Skeleton className="h-48 w-full" />
) : product?.vendor_performance ? (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Vendor Performance</h3>
<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>
<dt className="text-sm text-muted-foreground">On-Time Delivery</dt>
<dd>{product?.vendor_performance?.on_time_delivery_rate?.toFixed(1) || "N/A"}%</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Order Fill Rate</dt>
<dd>{product?.vendor_performance?.order_fill_rate?.toFixed(1) || "N/A"}%</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Total Orders</dt>
<dd>{product?.vendor_performance?.total_orders || 0}</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Order History</h3>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Total Orders</dt>
<dd>{product?.vendor_performance?.total_orders}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Late Orders</dt>
<dd>{product?.vendor_performance?.total_late_orders}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Total Purchase Value</dt>
<dd>${formatPrice(product?.vendor_performance?.total_purchase_value)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Avg Order Value</dt>
<dd>${formatPrice(product?.vendor_performance?.avg_order_value)}</dd>
</div>
</dl>
</Card>
</div>
) : (
<div className="text-center text-muted-foreground">No vendor performance data available</div>
)}
</TabsContent>
</Tabs>
</VaulDrawer.Content>
</VaulDrawer.Portal>
</VaulDrawer.Root>
);
}
}

View File

@@ -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<string, any>;
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<keyof Product | 'image'>;
visibleColumns: Set<ColumnKey>;
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<keyof Product | null>(null);
const [, setActiveId] = React.useState<ColumnKey | null>(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 (
<div className="min-w-[200px]">
<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>
);
case 'categories':
@@ -279,11 +285,11 @@ export function ProductTable({
</div>
);
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 ? (
<Badge variant="secondary">Active</Badge>
@@ -301,7 +307,7 @@ export function ProductTable({
}
return columnDef.format(value);
}
return value || '-';
return value ?? '-';
}
};

View File

@@ -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<string, any>;
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<Record<string, string | number | boolean>>({});
const [sortColumn, setSortColumn] = useState<keyof Product>('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<string | null>(null);
const [selectedProductId, setSelectedProductId] = useState<number | null>(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<string, ColumnDef[]>);
}, {} as Record<string, typeof AVAILABLE_COLUMNS>);
// 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 (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
aria-disabled={page === 1 || isFetching}
className={page === 1 || isFetching ? 'pointer-events-none opacity-50' : ''}
onClick={() => handlePageChange(Math.max(1, page - 1))}
/>
</PaginationItem>
{startPage > 1 && (
<>
<PaginationItem>
<PaginationLink
onClick={() => handlePageChange(1)}
aria-disabled={isFetching}
className={isFetching ? 'pointer-events-none opacity-50' : ''}
>
1
</PaginationLink>
</PaginationItem>
{startPage > 2 && <PaginationItem>...</PaginationItem>}
</>
)}
{pages.map(p => (
<PaginationItem key={p}>
<PaginationLink
onClick={() => handlePageChange(p)}
isActive={p === currentPage}
aria-disabled={isFetching}
className={isFetching ? 'pointer-events-none opacity-50' : ''}
>
{p}
</PaginationLink>
</PaginationItem>
))}
{endPage < totalPages && (
<>
{endPage < totalPages - 1 && <PaginationItem>...</PaginationItem>}
<PaginationItem>
<PaginationLink
onClick={() => handlePageChange(totalPages)}
aria-disabled={isFetching}
className={isFetching ? 'pointer-events-none opacity-50' : ''}
>
{totalPages}
</PaginationLink>
</PaginationItem>
</>
)}
<PaginationItem>
<PaginationNext
aria-disabled={page === data.pagination.pages || isFetching}
className={page === data.pagination.pages || isFetching ? 'pointer-events-none opacity-50' : ''}
onClick={() => handlePageChange(Math.min(data.pagination.pages, page + 1))}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<div className="flex items-center justify-between px-2">
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {page} of {pages}
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageChange(1)}
disabled={page === 1}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageChange(page + 1)}
disabled={page === pages}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageChange(pages)}
disabled={page === pages}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</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 (
<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>
<div>
@@ -396,39 +364,11 @@ export function Products() {
{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] 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>
{renderColumnToggle()}
</div>
</div>
<div className="mt-4">
{isLoading || isFetching ? (
{isFetching ? (
<ProductTableSkeleton />
) : (
<ProductTable