Fix various issues with product table and details components
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ?? '-';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user