Fix various issues with product table and details components
This commit is contained in:
@@ -688,7 +688,10 @@ async function calculateMetrics() {
|
|||||||
AVG(o.quantity) as avg_quantity_per_order,
|
AVG(o.quantity) as avg_quantity_per_order,
|
||||||
-- Calculate rolling averages using configured windows
|
-- Calculate rolling averages using configured windows
|
||||||
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_30_days_qty,
|
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_30_days_qty,
|
||||||
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_7_days_qty,
|
CASE
|
||||||
|
WHEN SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) IS NULL THEN 0
|
||||||
|
ELSE SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END)
|
||||||
|
END as rolling_weekly_avg,
|
||||||
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_month_qty
|
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_month_qty
|
||||||
FROM orders o
|
FROM orders o
|
||||||
JOIN products p ON o.product_id = p.product_id
|
JOIN products p ON o.product_id = p.product_id
|
||||||
@@ -704,13 +707,14 @@ async function calculateMetrics() {
|
|||||||
number_of_orders,
|
number_of_orders,
|
||||||
avg_quantity_per_order,
|
avg_quantity_per_order,
|
||||||
last_30_days_qty / ? as rolling_daily_avg,
|
last_30_days_qty / ? as rolling_daily_avg,
|
||||||
last_7_days_qty / ? as rolling_weekly_avg,
|
rolling_weekly_avg / ? as rolling_weekly_avg,
|
||||||
last_month_qty / ? as rolling_monthly_avg,
|
last_month_qty / ? as rolling_monthly_avg,
|
||||||
total_quantity_sold as total_sales_to_date
|
total_quantity_sold as total_sales_to_date
|
||||||
FROM sales_summary
|
FROM sales_summary
|
||||||
`, [
|
`, [
|
||||||
config.daily_window_days,
|
config.daily_window_days,
|
||||||
config.weekly_window_days,
|
config.weekly_window_days,
|
||||||
|
config.weekly_window_days,
|
||||||
config.monthly_window_days,
|
config.monthly_window_days,
|
||||||
product.product_id,
|
product.product_id,
|
||||||
config.daily_window_days,
|
config.daily_window_days,
|
||||||
|
|||||||
@@ -252,15 +252,29 @@ router.get('/', async (req, res) => {
|
|||||||
pm.daily_sales_avg,
|
pm.daily_sales_avg,
|
||||||
pm.weekly_sales_avg,
|
pm.weekly_sales_avg,
|
||||||
pm.monthly_sales_avg,
|
pm.monthly_sales_avg,
|
||||||
|
pm.avg_quantity_per_order,
|
||||||
|
pm.number_of_orders,
|
||||||
|
pm.first_sale_date,
|
||||||
|
pm.last_sale_date,
|
||||||
|
pm.days_of_inventory,
|
||||||
|
pm.weeks_of_inventory,
|
||||||
|
pm.reorder_point,
|
||||||
|
pm.safety_stock,
|
||||||
pm.avg_margin_percent,
|
pm.avg_margin_percent,
|
||||||
|
pm.total_revenue,
|
||||||
|
pm.inventory_value,
|
||||||
|
pm.cost_of_goods_sold,
|
||||||
|
pm.gross_profit,
|
||||||
pm.gmroi,
|
pm.gmroi,
|
||||||
|
pm.avg_lead_time_days,
|
||||||
|
pm.last_purchase_date,
|
||||||
|
pm.last_received_date,
|
||||||
pm.abc_class,
|
pm.abc_class,
|
||||||
pm.stock_status,
|
pm.stock_status,
|
||||||
pm.avg_lead_time_days,
|
pm.turnover_rate,
|
||||||
pm.current_lead_time,
|
pm.current_lead_time,
|
||||||
pm.target_lead_time,
|
pm.target_lead_time,
|
||||||
pm.lead_time_status,
|
pm.lead_time_status,
|
||||||
pm.days_of_inventory as days_of_stock,
|
|
||||||
COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
|
COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||||
@@ -281,15 +295,34 @@ router.get('/', async (req, res) => {
|
|||||||
categories: row.categories ? row.categories.split(',') : [],
|
categories: row.categories ? row.categories.split(',') : [],
|
||||||
price: parseFloat(row.price),
|
price: parseFloat(row.price),
|
||||||
cost_price: parseFloat(row.cost_price),
|
cost_price: parseFloat(row.cost_price),
|
||||||
landing_cost_price: parseFloat(row.landing_cost_price),
|
landing_cost_price: row.landing_cost_price ? parseFloat(row.landing_cost_price) : null,
|
||||||
stock_quantity: parseInt(row.stock_quantity),
|
stock_quantity: parseInt(row.stock_quantity),
|
||||||
daily_sales_avg: parseFloat(row.daily_sales_avg) || 0,
|
daily_sales_avg: parseFloat(row.daily_sales_avg) || 0,
|
||||||
weekly_sales_avg: parseFloat(row.weekly_sales_avg) || 0,
|
weekly_sales_avg: parseFloat(row.weekly_sales_avg) || 0,
|
||||||
monthly_sales_avg: parseFloat(row.monthly_sales_avg) || 0,
|
monthly_sales_avg: parseFloat(row.monthly_sales_avg) || 0,
|
||||||
|
avg_quantity_per_order: parseFloat(row.avg_quantity_per_order) || 0,
|
||||||
|
number_of_orders: parseInt(row.number_of_orders) || 0,
|
||||||
|
first_sale_date: row.first_sale_date || null,
|
||||||
|
last_sale_date: row.last_sale_date || null,
|
||||||
|
days_of_inventory: parseFloat(row.days_of_inventory) || 0,
|
||||||
|
weeks_of_inventory: parseFloat(row.weeks_of_inventory) || 0,
|
||||||
|
reorder_point: parseFloat(row.reorder_point) || 0,
|
||||||
|
safety_stock: parseFloat(row.safety_stock) || 0,
|
||||||
avg_margin_percent: parseFloat(row.avg_margin_percent) || 0,
|
avg_margin_percent: parseFloat(row.avg_margin_percent) || 0,
|
||||||
|
total_revenue: parseFloat(row.total_revenue) || 0,
|
||||||
|
inventory_value: parseFloat(row.inventory_value) || 0,
|
||||||
|
cost_of_goods_sold: parseFloat(row.cost_of_goods_sold) || 0,
|
||||||
|
gross_profit: parseFloat(row.gross_profit) || 0,
|
||||||
gmroi: parseFloat(row.gmroi) || 0,
|
gmroi: parseFloat(row.gmroi) || 0,
|
||||||
lead_time_days: parseInt(row.lead_time_days) || 0,
|
avg_lead_time_days: parseFloat(row.avg_lead_time_days) || 0,
|
||||||
days_of_stock: parseFloat(row.days_of_stock) || 0,
|
last_purchase_date: row.last_purchase_date || null,
|
||||||
|
last_received_date: row.last_received_date || null,
|
||||||
|
abc_class: row.abc_class || null,
|
||||||
|
stock_status: row.stock_status || null,
|
||||||
|
turnover_rate: parseFloat(row.turnover_rate) || 0,
|
||||||
|
current_lead_time: parseFloat(row.current_lead_time) || 0,
|
||||||
|
target_lead_time: parseFloat(row.target_lead_time) || 0,
|
||||||
|
lead_time_status: row.lead_time_status || null,
|
||||||
stock_coverage_ratio: parseFloat(row.stock_coverage_ratio) || 0
|
stock_coverage_ratio: parseFloat(row.stock_coverage_ratio) || 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -617,7 +650,7 @@ router.get('/:id/metrics', async (req, res) => {
|
|||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||||
LEFT JOIN inventory_status is ON p.product_id = is.product_id
|
LEFT JOIN inventory_status is ON p.product_id = is.product_id
|
||||||
WHERE p.product_id = ?
|
WHERE p.product_id = ?
|
||||||
`, [id, id]);
|
`, [id]);
|
||||||
|
|
||||||
if (!metrics.length) {
|
if (!metrics.length) {
|
||||||
// Return default metrics structure if no data found
|
// Return default metrics structure if no data found
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Drawer as VaulDrawer } from "vaul";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X } from "lucide-react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
@@ -23,10 +25,10 @@ interface Product {
|
|||||||
replenishable: boolean;
|
replenishable: boolean;
|
||||||
|
|
||||||
// Pricing fields
|
// Pricing fields
|
||||||
price: string | number;
|
price: number;
|
||||||
regular_price: string | number;
|
regular_price: number;
|
||||||
cost_price: string | number;
|
cost_price: number;
|
||||||
landing_cost_price: string | number | null;
|
landing_cost_price: number | null;
|
||||||
|
|
||||||
// Categorization
|
// Categorization
|
||||||
categories: string[];
|
categories: string[];
|
||||||
@@ -40,7 +42,7 @@ interface Product {
|
|||||||
|
|
||||||
// URLs
|
// URLs
|
||||||
permalink: string;
|
permalink: string;
|
||||||
image: string;
|
image: string | null;
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
metrics: {
|
metrics: {
|
||||||
@@ -124,7 +126,7 @@ interface Product {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ProductDetailProps {
|
interface ProductDetailProps {
|
||||||
productId: string | null;
|
productId: number | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,10 +167,19 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
const isLoading = isLoadingProduct || isLoadingTimeSeries;
|
const isLoading = isLoadingProduct || isLoadingTimeSeries;
|
||||||
|
|
||||||
// Helper function to format price values
|
// Helper function to format price values
|
||||||
const formatPrice = (price: string | number | null | undefined): string => {
|
const formatPrice = (price: number | null | undefined): string => {
|
||||||
if (price === null || price === undefined) return 'N/A';
|
if (price === null || price === undefined) return 'N/A';
|
||||||
const numericPrice = typeof price === 'string' ? parseFloat(price) : price;
|
return price.toFixed(2);
|
||||||
return typeof numericPrice === 'number' ? numericPrice.toFixed(2) : 'N/A';
|
};
|
||||||
|
|
||||||
|
// Helper function to format date values
|
||||||
|
const formatDate = (date: string | null): string => {
|
||||||
|
if (!date) return '-';
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combine product and time series data
|
// Combine product and time series data
|
||||||
@@ -184,27 +195,28 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
return (
|
return (
|
||||||
<VaulDrawer.Root open={!!productId} onOpenChange={(open) => !open && onClose()} direction="right">
|
<VaulDrawer.Root open={!!productId} onOpenChange={(open) => !open && onClose()} direction="right">
|
||||||
<VaulDrawer.Portal>
|
<VaulDrawer.Portal>
|
||||||
<VaulDrawer.Content className="fixed right-0 top-0 h-full w-[90%] max-w-[800px] bg-background p-6 shadow-lg">
|
<VaulDrawer.Overlay className="fixed inset-0 bg-black/40" />
|
||||||
<div className="mb-8">
|
<VaulDrawer.Content className="fixed right-0 top-0 h-full w-[90%] max-w-[800px] bg-background p-6 shadow-lg flex flex-col">
|
||||||
<VaulDrawer.Title className="text-2xl font-bold">
|
<div className="flex items-start justify-between p-4 border-b">
|
||||||
{isLoading ? (
|
<div className="flex items-center gap-4">
|
||||||
<Skeleton className="h-8 w-[200px]" />
|
{product?.image && (
|
||||||
) : (
|
<div className="h-16 w-16 rounded-lg border bg-white p-1">
|
||||||
product?.title
|
<img src={product.image} alt={product.title} className="h-full w-full object-contain" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</VaulDrawer.Title>
|
<div>
|
||||||
<VaulDrawer.Description className="text-muted-foreground">
|
<h2 className="text-xl font-semibold">{product?.title || 'Loading...'}</h2>
|
||||||
{isLoading ? (
|
<p className="text-sm text-muted-foreground">{product?.SKU || ''}</p>
|
||||||
"\u00A0"
|
</div>
|
||||||
) : (
|
</div>
|
||||||
`SKU: ${product?.SKU} | Stock: ${product?.stock_quantity}`
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||||
)}
|
<X className="h-4 w-4" />
|
||||||
</VaulDrawer.Description>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pb-8">
|
<Tabs defaultValue="overview" className="flex-1 overflow-auto">
|
||||||
<Tabs defaultValue="overview" className="w-full">
|
<div className="px-4 py-2 border-b bg-background sticky top-0 z-10">
|
||||||
<TabsList className="w-full justify-start mb-4 sticky top-0 bg-background z-10">
|
<TabsList>
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
||||||
<TabsTrigger value="sales">Sales</TabsTrigger>
|
<TabsTrigger value="sales">Sales</TabsTrigger>
|
||||||
@@ -212,381 +224,426 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
<TabsTrigger value="financial">Financial</TabsTrigger>
|
<TabsTrigger value="financial">Financial</TabsTrigger>
|
||||||
<TabsTrigger value="vendor">Vendor</TabsTrigger>
|
<TabsTrigger value="vendor">Vendor</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TabsContent value="overview" className="space-y-4">
|
<TabsContent value="overview" className="p-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Skeleton className="h-24 w-full" />
|
<Skeleton className="h-24 w-full" />
|
||||||
<Skeleton className="h-24 w-full" />
|
<Skeleton className="h-24 w-full" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h3 className="font-semibold mb-2">Basic Information</h3>
|
<h3 className="font-semibold mb-2">Basic Information</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Brand</dt>
|
<dt className="text-sm text-muted-foreground">Brand</dt>
|
||||||
<dd>{product?.brand || "N/A"}</dd>
|
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<div>
|
||||||
</div>
|
<dt className="text-sm text-muted-foreground">Vendor</dt>
|
||||||
)}
|
<dd>{product?.vendor || "N/A"}</dd>
|
||||||
</TabsContent>
|
</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">
|
<Card className="p-4">
|
||||||
{isLoading ? (
|
<h3 className="font-semibold mb-2">Pricing</h3>
|
||||||
<Skeleton className="h-48 w-full" />
|
<dl className="space-y-2">
|
||||||
) : (
|
<div>
|
||||||
<div className="space-y-4">
|
<dt className="text-sm text-muted-foreground">Price</dt>
|
||||||
<Card className="p-4">
|
<dd>${formatPrice(product?.price)}</dd>
|
||||||
<h3 className="font-semibold mb-2">Current Stock</h3>
|
</div>
|
||||||
<dl className="grid grid-cols-3 gap-4">
|
<div>
|
||||||
<div>
|
<dt className="text-sm text-muted-foreground">Regular Price</dt>
|
||||||
<dt className="text-sm text-muted-foreground">Stock Quantity</dt>
|
<dd>${formatPrice(product?.regular_price)}</dd>
|
||||||
<dd className="text-2xl font-semibold">{product?.stock_quantity}</dd>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<dt className="text-sm text-muted-foreground">Cost Price</dt>
|
||||||
<dt className="text-sm text-muted-foreground">Days of Inventory</dt>
|
<dd>${formatPrice(product?.cost_price)}</dd>
|
||||||
<dd className="text-2xl font-semibold">{product?.metrics?.days_of_inventory || 0}</dd>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
|
||||||
<dt className="text-sm text-muted-foreground">Status</dt>
|
<dd>${formatPrice(product?.landing_cost_price)}</dd>
|
||||||
<dd className="text-2xl font-semibold">{product?.metrics?.stock_status || "N/A"}</dd>
|
</div>
|
||||||
</div>
|
</dl>
|
||||||
</dl>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h3 className="font-semibold mb-2">Stock Thresholds</h3>
|
<h3 className="font-semibold mb-2">Stock Status</h3>
|
||||||
<dl className="grid grid-cols-3 gap-4">
|
<dl className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Reorder Point</dt>
|
<dt className="text-sm text-muted-foreground">Current Stock</dt>
|
||||||
<dd>{product?.metrics?.reorder_point || 0}</dd>
|
<dd className="text-2xl font-semibold">{product?.stock_quantity}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Safety Stock</dt>
|
<dt className="text-sm text-muted-foreground">Status</dt>
|
||||||
<dd>{product?.metrics?.safety_stock || 0}</dd>
|
<dd>{product?.metrics?.stock_status}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">ABC Class</dt>
|
<dt className="text-sm text-muted-foreground">Days of Stock</dt>
|
||||||
<dd>{product?.metrics?.abc_class || "N/A"}</dd>
|
<dd>{product?.metrics?.days_of_inventory} days</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="sales" className="space-y-4">
|
<Card className="p-4">
|
||||||
{isLoading ? (
|
<h3 className="font-semibold mb-2">Sales Velocity</h3>
|
||||||
<Skeleton className="h-96 w-full" />
|
<dl className="space-y-2">
|
||||||
) : (
|
<div>
|
||||||
<div className="space-y-4">
|
<dt className="text-sm text-muted-foreground">Daily Sales</dt>
|
||||||
<Card className="p-4">
|
<dd>{product?.metrics?.daily_sales_avg?.toFixed(1)} units</dd>
|
||||||
<h3 className="font-semibold mb-2">Recent Orders</h3>
|
</div>
|
||||||
<Table>
|
<div>
|
||||||
<TableHeader>
|
<dt className="text-sm text-muted-foreground">Weekly Sales</dt>
|
||||||
<TableRow>
|
<dd>{product?.metrics?.weekly_sales_avg?.toFixed(1)} units</dd>
|
||||||
<TableHead>Date</TableHead>
|
</div>
|
||||||
<TableHead>Order #</TableHead>
|
<div>
|
||||||
<TableHead>Customer</TableHead>
|
<dt className="text-sm text-muted-foreground">Monthly Sales</dt>
|
||||||
<TableHead>Quantity</TableHead>
|
<dd>{product?.metrics?.monthly_sales_avg?.toFixed(1)} units</dd>
|
||||||
<TableHead>Price</TableHead>
|
</div>
|
||||||
<TableHead>Status</TableHead>
|
</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>
|
</TableRow>
|
||||||
</TableHeader>
|
))}
|
||||||
<TableBody>
|
{(!combinedData?.recent_orders || combinedData.recent_orders.length === 0) && (
|
||||||
{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>
|
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Date</TableHead>
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
<TableHead>PO #</TableHead>
|
No recent orders
|
||||||
<TableHead>Ordered</TableHead>
|
</TableCell>
|
||||||
<TableHead>Received</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Lead Time</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
)}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{combinedData?.recent_purchases?.map((po: NonNullable<Product['recent_purchases']>[number]) => (
|
</Table>
|
||||||
<TableRow key={po.po_id}>
|
</Card>
|
||||||
<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>
|
|
||||||
|
|
||||||
<TabsContent value="financial" className="space-y-4">
|
<Card className="p-4">
|
||||||
{isLoading ? (
|
<h3 className="font-semibold mb-2">Monthly Sales Trend</h3>
|
||||||
<Skeleton className="h-48 w-full" />
|
<div className="h-64">
|
||||||
) : (
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<div className="space-y-4">
|
<LineChart data={combinedData?.monthly_sales || []}>
|
||||||
<Card className="p-4">
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<h3 className="font-semibold mb-2">Financial Overview</h3>
|
<XAxis dataKey="month" />
|
||||||
<dl className="grid grid-cols-3 gap-4">
|
<YAxis yAxisId="left" />
|
||||||
<div>
|
<YAxis yAxisId="right" orientation="right" />
|
||||||
<dt className="text-sm text-muted-foreground">Gross Profit</dt>
|
<Tooltip />
|
||||||
<dd className="text-2xl font-semibold">${formatPrice(product?.metrics.gross_profit)}</dd>
|
<Line yAxisId="left" type="monotone" dataKey="quantity" stroke="#8884d8" name="Quantity" />
|
||||||
</div>
|
<Line yAxisId="right" type="monotone" dataKey="revenue" stroke="#82ca9d" name="Revenue" />
|
||||||
<div>
|
</LineChart>
|
||||||
<dt className="text-sm text-muted-foreground">GMROI</dt>
|
</ResponsiveContainer>
|
||||||
<dd className="text-2xl font-semibold">{product?.metrics.gmroi.toFixed(2)}</dd>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
<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>
|
</TabsContent>
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-4">
|
<TabsContent value="purchase" className="p-4">
|
||||||
<h3 className="font-semibold mb-2">Cost Breakdown</h3>
|
{isLoading ? (
|
||||||
<dl className="grid grid-cols-2 gap-4">
|
<Skeleton className="h-96 w-full" />
|
||||||
<div>
|
) : (
|
||||||
<dt className="text-sm text-muted-foreground">Cost of Goods Sold</dt>
|
<div className="space-y-4">
|
||||||
<dd>${formatPrice(product?.metrics.cost_of_goods_sold)}</dd>
|
<Card className="p-4">
|
||||||
</div>
|
<h3 className="font-semibold mb-2">Recent Purchase Orders</h3>
|
||||||
<div>
|
<Table>
|
||||||
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
|
<TableHeader>
|
||||||
<dd>${formatPrice(product?.landing_cost_price)}</dd>
|
<TableRow>
|
||||||
</div>
|
<TableHead>Date</TableHead>
|
||||||
</dl>
|
<TableHead>PO #</TableHead>
|
||||||
</Card>
|
<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">
|
<TabsContent value="financial" className="p-4">
|
||||||
<h3 className="font-semibold mb-2">Profit Margin Trend</h3>
|
{isLoading ? (
|
||||||
<div className="h-64">
|
<Skeleton className="h-48 w-full" />
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
) : (
|
||||||
<LineChart data={combinedData?.monthly_sales || []}>
|
<div className="space-y-4">
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<Card className="p-4">
|
||||||
<XAxis dataKey="month" />
|
<h3 className="font-semibold mb-2">Financial Overview</h3>
|
||||||
<YAxis domain={[0, 100]} />
|
<dl className="grid grid-cols-3 gap-4">
|
||||||
<Tooltip />
|
<div>
|
||||||
<Line type="monotone" dataKey="profit_margin" stroke="#82ca9d" name="Profit Margin %" />
|
<dt className="text-sm text-muted-foreground">Gross Profit</dt>
|
||||||
</LineChart>
|
<dd className="text-2xl font-semibold">${formatPrice(product?.metrics.gross_profit)}</dd>
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<div>
|
||||||
</div>
|
<dt className="text-sm text-muted-foreground">GMROI</dt>
|
||||||
)}
|
<dd className="text-2xl font-semibold">{product?.metrics.gmroi.toFixed(2)}</dd>
|
||||||
</TabsContent>
|
</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">
|
<Card className="p-4">
|
||||||
{isLoading ? (
|
<h3 className="font-semibold mb-2">Cost Breakdown</h3>
|
||||||
<Skeleton className="h-48 w-full" />
|
<dl className="grid grid-cols-2 gap-4">
|
||||||
) : product?.vendor_performance ? (
|
<div>
|
||||||
<div className="space-y-4">
|
<dt className="text-sm text-muted-foreground">Cost of Goods Sold</dt>
|
||||||
<Card className="p-4">
|
<dd>${formatPrice(product?.metrics.cost_of_goods_sold)}</dd>
|
||||||
<h3 className="font-semibold mb-2">Vendor Performance</h3>
|
</div>
|
||||||
<dl className="grid grid-cols-3 gap-4">
|
<div>
|
||||||
<div>
|
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
|
||||||
<dt className="text-sm text-muted-foreground">On-Time Delivery</dt>
|
<dd>${formatPrice(product?.landing_cost_price)}</dd>
|
||||||
<dd className="text-2xl font-semibold">{product.vendor_performance.on_time_delivery_rate.toFixed(1)}%</dd>
|
</div>
|
||||||
</div>
|
</dl>
|
||||||
<div>
|
</Card>
|
||||||
<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">
|
<Card className="p-4">
|
||||||
<h3 className="font-semibold mb-2">Order History</h3>
|
<h3 className="font-semibold mb-2">Profit Margin Trend</h3>
|
||||||
<dl className="grid grid-cols-2 gap-4">
|
<div className="h-64">
|
||||||
<div>
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<dt className="text-sm text-muted-foreground">Total Orders</dt>
|
<LineChart data={combinedData?.monthly_sales || []}>
|
||||||
<dd>{product.vendor_performance.total_orders}</dd>
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
</div>
|
<XAxis dataKey="month" />
|
||||||
<div>
|
<YAxis domain={[0, 100]} />
|
||||||
<dt className="text-sm text-muted-foreground">Late Orders</dt>
|
<Tooltip />
|
||||||
<dd>{product.vendor_performance.total_late_orders}</dd>
|
<Line type="monotone" dataKey="profit_margin" stroke="#82ca9d" name="Profit Margin %" />
|
||||||
</div>
|
</LineChart>
|
||||||
<div>
|
</ResponsiveContainer>
|
||||||
<dt className="text-sm text-muted-foreground">Total Purchase Value</dt>
|
</div>
|
||||||
<dd>${formatPrice(product.vendor_performance.total_purchase_value)}</dd>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<dt className="text-sm text-muted-foreground">Avg Order Value</dt>
|
</TabsContent>
|
||||||
<dd>${formatPrice(product.vendor_performance.avg_order_value)}</dd>
|
|
||||||
</div>
|
<TabsContent value="vendor" className="p-4">
|
||||||
</dl>
|
{isLoading ? (
|
||||||
</Card>
|
<Skeleton className="h-48 w-full" />
|
||||||
</div>
|
) : product?.vendor_performance ? (
|
||||||
) : (
|
<div className="space-y-4">
|
||||||
<div className="text-center text-muted-foreground">No vendor performance data available</div>
|
<Card className="p-4">
|
||||||
)}
|
<h3 className="font-semibold mb-2">Vendor Performance</h3>
|
||||||
</TabsContent>
|
<dl className="grid grid-cols-2 gap-4">
|
||||||
</Tabs>
|
<div>
|
||||||
</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.Content>
|
||||||
</VaulDrawer.Portal>
|
</VaulDrawer.Portal>
|
||||||
</VaulDrawer.Root>
|
</VaulDrawer.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,25 +28,29 @@ import {
|
|||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
product_id: string;
|
product_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
sku: string;
|
SKU: string;
|
||||||
stock_quantity: number;
|
stock_quantity: number;
|
||||||
price: number;
|
price: number;
|
||||||
regular_price: number;
|
regular_price: number;
|
||||||
cost_price: number;
|
cost_price: number;
|
||||||
landing_cost_price: number;
|
landing_cost_price: number | null;
|
||||||
barcode: string;
|
barcode: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
vendor_reference: string;
|
vendor_reference: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
tags: string[];
|
||||||
|
options: Record<string, any>;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
moq: number;
|
moq: number;
|
||||||
uom: number;
|
uom: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
managing_stock: boolean;
|
managing_stock: boolean;
|
||||||
replenishable: boolean;
|
replenishable: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
daily_sales_avg?: number;
|
daily_sales_avg?: number;
|
||||||
@@ -77,8 +81,10 @@ interface Product {
|
|||||||
lead_time_status?: string;
|
lead_time_status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ColumnKey = keyof Product | 'image';
|
||||||
|
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
key: keyof Product | 'image';
|
key: ColumnKey;
|
||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
format?: (value: any) => string | number;
|
format?: (value: any) => string | number;
|
||||||
@@ -88,21 +94,21 @@ interface ColumnDef {
|
|||||||
|
|
||||||
interface ProductTableProps {
|
interface ProductTableProps {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
onSort: (column: keyof Product) => void;
|
onSort: (column: ColumnKey) => void;
|
||||||
sortColumn: keyof Product;
|
sortColumn: ColumnKey;
|
||||||
sortDirection: 'asc' | 'desc';
|
sortDirection: 'asc' | 'desc';
|
||||||
visibleColumns: Set<keyof Product | 'image'>;
|
visibleColumns: Set<ColumnKey>;
|
||||||
columnDefs: ColumnDef[];
|
columnDefs: ColumnDef[];
|
||||||
columnOrder: (keyof Product | 'image')[];
|
columnOrder: ColumnKey[];
|
||||||
onColumnOrderChange?: (columns: (keyof Product | 'image')[]) => void;
|
onColumnOrderChange?: (columns: ColumnKey[]) => void;
|
||||||
onRowClick?: (product: Product) => void;
|
onRowClick?: (product: Product) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableHeaderProps {
|
interface SortableHeaderProps {
|
||||||
column: keyof Product;
|
column: ColumnKey;
|
||||||
columnDef?: ColumnDef;
|
columnDef?: ColumnDef;
|
||||||
onSort: (column: keyof Product) => void;
|
onSort: (column: ColumnKey) => void;
|
||||||
sortColumn: keyof Product;
|
sortColumn: ColumnKey;
|
||||||
sortDirection: 'asc' | 'desc';
|
sortDirection: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +170,7 @@ export function ProductTable({
|
|||||||
onColumnOrderChange,
|
onColumnOrderChange,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
}: ProductTableProps) {
|
}: ProductTableProps) {
|
||||||
const [, setActiveId] = React.useState<keyof Product | null>(null);
|
const [, setActiveId] = React.useState<ColumnKey | null>(null);
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, {
|
useSensor(MouseSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
@@ -185,7 +191,7 @@ export function ProductTable({
|
|||||||
}, [columnOrder, visibleColumns]);
|
}, [columnOrder, visibleColumns]);
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
setActiveId(event.active.id as keyof Product);
|
setActiveId(event.active.id as ColumnKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
@@ -193,8 +199,8 @@ export function ProductTable({
|
|||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
const oldIndex = orderedColumns.indexOf(active.id as keyof Product);
|
const oldIndex = orderedColumns.indexOf(active.id as ColumnKey);
|
||||||
const newIndex = orderedColumns.indexOf(over.id as keyof Product);
|
const newIndex = orderedColumns.indexOf(over.id as ColumnKey);
|
||||||
|
|
||||||
const newOrder = arrayMove(orderedColumns, oldIndex, newIndex);
|
const newOrder = arrayMove(orderedColumns, oldIndex, newIndex);
|
||||||
onColumnOrderChange?.(newOrder);
|
onColumnOrderChange?.(newOrder);
|
||||||
@@ -248,9 +254,9 @@ export function ProductTable({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatColumnValue = (product: Product, column: keyof Product | 'image') => {
|
const formatColumnValue = (product: Product, column: ColumnKey) => {
|
||||||
const value = column === 'image' ? product.image : product[column as keyof Product];
|
|
||||||
const columnDef = columnDefs.find(def => def.key === column);
|
const columnDef = columnDefs.find(def => def.key === column);
|
||||||
|
let value: any = product[column as keyof Product];
|
||||||
|
|
||||||
switch (column) {
|
switch (column) {
|
||||||
case 'image':
|
case 'image':
|
||||||
@@ -267,7 +273,7 @@ export function ProductTable({
|
|||||||
return (
|
return (
|
||||||
<div className="min-w-[200px]">
|
<div className="min-w-[200px]">
|
||||||
<div className="font-medium">{product.title}</div>
|
<div className="font-medium">{product.title}</div>
|
||||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
<div className="text-sm text-muted-foreground">{product.SKU}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'categories':
|
case 'categories':
|
||||||
@@ -279,11 +285,11 @@ export function ProductTable({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'stock_status':
|
case 'stock_status':
|
||||||
return getStockStatus(value as string);
|
return getStockStatus(product.stock_status);
|
||||||
case 'abc_class':
|
case 'abc_class':
|
||||||
return getABCClass(value as string);
|
return getABCClass(product.abc_class);
|
||||||
case 'lead_time_status':
|
case 'lead_time_status':
|
||||||
return getLeadTimeStatus(value as string);
|
return getLeadTimeStatus(product.lead_time_status);
|
||||||
case 'visible':
|
case 'visible':
|
||||||
return value ? (
|
return value ? (
|
||||||
<Badge variant="secondary">Active</Badge>
|
<Badge variant="secondary">Active</Badge>
|
||||||
@@ -301,7 +307,7 @@ export function ProductTable({
|
|||||||
}
|
}
|
||||||
return columnDef.format(value);
|
return columnDef.format(value);
|
||||||
}
|
}
|
||||||
return value || '-';
|
return value ?? '-';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { ProductFilters } from '@/components/products/ProductFilters';
|
import { ProductFilters } from '@/components/products/ProductFilters';
|
||||||
import { ProductTable } from '@/components/products/ProductTable';
|
import { ProductTable } from '@/components/products/ProductTable';
|
||||||
import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton';
|
import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton';
|
||||||
import { ProductDetail } from '@/components/products/ProductDetail';
|
import { ProductDetail } from '@/components/products/ProductDetail';
|
||||||
import {
|
import { Button } from '@/components/ui/button';
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from "@/components/ui/pagination";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@@ -19,35 +12,38 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Settings2, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react';
|
||||||
import { Settings2 } from "lucide-react";
|
import { motion } from 'framer-motion';
|
||||||
import config from '../config';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
// Enhanced Product interface with all possible fields
|
// Enhanced Product interface with all possible fields
|
||||||
interface Product {
|
interface Product {
|
||||||
// Basic product info (from products table)
|
// Basic product info
|
||||||
product_id: string;
|
product_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
sku: string;
|
SKU: string;
|
||||||
stock_quantity: number;
|
stock_quantity: number;
|
||||||
price: number;
|
price: number;
|
||||||
regular_price: number;
|
regular_price: number;
|
||||||
cost_price: number;
|
cost_price: number;
|
||||||
landing_cost_price: number;
|
landing_cost_price: number | null;
|
||||||
barcode: string;
|
barcode: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
vendor_reference: string;
|
vendor_reference: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
tags: string[];
|
||||||
|
options: Record<string, any>;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
moq: number;
|
moq: number;
|
||||||
uom: number;
|
uom: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
managing_stock: boolean;
|
managing_stock: boolean;
|
||||||
replenishable: boolean;
|
replenishable: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
|
||||||
// Metrics (from product_metrics table)
|
// Metrics
|
||||||
daily_sales_avg?: number;
|
daily_sales_avg?: number;
|
||||||
weekly_sales_avg?: number;
|
weekly_sales_avg?: number;
|
||||||
monthly_sales_avg?: number;
|
monthly_sales_avg?: number;
|
||||||
@@ -76,7 +72,6 @@ interface Product {
|
|||||||
lead_time_status?: string;
|
lead_time_status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Column definition interface
|
// Column definition interface
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
key: keyof Product | 'image';
|
key: keyof Product | 'image';
|
||||||
@@ -94,7 +89,6 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
|
|||||||
|
|
||||||
// Basic Info Group
|
// Basic Info Group
|
||||||
{ key: 'title', label: 'Title', group: 'Basic Info' },
|
{ key: 'title', label: 'Title', group: 'Basic Info' },
|
||||||
{ key: 'sku', label: 'SKU', group: 'Basic Info' },
|
|
||||||
{ key: 'brand', label: 'Brand', group: 'Basic Info' },
|
{ key: 'brand', label: 'Brand', group: 'Basic Info' },
|
||||||
{ key: 'categories', label: 'Categories', group: 'Basic Info' },
|
{ key: 'categories', label: 'Categories', group: 'Basic Info' },
|
||||||
{ key: 'vendor', label: 'Vendor', group: 'Basic Info' },
|
{ key: 'vendor', label: 'Vendor', group: 'Basic Info' },
|
||||||
@@ -150,7 +144,6 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
|
|||||||
const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [
|
const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [
|
||||||
'image',
|
'image',
|
||||||
'title',
|
'title',
|
||||||
'sku',
|
|
||||||
'stock_quantity',
|
'stock_quantity',
|
||||||
'stock_status',
|
'stock_status',
|
||||||
'price',
|
'price',
|
||||||
@@ -160,7 +153,6 @@ const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function Products() {
|
export function Products() {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [filters, setFilters] = useState<Record<string, string | number | boolean>>({});
|
const [filters, setFilters] = useState<Record<string, string | number | boolean>>({});
|
||||||
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
|
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
@@ -170,7 +162,7 @@ export function Products() {
|
|||||||
...DEFAULT_VISIBLE_COLUMNS,
|
...DEFAULT_VISIBLE_COLUMNS,
|
||||||
...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key))
|
...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key))
|
||||||
]);
|
]);
|
||||||
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
|
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Group columns by their group property
|
// Group columns by their group property
|
||||||
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
|
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
|
||||||
@@ -179,88 +171,55 @@ export function Products() {
|
|||||||
}
|
}
|
||||||
acc[col.group].push(col);
|
acc[col.group].push(col);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, ColumnDef[]>);
|
}, {} as Record<string, typeof AVAILABLE_COLUMNS>);
|
||||||
|
|
||||||
// Toggle column visibility
|
// Handle column reordering from drag and drop
|
||||||
const toggleColumn = (columnKey: keyof Product | 'image') => {
|
const handleColumnOrderChange = (newOrder: (keyof Product | 'image')[]) => {
|
||||||
setVisibleColumns(prev => {
|
setColumnOrder(newOrder);
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(columnKey)) {
|
|
||||||
next.delete(columnKey);
|
|
||||||
} else {
|
|
||||||
next.add(columnKey);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to fetch products data
|
// Function to fetch products data
|
||||||
const fetchProducts = async (pageNum: number) => {
|
const fetchProducts = async () => {
|
||||||
const searchParams = new URLSearchParams({
|
const params = new URLSearchParams();
|
||||||
page: pageNum.toString(),
|
|
||||||
limit: '100',
|
// Add pagination params
|
||||||
sortColumn: sortColumn.toString(),
|
params.append('page', page.toString());
|
||||||
sortDirection,
|
params.append('limit', '50');
|
||||||
...filters,
|
|
||||||
|
// Add sorting params
|
||||||
|
if (sortColumn) {
|
||||||
|
params.append('sortColumn', sortColumn);
|
||||||
|
params.append('sortDirection', sortDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add filter params
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
params.append(key, value.toString());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`${config.apiUrl}/products?${searchParams}`);
|
const response = await fetch(`/api/products?${params.toString()}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Network response was not ok');
|
throw new Error('Failed to fetch products');
|
||||||
}
|
}
|
||||||
const result = await response.json();
|
return response.json();
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, isLoading, isFetching } = useQuery({
|
// Query for products data
|
||||||
queryKey: ['products', filters, sortColumn, sortDirection, page],
|
const { data, isFetching } = useQuery({
|
||||||
queryFn: () => fetchProducts(page),
|
queryKey: ['products', page, sortColumn, sortDirection, filters],
|
||||||
|
queryFn: fetchProducts,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
staleTime: 30000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enhanced prefetching strategy
|
// Handle sort column change
|
||||||
useEffect(() => {
|
|
||||||
if (data?.pagination) {
|
|
||||||
const prefetchPage = async (pageNum: number) => {
|
|
||||||
// Don't prefetch if the page is out of bounds
|
|
||||||
if (pageNum < 1 || pageNum > data.pagination.pages) return;
|
|
||||||
|
|
||||||
await queryClient.prefetchQuery({
|
|
||||||
queryKey: ['products', filters, sortColumn, sortDirection, pageNum],
|
|
||||||
queryFn: () => fetchProducts(pageNum),
|
|
||||||
staleTime: 30000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prefetch priority:
|
|
||||||
// 1. Next page (most likely to be clicked)
|
|
||||||
// 2. Previous page (second most likely)
|
|
||||||
// 3. Jump forward 5 pages (for quick navigation)
|
|
||||||
// 4. Jump backward 5 pages
|
|
||||||
const prefetchPriority = async () => {
|
|
||||||
if (page < data.pagination.pages) {
|
|
||||||
await prefetchPage(page + 1);
|
|
||||||
}
|
|
||||||
if (page > 1) {
|
|
||||||
await prefetchPage(page - 1);
|
|
||||||
}
|
|
||||||
await prefetchPage(page + 5);
|
|
||||||
await prefetchPage(page - 5);
|
|
||||||
};
|
|
||||||
|
|
||||||
prefetchPriority();
|
|
||||||
}
|
|
||||||
}, [page, data?.pagination, queryClient, filters, sortColumn, sortDirection]);
|
|
||||||
|
|
||||||
const handleSort = (column: keyof Product) => {
|
const handleSort = (column: keyof Product) => {
|
||||||
if (sortColumn === column) {
|
setSortDirection(prev => {
|
||||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
if (sortColumn !== column) return 'asc';
|
||||||
} else {
|
return prev === 'asc' ? 'desc' : 'asc';
|
||||||
setSortColumn(column);
|
});
|
||||||
setSortDirection('asc');
|
setSortColumn(column);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes
|
// Handle filter changes
|
||||||
@@ -275,109 +234,118 @@ export function Products() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
window.scrollTo({ top: 0 });
|
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
};
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
|
||||||
// Handle column reordering from drag and drop
|
|
||||||
const handleColumnOrderChange = (newOrder: (keyof Product | 'image')[]) => {
|
|
||||||
setColumnOrder(prev => {
|
|
||||||
// Keep hidden columns in their current positions
|
|
||||||
const newOrderSet = new Set(newOrder);
|
|
||||||
const hiddenColumns = prev.filter(col => !newOrderSet.has(col));
|
|
||||||
return [...newOrder, ...hiddenColumns];
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPagination = () => {
|
const renderPagination = () => {
|
||||||
if (!data?.pagination.pages || data.pagination.pages <= 1) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const currentPage = data.pagination.currentPage;
|
const { total, pages } = data.pagination;
|
||||||
const totalPages = data.pagination.pages;
|
if (total === 0) return null;
|
||||||
const maxVisiblePages = 7;
|
|
||||||
|
|
||||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
|
|
||||||
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
|
||||||
|
|
||||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
|
||||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = Array.from(
|
|
||||||
{ length: endPage - startPage + 1 },
|
|
||||||
(_, i) => startPage + i
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
<Pagination>
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||||
<PaginationContent>
|
Page {page} of {pages}
|
||||||
<PaginationItem>
|
</div>
|
||||||
<PaginationPrevious
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
aria-disabled={page === 1 || isFetching}
|
<div className="flex items-center space-x-2">
|
||||||
className={page === 1 || isFetching ? 'pointer-events-none opacity-50' : ''}
|
<Button
|
||||||
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
variant="outline"
|
||||||
/>
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
</PaginationItem>
|
onClick={() => handlePageChange(1)}
|
||||||
|
disabled={page === 1}
|
||||||
{startPage > 1 && (
|
>
|
||||||
<>
|
<span className="sr-only">Go to first page</span>
|
||||||
<PaginationItem>
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
<PaginationLink
|
</Button>
|
||||||
onClick={() => handlePageChange(1)}
|
<Button
|
||||||
aria-disabled={isFetching}
|
variant="outline"
|
||||||
className={isFetching ? 'pointer-events-none opacity-50' : ''}
|
className="h-8 w-8 p-0"
|
||||||
>
|
onClick={() => handlePageChange(page - 1)}
|
||||||
1
|
disabled={page === 1}
|
||||||
</PaginationLink>
|
>
|
||||||
</PaginationItem>
|
<span className="sr-only">Go to previous page</span>
|
||||||
{startPage > 2 && <PaginationItem>...</PaginationItem>}
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</>
|
</Button>
|
||||||
)}
|
<Button
|
||||||
|
variant="outline"
|
||||||
{pages.map(p => (
|
className="h-8 w-8 p-0"
|
||||||
<PaginationItem key={p}>
|
onClick={() => handlePageChange(page + 1)}
|
||||||
<PaginationLink
|
disabled={page === pages}
|
||||||
onClick={() => handlePageChange(p)}
|
>
|
||||||
isActive={p === currentPage}
|
<span className="sr-only">Go to next page</span>
|
||||||
aria-disabled={isFetching}
|
<ChevronRight className="h-4 w-4" />
|
||||||
className={isFetching ? 'pointer-events-none opacity-50' : ''}
|
</Button>
|
||||||
>
|
<Button
|
||||||
{p}
|
variant="outline"
|
||||||
</PaginationLink>
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
</PaginationItem>
|
onClick={() => handlePageChange(pages)}
|
||||||
))}
|
disabled={page === pages}
|
||||||
|
>
|
||||||
{endPage < totalPages && (
|
<span className="sr-only">Go to last page</span>
|
||||||
<>
|
<ChevronsRight className="h-4 w-4" />
|
||||||
{endPage < totalPages - 1 && <PaginationItem>...</PaginationItem>}
|
</Button>
|
||||||
<PaginationItem>
|
</div>
|
||||||
<PaginationLink
|
</div>
|
||||||
onClick={() => handlePageChange(totalPages)}
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderColumnToggle = () => (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="ml-auto">
|
||||||
|
<Settings2 className="mr-2 h-4 w-4" />
|
||||||
|
Columns
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="w-[280px] max-h-[calc(100vh-4rem)] overflow-y-auto"
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="sticky top-0 bg-background z-10">Toggle columns</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator className="sticky top-[29px] bg-background z-10" />
|
||||||
|
{Object.entries(columnsByGroup).map(([group, columns]) => (
|
||||||
|
<div key={group}>
|
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||||
|
{group}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.key}
|
||||||
|
className="capitalize"
|
||||||
|
checked={visibleColumns.has(column.key)}
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const newVisibleColumns = new Set(visibleColumns);
|
||||||
|
if (newVisibleColumns.has(column.key)) {
|
||||||
|
newVisibleColumns.delete(column.key);
|
||||||
|
} else {
|
||||||
|
newVisibleColumns.add(column.key);
|
||||||
|
}
|
||||||
|
setVisibleColumns(newVisibleColumns);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div layout className="p-8 space-y-8">
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="p-8 space-y-8"
|
||||||
|
>
|
||||||
<h1 className="text-2xl font-bold">Products</h1>
|
<h1 className="text-2xl font-bold">Products</h1>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -396,39 +364,11 @@ export function Products() {
|
|||||||
{data.pagination.total.toLocaleString()} products
|
{data.pagination.total.toLocaleString()} products
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DropdownMenu>
|
{renderColumnToggle()}
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Settings2 className="mr-2 h-4 w-4" />
|
|
||||||
Columns
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-[280px] max-h-[calc(100vh-4rem)] overflow-y-auto">
|
|
||||||
<DropdownMenuLabel>Toggle Columns</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{Object.entries(columnsByGroup).map(([group, columns]) => (
|
|
||||||
<div key={group}>
|
|
||||||
<DropdownMenuLabel className="text-xs font-bold text-muted-foreground">
|
|
||||||
{group}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{columns.map((col) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={col.key}
|
|
||||||
checked={visibleColumns.has(col.key)}
|
|
||||||
onCheckedChange={() => toggleColumn(col.key)}
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{isLoading || isFetching ? (
|
{isFetching ? (
|
||||||
<ProductTableSkeleton />
|
<ProductTableSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<ProductTable
|
<ProductTable
|
||||||
|
|||||||
Reference in New Issue
Block a user