Updates and fixes for products page
This commit is contained in:
@@ -381,8 +381,8 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
|
||||
<span className={`text-sm ${p.current_stock <= 0 ? "text-red-500" : p.is_low_stock ? "text-yellow-600" : ""}`}>
|
||||
{p.current_stock ?? 0}
|
||||
</span>
|
||||
{p.on_order_qty > 0 && (
|
||||
<span className="text-xs text-blue-500 ml-1">(+{p.on_order_qty})</span>
|
||||
{p.preorder_count > 0 && (
|
||||
<span className="text-xs text-blue-500 ml-1">(+{p.preorder_count})</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">{p.sales_7d ?? 0}</TableCell>
|
||||
|
||||
@@ -6,19 +6,20 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { ProductMetric, ProductStatus } from "@/types/products";
|
||||
import {
|
||||
getStatusBadge,
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
formatPercentage,
|
||||
formatDays,
|
||||
formatDate,
|
||||
import { ProductMetric } from "@/types/products";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
formatPercentage,
|
||||
formatDays,
|
||||
formatDate,
|
||||
formatBoolean
|
||||
} from "@/utils/productUtils";
|
||||
import { StatusBadge } from "@/components/products/StatusBadge";
|
||||
import { transformMetricsRow } from "@/utils/transformUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import config from "@/config";
|
||||
import { ResponsiveContainer, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from "recharts";
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
|
||||
@@ -30,8 +31,7 @@ interface ProductPurchaseOrder {
|
||||
receivedDate: string | null;
|
||||
ordered: number;
|
||||
received: number;
|
||||
status: number;
|
||||
receivingStatus: number;
|
||||
status: string;
|
||||
costPrice: number;
|
||||
notes: string | null;
|
||||
leadTimeDays: number | null;
|
||||
@@ -42,7 +42,6 @@ interface ProductTimeSeries {
|
||||
month: string;
|
||||
sales: number;
|
||||
revenue: number;
|
||||
profit: number;
|
||||
}[];
|
||||
recentPurchases: ProductPurchaseOrder[];
|
||||
}
|
||||
@@ -63,36 +62,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
throw new Error(`Failed to fetch product details (${response.status}): ${errorData.error || 'Server error'}`);
|
||||
}
|
||||
const rawData = await response.json();
|
||||
|
||||
// Transform snake_case to camelCase and convert string numbers to actual numbers
|
||||
const transformed: any = {};
|
||||
Object.entries(rawData).forEach(([key, value]) => {
|
||||
// Better handling of snake_case to camelCase conversion
|
||||
let camelKey = key;
|
||||
|
||||
// First handle cases like sales_7d -> sales7d
|
||||
camelKey = camelKey.replace(/_(\d+[a-z])/g, '$1');
|
||||
|
||||
// Then handle regular snake_case -> camelCase
|
||||
camelKey = camelKey.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase());
|
||||
|
||||
// Convert numeric strings to actual numbers
|
||||
if (typeof value === 'string' && !isNaN(Number(value)) &&
|
||||
!key.toLowerCase().includes('date') && key !== 'sku' && key !== 'title' &&
|
||||
key !== 'brand' && key !== 'vendor') {
|
||||
transformed[camelKey] = Number(value);
|
||||
} else {
|
||||
transformed[camelKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure pid is a number
|
||||
transformed.pid = typeof transformed.pid === 'string' ?
|
||||
parseInt(transformed.pid, 10) : transformed.pid;
|
||||
|
||||
console.log("Transformed product data:", transformed);
|
||||
|
||||
return transformed;
|
||||
return transformMetricsRow(rawData) as ProductMetric;
|
||||
},
|
||||
enabled: !!productId, // Only run query when productId is truthy
|
||||
});
|
||||
@@ -108,48 +78,58 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
throw new Error(`Failed to fetch time series data (${response.status}): ${errorData.error || 'Server error'}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Ensure the monthly_sales data is properly formatted for charts
|
||||
const formattedMonthlySales = data.monthly_sales.map((item: any) => ({
|
||||
|
||||
// Map backend field names (units_sold) to frontend chart keys (sales)
|
||||
// Reverse from DESC to ASC so the chart shows oldest-to-newest (left-to-right)
|
||||
const formattedMonthlySales = [...(data.monthly_sales || [])].reverse().map((item: any) => ({
|
||||
month: item.month,
|
||||
sales: Number(item.sales),
|
||||
revenue: Number(item.revenue),
|
||||
profit: Number(item.profit || 0)
|
||||
sales: Number(item.units_sold || 0),
|
||||
revenue: Number(item.revenue || 0),
|
||||
}));
|
||||
|
||||
|
||||
// Transform snake_case PO fields to camelCase expected by ProductPurchaseOrder
|
||||
const formattedPurchases: ProductPurchaseOrder[] = (data.recent_purchases || []).map((po: any) => ({
|
||||
poId: po.po_id,
|
||||
date: po.date,
|
||||
expectedDate: po.expected_date,
|
||||
receivedDate: po.received_date,
|
||||
ordered: po.ordered,
|
||||
received: po.received,
|
||||
status: po.status,
|
||||
costPrice: po.cost_price,
|
||||
notes: po.notes,
|
||||
leadTimeDays: po.lead_time_days,
|
||||
}));
|
||||
|
||||
return {
|
||||
monthlySales: formattedMonthlySales,
|
||||
recentPurchases: data.recent_purchases || []
|
||||
recentPurchases: formattedPurchases,
|
||||
};
|
||||
},
|
||||
enabled: !!productId, // Only run query when productId is truthy
|
||||
});
|
||||
|
||||
// Get PO status names
|
||||
const getPOStatusName = (status: number): string => {
|
||||
const statusMap: {[key: number]: string} = {
|
||||
0: 'Canceled',
|
||||
1: 'Created',
|
||||
10: 'Ready to Send',
|
||||
11: 'Ordered',
|
||||
12: 'Preordered',
|
||||
13: 'Electronically Sent',
|
||||
15: 'Receiving Started',
|
||||
50: 'Completed'
|
||||
// Get PO status display names (DB stores text statuses)
|
||||
const getPOStatusName = (status: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
canceled: 'Canceled',
|
||||
created: 'Created',
|
||||
ordered: 'Ordered',
|
||||
electronically_sent: 'Electronically Sent',
|
||||
receiving_started: 'Receiving Started',
|
||||
done: 'Completed',
|
||||
};
|
||||
return statusMap[status] || 'Unknown';
|
||||
};
|
||||
|
||||
// Get status badge color class
|
||||
const getStatusBadgeClass = (status: number): string => {
|
||||
if (status === 0) return "bg-destructive text-destructive-foreground"; // Canceled
|
||||
if (status === 50) return "bg-green-600 text-white"; // Completed
|
||||
if (status >= 15) return "bg-amber-500 text-black"; // In progress
|
||||
return "bg-blue-600 text-white"; // Other statuses
|
||||
const getStatusBadgeClass = (status: string): string => {
|
||||
if (status === 'canceled') return "bg-destructive text-destructive-foreground";
|
||||
if (status === 'done') return "bg-green-600 text-white";
|
||||
if (status === 'receiving_started') return "bg-amber-500 text-black";
|
||||
return "bg-blue-600 text-white"; // created, ordered, electronically_sent
|
||||
};
|
||||
|
||||
const isLoading = isLoadingProduct || isLoadingTimeSeries;
|
||||
|
||||
if (!productId) return null; // Don't render anything if no ID
|
||||
|
||||
return (
|
||||
@@ -160,7 +140,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-4 border-b sticky top-0 bg-background z-10">
|
||||
<div className="flex items-center gap-4 overflow-hidden">
|
||||
{isLoading ? (
|
||||
{isLoadingProduct ? (
|
||||
<Skeleton className="h-16 w-16 rounded-lg" />
|
||||
) : product?.imageUrl ? (
|
||||
<div className="h-16 w-16 rounded-lg border bg-white p-1 flex-shrink-0">
|
||||
@@ -171,14 +151,23 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<VaulDrawer.Title className="text-lg font-semibold truncate">
|
||||
{isLoading ? <Skeleton className="h-6 w-3/4" /> : product?.title || 'Product Detail'}
|
||||
{isLoadingProduct ? <Skeleton className="h-6 w-3/4" /> : product?.title || 'Product Detail'}
|
||||
</VaulDrawer.Title>
|
||||
<VaulDrawer.Description className="text-sm text-muted-foreground">
|
||||
{isLoading ? <Skeleton className="h-4 w-1/2 mt-1" /> : product?.sku || ''}
|
||||
{isLoadingProduct ? <Skeleton className="h-4 w-1/2 mt-1" /> : (
|
||||
<>
|
||||
{product?.sku || ''}
|
||||
{product?.lastCalculated && (
|
||||
<span className="ml-2 text-xs text-muted-foreground/70">
|
||||
· Updated {formatDate(product.lastCalculated)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</VaulDrawer.Description>
|
||||
{/* Show Status Badge */}
|
||||
{!isLoading && product && (
|
||||
<div className="mt-1" dangerouslySetInnerHTML={{ __html: getStatusBadge(product.status as ProductStatus) }} />
|
||||
{!isLoadingProduct && product && (
|
||||
<div className="mt-1"><StatusBadge status={product.status as string} /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,7 +178,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
{isLoadingProduct ? (
|
||||
<div className="p-4 space-y-4">
|
||||
<Skeleton className="h-8 w-1/2" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -234,6 +223,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<InfoItem label="Landing Cost" value={formatCurrency(product.currentLandingCostPrice)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Customer Engagement</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Rating" value={product.rating != null ? `${product.rating.toFixed(1)} / 5` : 'N/A'} />
|
||||
<InfoItem label="Reviews" value={formatNumber(product.reviews)} />
|
||||
<InfoItem label="Basket Adds" value={formatNumber(product.baskets)} />
|
||||
<InfoItem label="Stock Alerts" value={formatNumber(product.notifies)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="inventory" className="space-y-4">
|
||||
@@ -248,7 +246,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Stock Position</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Status" value={<div className="scale-90 origin-left" dangerouslySetInnerHTML={{ __html: getStatusBadge(product.status as ProductStatus) }} />} />
|
||||
<InfoItem label="Status" value={<div className="scale-90 origin-left"><StatusBadge status={product.status as string} /></div>} />
|
||||
<InfoItem label="Stock Cover" value={formatDays(product.stockCoverInDays)} />
|
||||
<InfoItem label="Sells Out In" value={formatDays(product.sellsOutInDays)} />
|
||||
<InfoItem label="Overstock Units" value={formatNumber(product.overstockedUnits)} />
|
||||
@@ -264,6 +262,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<InfoItem label="Earliest Arrival" value={formatDate(product.earliestExpectedDate)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Service Level (30 Days)</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Fill Rate" value={formatPercentage(product.fillRate30d)} />
|
||||
<InfoItem label="Service Level" value={formatPercentage(product.serviceLevel30d)} />
|
||||
<InfoItem label="Stockout Incidents" value={formatNumber(product.stockoutIncidents30d)} />
|
||||
<InfoItem label="Lost Sales Incidents" value={formatNumber(product.lostSalesIncidents30d)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="performance" className="space-y-4">
|
||||
@@ -288,36 +295,29 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis yAxisId="left" />
|
||||
<YAxis yAxisId="right" orientation="right" />
|
||||
<Tooltip
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'revenue' || name === 'profit') {
|
||||
if (name === 'Revenue') {
|
||||
return [formatCurrency(value), name];
|
||||
}
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="sales"
|
||||
name="Units Sold"
|
||||
stroke="#8884d8"
|
||||
activeDot={{ r: 8 }}
|
||||
type="monotone"
|
||||
dataKey="sales"
|
||||
name="Units Sold"
|
||||
stroke="#8884d8"
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
<Line
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#82ca9d"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="profit"
|
||||
name="Profit"
|
||||
stroke="#ffc658"
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#82ca9d"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -342,59 +342,16 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Inventory KPIs Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Key Inventory Metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[250px]">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[200px] w-full" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={[
|
||||
{
|
||||
name: 'Stock Turn',
|
||||
value: product.stockturn30d || 0,
|
||||
fill: '#8884d8'
|
||||
},
|
||||
{
|
||||
name: 'GMROI',
|
||||
value: product.gmroi30d || 0,
|
||||
fill: '#82ca9d'
|
||||
},
|
||||
{
|
||||
name: 'Sell Through %',
|
||||
value: product.sellThrough30d ? product.sellThrough30d * 100 : 0,
|
||||
fill: '#ffc658'
|
||||
},
|
||||
{
|
||||
name: 'Margin %',
|
||||
value: product.margin30d ? product.margin30d * 100 : 0,
|
||||
fill: '#ff8042'
|
||||
}
|
||||
]}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 50 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" height={60} />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Sell Through %' || name === 'Margin %') {
|
||||
return [`${value.toFixed(1)}%`, name];
|
||||
}
|
||||
return [value.toFixed(2), name];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
<CardHeader><CardTitle className="text-base">Growth Analysis</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Sales Growth (30d vs Prev)" value={formatPercentage(product.salesGrowth30dVsPrev)} />
|
||||
<InfoItem label="Revenue Growth (30d vs Prev)" value={formatPercentage(product.revenueGrowth30dVsPrev)} />
|
||||
<InfoItem label="Sales Growth YoY" value={formatPercentage(product.salesGrowthYoy)} />
|
||||
<InfoItem label="Revenue Growth YoY" value={formatPercentage(product.revenueGrowthYoy)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Inventory Performance (30 Days)</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
@@ -503,16 +460,18 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
) : 'N/A'
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Fulfillment Rate"
|
||||
<InfoItem
|
||||
label="Fulfillment Rate"
|
||||
value={
|
||||
timeSeriesData?.recentPurchases ?
|
||||
timeSeriesData?.recentPurchases ?
|
||||
(() => {
|
||||
const totalOrdered = timeSeriesData.recentPurchases.reduce((acc, po) => acc + po.ordered, 0);
|
||||
const totalReceived = timeSeriesData.recentPurchases.reduce((acc, po) => acc + po.received, 0);
|
||||
return totalOrdered > 0 ? formatPercentage(totalReceived / totalOrdered) : 'N/A';
|
||||
// Only include POs where receiving has started or completed for an accurate rate
|
||||
const receivingPOs = timeSeriesData.recentPurchases.filter(po => ['receiving_started', 'done'].includes(po.status));
|
||||
const totalOrdered = receivingPOs.reduce((acc, po) => acc + po.ordered, 0);
|
||||
const totalReceived = receivingPOs.reduce((acc, po) => acc + po.received, 0);
|
||||
return totalOrdered > 0 ? formatPercentage((totalReceived / totalOrdered) * 100) : 'N/A';
|
||||
})() : 'N/A'
|
||||
}
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Total PO Cost"
|
||||
@@ -544,7 +503,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
timeSeriesData?.recentPurchases ?
|
||||
formatNumber(
|
||||
timeSeriesData.recentPurchases.filter(
|
||||
po => po.status < 50 && po.receivingStatus < 40
|
||||
po => !['done', 'canceled'].includes(po.status)
|
||||
).length
|
||||
) : 'N/A'
|
||||
}
|
||||
@@ -584,6 +543,29 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<InfoItem label="Forecast Lost Revenue" value={formatCurrency(product.forecastLostRevenue)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Demand & Seasonality</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Demand Pattern" value={product.demandPattern || 'N/A'} />
|
||||
<InfoItem label="Sales CV %" value={formatPercentage(product.salesCv30d)} />
|
||||
<InfoItem label="Sales Std Dev" value={formatNumber(product.salesStdDev30d, 2)} />
|
||||
<InfoItem label="Seasonality Index" value={formatNumber(product.seasonalityIndex, 2)} />
|
||||
<InfoItem label="Seasonal Pattern" value={product.seasonalPattern || 'N/A'} />
|
||||
<InfoItem label="Peak Season" value={product.peakSeason || 'N/A'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Lifetime & First Period</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Lifetime Sales" value={formatNumber(product.lifetimeSales)} />
|
||||
<InfoItem label="Lifetime Revenue" value={formatCurrency(product.lifetimeRevenue)} />
|
||||
<InfoItem label="Revenue Quality" value={product.lifetimeRevenueQuality || 'N/A'} />
|
||||
<InfoItem label="First 7d Sales" value={formatNumber(product.first7DaysSales)} />
|
||||
<InfoItem label="First 30d Sales" value={formatNumber(product.first30DaysSales)} />
|
||||
<InfoItem label="First 60d Sales" value={formatNumber(product.first60DaysSales)} />
|
||||
<InfoItem label="First 90d Sales" value={formatNumber(product.first90DaysSales)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
@@ -19,14 +19,21 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ProductFilterOptions, ProductMetricColumnKey } from "@/types/products";
|
||||
import {
|
||||
ProductFilterOptions,
|
||||
ProductMetricColumnKey,
|
||||
ComparisonOperator,
|
||||
ActiveFilterValue,
|
||||
FilterValue,
|
||||
} from "@/types/products";
|
||||
|
||||
// Define operators for different filter types
|
||||
const STRING_OPERATORS: ComparisonOperator[] = ["contains", "equals", "starts_with", "ends_with", "not_contains", "is_empty", "is_not_empty"];
|
||||
const NUMBER_OPERATORS: ComparisonOperator[] = ["=", ">", ">=", "<", "<=", "between", "is_empty", "is_not_empty"];
|
||||
const BOOLEAN_OPERATORS: ComparisonOperator[] = ["is_true", "is_false"];
|
||||
const DATE_OPERATORS: ComparisonOperator[] = ["=", ">", ">=", "<", "<=", "between", "is_empty", "is_not_empty"];
|
||||
const SELECT_OPERATORS: ComparisonOperator[] = ["=", "!=", "in", "not_in", "is_empty", "is_not_empty"];
|
||||
// Select filters use direct value selection (no operator UI), so only equality is applied
|
||||
const SELECT_OPERATORS: ComparisonOperator[] = ["="];
|
||||
|
||||
interface FilterOption {
|
||||
id: ProductMetricColumnKey | 'search';
|
||||
@@ -37,21 +44,6 @@ interface FilterOption {
|
||||
operators?: ComparisonOperator[];
|
||||
}
|
||||
|
||||
type FilterValue = string | number | boolean;
|
||||
|
||||
export type ComparisonOperator =
|
||||
| "=" | "!=" | ">" | ">=" | "<" | "<=" | "between"
|
||||
| "contains" | "equals" | "starts_with" | "ends_with" | "not_contains"
|
||||
| "in" | "not_in" | "is_empty" | "is_not_empty" | "is_true" | "is_false";
|
||||
|
||||
// Support both simple values and complex ones with operators
|
||||
export type ActiveFilterValue = FilterValue | FilterValueWithOperator;
|
||||
|
||||
interface FilterValueWithOperator {
|
||||
value: FilterValue | string[] | number[];
|
||||
operator: ComparisonOperator;
|
||||
}
|
||||
|
||||
interface ActiveFilterDisplay {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -76,8 +68,8 @@ const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||
{ id: 'status', label: 'Status', type: 'select', group: 'Basic Info', operators: SELECT_OPERATORS, options: [
|
||||
{ value: 'Critical', label: 'Critical' },
|
||||
{ value: 'At Risk', label: 'At Risk' },
|
||||
{ value: 'Reorder', label: 'Reorder' },
|
||||
{ value: 'Overstocked', label: 'Overstocked' },
|
||||
{ value: 'Reorder Soon', label: 'Reorder Soon' },
|
||||
{ value: 'Overstock', label: 'Overstock' },
|
||||
{ value: 'Healthy', label: 'Healthy' },
|
||||
{ value: 'New', label: 'New' },
|
||||
]},
|
||||
@@ -347,7 +339,7 @@ export function ProductFilters({
|
||||
if (!selectedFilter) return;
|
||||
|
||||
let valueToApply: FilterValue | [string, string]; // Use string for dates
|
||||
let requiresOperator = selectedFilter.type === 'number' || selectedFilter.type === 'date';
|
||||
let requiresOperator = selectedFilter.type === 'number' || selectedFilter.type === 'date' || selectedFilter.type === 'text';
|
||||
|
||||
if (selectedOperator === 'between') {
|
||||
if (!inputValue || !inputValue2) return; // Need both values
|
||||
@@ -357,9 +349,9 @@ export function ProductFilters({
|
||||
const numVal = parseFloat(inputValue);
|
||||
if (isNaN(numVal)) return; // Invalid number
|
||||
valueToApply = numVal;
|
||||
} else if (selectedFilter.type === 'boolean' || selectedFilter.type === 'select') {
|
||||
} else if (selectedFilter.type === 'select') {
|
||||
valueToApply = inputValue; // Value set directly via CommandItem select
|
||||
requiresOperator = false; // Usually simple equality for selects/booleans
|
||||
requiresOperator = false; // Usually simple equality for selects
|
||||
} else { // Text or Date (not between)
|
||||
if (!inputValue.trim()) return;
|
||||
valueToApply = inputValue.trim();
|
||||
@@ -404,9 +396,15 @@ export function ProductFilters({
|
||||
if (!option) return String(value); // Fallback
|
||||
|
||||
if (typeof value === 'object' && value !== null && 'operator' in value) {
|
||||
// Boolean operators get friendly display
|
||||
if (value.operator === 'is_true') return `${option.label}: Yes`;
|
||||
if (value.operator === 'is_false') return `${option.label}: No`;
|
||||
if (value.operator === 'is_empty') return `${option.label}: is empty`;
|
||||
if (value.operator === 'is_not_empty') return `${option.label}: is not empty`;
|
||||
|
||||
const opLabel = value.operator === '=' ? '' : `${value.operator} `;
|
||||
if (value.operator === 'between' && Array.isArray(value.value)) {
|
||||
return `${option.label}: ${opLabel} ${value.value[0]} and ${value.value[1]}`;
|
||||
return `${option.label}: ${value.value[0]} to ${value.value[1]}`;
|
||||
}
|
||||
return `${option.label}: ${opLabel}${value.value}`;
|
||||
}
|
||||
@@ -529,8 +527,8 @@ export function ProductFilters({
|
||||
← {selectedFilter.label}
|
||||
</Button>
|
||||
|
||||
{/* Render Operator Select ONLY if type is number or date */}
|
||||
{(selectedFilter.type === 'number' || selectedFilter.type === 'date') && renderOperatorSelect()}
|
||||
{/* Render Operator Select for number, date, and text types */}
|
||||
{(selectedFilter.type === 'number' || selectedFilter.type === 'date' || selectedFilter.type === 'text') && renderOperatorSelect()}
|
||||
|
||||
{/* Render Input based on type */}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -606,8 +604,42 @@ export function ProductFilters({
|
||||
{/* Select and Boolean types are handled via CommandList below */}
|
||||
</div>
|
||||
|
||||
{/* CommandList for Select and Boolean */}
|
||||
{(selectedFilter.type === 'select' || selectedFilter.type === 'boolean') && (
|
||||
{/* Boolean type: show Yes/No buttons */}
|
||||
{selectedFilter.type === 'boolean' && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8"
|
||||
onClick={() => {
|
||||
onFilterChange({
|
||||
...activeFilters,
|
||||
[selectedFilter.id]: { value: 'true', operator: 'is_true' as ComparisonOperator },
|
||||
});
|
||||
handlePopoverClose();
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8"
|
||||
onClick={() => {
|
||||
onFilterChange({
|
||||
...activeFilters,
|
||||
[selectedFilter.id]: { value: 'false', operator: 'is_false' as ComparisonOperator },
|
||||
});
|
||||
handlePopoverClose();
|
||||
}}
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CommandList for Select type */}
|
||||
{selectedFilter.type === 'select' && (
|
||||
<Command className="mt-2 border rounded-md">
|
||||
<CommandInput
|
||||
ref={selectInputRef}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
DollarSign,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
PackageX,
|
||||
PackageMinus,
|
||||
Truck,
|
||||
ShoppingCart,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SummaryData {
|
||||
total_products: number;
|
||||
total_stock_value: string;
|
||||
total_stock_retail: string;
|
||||
needs_reorder_count: number;
|
||||
total_replenishment_cost: string;
|
||||
total_replenishment_units: number;
|
||||
total_overstock_value: string;
|
||||
total_overstock_units: number;
|
||||
total_on_order_units: number;
|
||||
total_on_order_cost: string;
|
||||
avg_stock_cover_days: string;
|
||||
out_of_stock_count: number;
|
||||
total_lost_revenue: string;
|
||||
total_lost_sales_units: number;
|
||||
critical_count: number;
|
||||
reorder_count: number;
|
||||
at_risk_count: number;
|
||||
overstock_count: number;
|
||||
healthy_count: number;
|
||||
new_count: number;
|
||||
}
|
||||
|
||||
interface ProductSummaryCardsProps {
|
||||
activeView: string;
|
||||
showNonReplenishable: boolean;
|
||||
showInvisible: boolean;
|
||||
}
|
||||
|
||||
function formatCurrency(value: string | number): string {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return '$0';
|
||||
if (num >= 1_000_000) return `$${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `$${(num / 1_000).toFixed(1)}K`;
|
||||
return `$${num.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function formatDays(value: string | number): string {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num) || num === 0) return '0 days';
|
||||
return `${num.toFixed(0)} days`;
|
||||
}
|
||||
|
||||
interface CardConfig {
|
||||
label: string;
|
||||
value: string;
|
||||
subValue?: string;
|
||||
icon: any;
|
||||
iconClassName: string;
|
||||
}
|
||||
|
||||
function getCardsForView(data: SummaryData, activeView: string): CardConfig[] {
|
||||
const allCards: Record<string, CardConfig[]> = {
|
||||
all: [
|
||||
{
|
||||
label: 'Stock Value',
|
||||
value: formatCurrency(data.total_stock_value),
|
||||
subValue: `${formatCurrency(data.total_stock_retail)} retail`,
|
||||
icon: DollarSign,
|
||||
iconClassName: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'Needs Reorder',
|
||||
value: formatNumber(data.needs_reorder_count),
|
||||
subValue: `${formatCurrency(data.total_replenishment_cost)} to replenish`,
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-amber-500',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
{
|
||||
label: 'Overstock Value',
|
||||
value: formatCurrency(data.total_overstock_value),
|
||||
subValue: `${formatNumber(data.total_overstock_units)} excess units`,
|
||||
icon: PackageX,
|
||||
iconClassName: 'text-orange-400',
|
||||
},
|
||||
],
|
||||
critical: [
|
||||
{
|
||||
label: 'Out of Stock',
|
||||
value: formatNumber(data.out_of_stock_count),
|
||||
subValue: `of ${formatNumber(data.total_products)} products`,
|
||||
icon: PackageMinus,
|
||||
iconClassName: 'text-red-500',
|
||||
},
|
||||
{
|
||||
label: 'Replenishment Needed',
|
||||
value: formatNumber(data.total_replenishment_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_replenishment_cost)} cost`,
|
||||
icon: ShoppingCart,
|
||||
iconClassName: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
{
|
||||
label: 'Forecast Lost Sales',
|
||||
value: formatNumber(data.total_lost_sales_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_lost_revenue)} revenue`,
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-red-400',
|
||||
},
|
||||
],
|
||||
reorder: [
|
||||
{
|
||||
label: 'Replenishment Needed',
|
||||
value: formatNumber(data.total_replenishment_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_replenishment_cost)} cost`,
|
||||
icon: ShoppingCart,
|
||||
iconClassName: 'text-amber-500',
|
||||
},
|
||||
{
|
||||
label: 'Also Critical',
|
||||
value: formatNumber(data.critical_count),
|
||||
subValue: 'need ordering too',
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-red-500',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
{
|
||||
label: 'Forecast Lost Sales',
|
||||
value: formatNumber(data.total_lost_sales_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_lost_revenue)} revenue`,
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-red-400',
|
||||
},
|
||||
],
|
||||
healthy: [
|
||||
{
|
||||
label: 'Healthy Products',
|
||||
value: formatNumber(data.healthy_count),
|
||||
subValue: `of ${formatNumber(data.total_products)} total`,
|
||||
icon: Package,
|
||||
iconClassName: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: 'Avg Stock Cover',
|
||||
value: formatDays(data.avg_stock_cover_days),
|
||||
icon: Clock,
|
||||
iconClassName: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: 'Stock Value',
|
||||
value: formatCurrency(data.total_stock_value),
|
||||
subValue: `${formatCurrency(data.total_stock_retail)} retail`,
|
||||
icon: DollarSign,
|
||||
iconClassName: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
],
|
||||
'at-risk': [
|
||||
{
|
||||
label: 'At Risk Products',
|
||||
value: formatNumber(data.at_risk_count),
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-orange-500',
|
||||
},
|
||||
{
|
||||
label: 'Avg Stock Cover',
|
||||
value: formatDays(data.avg_stock_cover_days),
|
||||
icon: Clock,
|
||||
iconClassName: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
label: 'Forecast Lost Sales',
|
||||
value: formatNumber(data.total_lost_sales_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_lost_revenue)} revenue`,
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-red-400',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
],
|
||||
overstocked: [
|
||||
{
|
||||
label: 'Overstocked Products',
|
||||
value: formatNumber(data.overstock_count),
|
||||
icon: PackageX,
|
||||
iconClassName: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
label: 'Excess Units',
|
||||
value: formatNumber(data.total_overstock_units),
|
||||
icon: Package,
|
||||
iconClassName: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
label: 'Overstock Value',
|
||||
value: formatCurrency(data.total_overstock_value),
|
||||
subValue: 'at cost',
|
||||
icon: DollarSign,
|
||||
iconClassName: 'text-orange-500',
|
||||
},
|
||||
{
|
||||
label: 'Avg Stock Cover',
|
||||
value: formatDays(data.avg_stock_cover_days),
|
||||
icon: Clock,
|
||||
iconClassName: 'text-blue-400',
|
||||
},
|
||||
],
|
||||
new: [
|
||||
{
|
||||
label: 'New Products',
|
||||
value: formatNumber(data.new_count),
|
||||
icon: Package,
|
||||
iconClassName: 'text-purple-500',
|
||||
},
|
||||
{
|
||||
label: 'Stock Value',
|
||||
value: formatCurrency(data.total_stock_value),
|
||||
subValue: `${formatCurrency(data.total_stock_retail)} retail`,
|
||||
icon: DollarSign,
|
||||
iconClassName: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
{
|
||||
label: 'Out of Stock',
|
||||
value: formatNumber(data.out_of_stock_count),
|
||||
subValue: `of ${formatNumber(data.total_products)} products`,
|
||||
icon: PackageMinus,
|
||||
iconClassName: 'text-red-400',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return allCards[activeView] || allCards.all;
|
||||
}
|
||||
|
||||
export function ProductSummaryCards({ activeView, showNonReplenishable, showInvisible }: ProductSummaryCardsProps) {
|
||||
const { data, isLoading } = useQuery<SummaryData>({
|
||||
queryKey: ['productsSummary', showNonReplenishable, showInvisible],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (showNonReplenishable) params.append('showNonReplenishable', 'true');
|
||||
if (showInvisible) params.append('showInvisible', 'true');
|
||||
const response = await fetch(`/api/metrics/summary?${params.toString()}`, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to fetch summary');
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 60 * 1000, // Cache for 1 minute
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card p-4 animate-pulse">
|
||||
<div className="h-4 w-24 bg-muted rounded mb-2" />
|
||||
<div className="h-7 w-20 bg-muted rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = getCardsForView(data, activeView);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{cards.map((card) => (
|
||||
<div key={card.label} className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{card.label}</p>
|
||||
<card.icon className={`h-4 w-4 ${card.iconClassName}`} />
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{card.value}</p>
|
||||
{card.subValue && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{card.subValue}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,19 +25,10 @@ import {
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ProductMetric, ProductMetricColumnKey, ProductStatus } from "@/types/products";
|
||||
import { ProductMetric, ProductMetricColumnKey } from "@/types/products";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getStatusBadge } from "@/utils/productUtils";
|
||||
|
||||
// Column definition
|
||||
interface ColumnDef {
|
||||
key: ProductMetricColumnKey;
|
||||
label: string;
|
||||
group: string;
|
||||
noLabel?: boolean;
|
||||
width?: string;
|
||||
format?: (value: any, product?: ProductMetric) => React.ReactNode;
|
||||
}
|
||||
import { StatusBadge } from "@/components/products/StatusBadge";
|
||||
import { ColumnDef } from "@/components/products/columnDefinitions";
|
||||
|
||||
interface ProductTableProps {
|
||||
products: ProductMetric[];
|
||||
@@ -145,10 +136,6 @@ export function ProductTable({
|
||||
return columnOrder.filter(col => visibleColumns.has(col));
|
||||
}, [columnOrder, visibleColumns]);
|
||||
|
||||
const handleDragStart = () => {
|
||||
// No need to set activeId as it's not used in the new implementation
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
@@ -163,18 +150,23 @@ export function ProductTable({
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-compute a Map for O(1) column def lookups instead of O(n) find() per cell
|
||||
const columnDefMap = React.useMemo(() => {
|
||||
return new Map(columnDefs.map(col => [col.key, col]));
|
||||
}, [columnDefs]);
|
||||
|
||||
const formatColumnValue = (product: ProductMetric, columnKey: ProductMetricColumnKey): React.ReactNode => {
|
||||
const value = product[columnKey as keyof ProductMetric];
|
||||
const columnDef = columnDefs.find(col => col.key === columnKey);
|
||||
|
||||
const columnDef = columnDefMap.get(columnKey);
|
||||
|
||||
// Use the format function from column definition if available
|
||||
if (columnDef?.format) {
|
||||
return columnDef.format(value, product);
|
||||
}
|
||||
|
||||
// Special handling for status
|
||||
|
||||
// Special handling for status - proper React component instead of dangerouslySetInnerHTML
|
||||
if (columnKey === 'status') {
|
||||
return <div dangerouslySetInnerHTML={{ __html: getStatusBadge(value as ProductStatus) }} />;
|
||||
return <StatusBadge status={value as string} />;
|
||||
}
|
||||
|
||||
// Special handling for boolean values
|
||||
@@ -204,9 +196,7 @@ export function ProductTable({
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => {}}
|
||||
>
|
||||
<div className="border rounded-md relative">
|
||||
{isLoading && (
|
||||
@@ -226,7 +216,7 @@ export function ProductTable({
|
||||
<SortableHeader
|
||||
key={columnKey}
|
||||
column={columnKey}
|
||||
columnDef={columnDefs.find(def => def.key === columnKey)}
|
||||
columnDef={columnDefMap.get(columnKey)}
|
||||
onSort={onSort}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
@@ -254,7 +244,7 @@ export function ProductTable({
|
||||
data-state={isLoading ? 'loading' : undefined}
|
||||
>
|
||||
{orderedVisibleColumns.map((columnKey) => {
|
||||
const colDef = columnDefs.find(c => c.key === columnKey);
|
||||
const colDef = columnDefMap.get(columnKey);
|
||||
return (
|
||||
<TableCell
|
||||
key={`${product.pid}-${columnKey}`}
|
||||
@@ -289,7 +279,7 @@ export function ProductTable({
|
||||
{isLoading && products.length === 0 && Array.from({length: 10}).map((_, i) => (
|
||||
<TableRow key={`skel-${i}`}>
|
||||
{orderedVisibleColumns.map(key => {
|
||||
const colDef = columnDefs.find(c => c.key === key);
|
||||
const colDef = columnDefMap.get(key);
|
||||
return (
|
||||
<TableCell
|
||||
key={`skel-${i}-${key}`}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
PackageSearch,
|
||||
CheckCircle2,
|
||||
PackageSearch,
|
||||
Sparkles,
|
||||
Timer,
|
||||
PackagePlus,
|
||||
@@ -61,25 +61,57 @@ export const PRODUCT_VIEWS: ProductView[] = [
|
||||
}
|
||||
]
|
||||
|
||||
interface ViewCounts {
|
||||
critical_count: number;
|
||||
reorder_count: number;
|
||||
at_risk_count: number;
|
||||
overstock_count: number;
|
||||
healthy_count: number;
|
||||
new_count: number;
|
||||
total_products: number;
|
||||
}
|
||||
|
||||
interface ProductViewsProps {
|
||||
activeView: string
|
||||
onViewChange: (view: string) => void
|
||||
viewCounts?: ViewCounts | null
|
||||
}
|
||||
|
||||
export function ProductViews({ activeView, onViewChange }: ProductViewsProps) {
|
||||
function getCountForView(viewId: string, counts: ViewCounts): number | null {
|
||||
switch (viewId) {
|
||||
case 'all': return counts.total_products;
|
||||
case 'critical': return counts.critical_count;
|
||||
case 'reorder': return counts.reorder_count;
|
||||
case 'healthy': return counts.healthy_count;
|
||||
case 'at-risk': return counts.at_risk_count;
|
||||
case 'overstocked': return counts.overstock_count;
|
||||
case 'new': return counts.new_count;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProductViews({ activeView, onViewChange, viewCounts }: ProductViewsProps) {
|
||||
return (
|
||||
<Tabs value={activeView} onValueChange={onViewChange} className="w-full">
|
||||
<TabsList className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground w-fit">
|
||||
{PRODUCT_VIEWS.map((view) => (
|
||||
<TabsTrigger
|
||||
key={view.id}
|
||||
value={view.id}
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow"
|
||||
>
|
||||
<view.icon className={`h-4 w-4 ${view.iconClassName} mr-2`} />
|
||||
{view.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{PRODUCT_VIEWS.map((view) => {
|
||||
const count = viewCounts ? getCountForView(view.id, viewCounts) : null;
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={view.id}
|
||||
value={view.id}
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow"
|
||||
>
|
||||
<view.icon className={`h-4 w-4 ${view.iconClassName} mr-2`} />
|
||||
{view.label}
|
||||
{count !== null && (
|
||||
<span className="ml-1.5 text-[10px] rounded-full bg-background/50 px-1.5 py-0 text-muted-foreground tabular-nums">
|
||||
{count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ProductFilterOptions, ProductMetric, ProductMetricColumnKey } from "@/types/products";
|
||||
import { ProductTable } from "./ProductTable";
|
||||
import { ProductFilters } from "./ProductFilters";
|
||||
import { ProductDetail } from "./ProductDetail";
|
||||
import config from "@/config";
|
||||
import { getProductStatus } from "@/utils/productUtils";
|
||||
|
||||
export function Products() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selectedProductId, setSelectedProductId] = React.useState<number | null>(null);
|
||||
|
||||
// Get current filter values from URL params
|
||||
const currentPage = Number(searchParams.get("page") || "1");
|
||||
const pageSize = Number(searchParams.get("pageSize") || "25");
|
||||
const sortBy = searchParams.get("sortBy") || "title";
|
||||
const sortDirection = searchParams.get("sortDirection") || "asc";
|
||||
const filterType = searchParams.get("filterType") || "";
|
||||
const filterValue = searchParams.get("filterValue") || "";
|
||||
const searchQuery = searchParams.get("search") || "";
|
||||
const statusFilter = searchParams.get("status") || "";
|
||||
|
||||
// Fetch filter options
|
||||
const {
|
||||
data: filterOptions,
|
||||
isLoading: isLoadingOptions
|
||||
} = useQuery<ProductFilterOptions>({
|
||||
queryKey: ["productFilterOptions"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/metrics/filter-options`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { vendors: [], brands: [], abcClasses: [] };
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
initialData: { vendors: [], brands: [], abcClasses: [] }, // Provide initial data to prevent undefined
|
||||
});
|
||||
|
||||
// Fetch products with metrics data
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error
|
||||
} = useQuery<{ products: ProductMetric[], total: number }>({
|
||||
queryKey: ["products", currentPage, pageSize, sortBy, sortDirection, filterType, filterValue, searchQuery, statusFilter],
|
||||
queryFn: async () => {
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams();
|
||||
params.append("page", currentPage.toString());
|
||||
params.append("limit", pageSize.toString());
|
||||
|
||||
if (sortBy) params.append("sortBy", sortBy);
|
||||
if (sortDirection) params.append("sortDirection", sortDirection);
|
||||
if (filterType && filterValue) {
|
||||
params.append("filterType", filterType);
|
||||
params.append("filterValue", filterValue);
|
||||
}
|
||||
if (searchQuery) params.append("search", searchQuery);
|
||||
if (statusFilter) params.append("status", statusFilter);
|
||||
|
||||
const response = await fetch(`${config.apiUrl}/metrics?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Failed to fetch products (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Calculate status for each product
|
||||
const productsWithStatus = data.products.map((product: ProductMetric) => ({
|
||||
...product,
|
||||
status: getProductStatus(product)
|
||||
}));
|
||||
|
||||
return {
|
||||
products: productsWithStatus,
|
||||
total: data.total
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const handleSortChange = (field: string, direction: "asc" | "desc") => {
|
||||
searchParams.set("sortBy", field);
|
||||
searchParams.set("sortDirection", direction);
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handleSort = (column: ProductMetricColumnKey) => {
|
||||
// Toggle sort direction if same column, otherwise default to asc
|
||||
const newDirection = sortBy === column && sortDirection === "asc" ? "desc" : "asc";
|
||||
handleSortChange(column, newDirection as "asc" | "desc");
|
||||
};
|
||||
|
||||
const handleViewProduct = (id: number) => {
|
||||
setSelectedProductId(id);
|
||||
};
|
||||
|
||||
const handleCloseProductDetail = () => {
|
||||
setSelectedProductId(null);
|
||||
};
|
||||
|
||||
// Create a wrapper function to handle all filter changes
|
||||
const handleFiltersChange = (filters: Record<string, any>) => {
|
||||
// Reset to first page when applying filters
|
||||
searchParams.set("page", "1");
|
||||
|
||||
// Update searchParams with all filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
searchParams.set(key, String(value));
|
||||
} else {
|
||||
searchParams.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
// Clear all filters
|
||||
const handleClearFilters = () => {
|
||||
// Keep only pagination and sorting params
|
||||
const newParams = new URLSearchParams();
|
||||
newParams.set("page", "1");
|
||||
newParams.set("pageSize", pageSize.toString());
|
||||
newParams.set("sortBy", sortBy);
|
||||
newParams.set("sortDirection", sortDirection);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// Current active filters
|
||||
const activeFilters = React.useMemo(() => {
|
||||
const filters: Record<string, any> = {};
|
||||
|
||||
if (filterType && filterValue) {
|
||||
filters[filterType] = filterValue;
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
filters.search = searchQuery;
|
||||
}
|
||||
|
||||
if (statusFilter) {
|
||||
filters.status = statusFilter;
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [filterType, filterValue, searchQuery, statusFilter]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Products</h2>
|
||||
</div>
|
||||
|
||||
<ProductFilters
|
||||
filterOptions={filterOptions || { vendors: [], brands: [], abcClasses: [] }}
|
||||
isLoadingOptions={isLoadingOptions}
|
||||
onFilterChange={handleFiltersChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
activeFilters={activeFilters}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center min-h-[300px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span>Loading products...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-destructive/10 p-4 rounded-lg text-center text-destructive border border-destructive">
|
||||
Error loading products: {(error as Error).message}
|
||||
</div>
|
||||
) : (
|
||||
<ProductTable
|
||||
products={data?.products || []}
|
||||
onViewProduct={handleViewProduct}
|
||||
isLoading={isLoading}
|
||||
onSort={handleSort}
|
||||
sortColumn={sortBy as ProductMetricColumnKey}
|
||||
sortDirection={sortDirection as "asc" | "desc"}
|
||||
columnDefs={[
|
||||
{ key: 'title', label: 'Name', group: 'Product' },
|
||||
{ key: 'brand', label: 'Brand', group: 'Product' },
|
||||
{ key: 'sku', label: 'SKU', group: 'Product' },
|
||||
{ key: 'currentStock', label: 'Stock', group: 'Inventory' },
|
||||
{ key: 'currentPrice', label: 'Price', group: 'Pricing' },
|
||||
{ key: 'status', label: 'Status', group: 'Product' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProductDetail
|
||||
productId={selectedProductId}
|
||||
onClose={handleCloseProductDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
'Critical': 'bg-red-600 text-white border-transparent',
|
||||
'Reorder Soon': 'bg-yellow-500 text-black border-secondary',
|
||||
'Healthy': 'bg-green-600 text-white border-transparent',
|
||||
'Overstock': 'bg-blue-600 text-white border-secondary',
|
||||
'At Risk': 'border-orange-500 text-orange-600',
|
||||
'New': 'bg-purple-600 text-white border-transparent',
|
||||
'Unknown': 'bg-muted text-muted-foreground border-transparent',
|
||||
};
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string | null | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
||||
const displayStatus = status || 'Unknown';
|
||||
const styles = STATUS_STYLES[displayStatus] || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
styles,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{displayStatus}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createElement } from 'react';
|
||||
import type { ProductMetric, ProductMetricColumnKey } from "@/types/products";
|
||||
|
||||
export interface ColumnDef {
|
||||
key: ProductMetricColumnKey;
|
||||
label: string;
|
||||
group: string;
|
||||
noLabel?: boolean;
|
||||
width?: string;
|
||||
format?: (value: any, product?: ProductMetric) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an inline trend indicator element showing value + colored arrow.
|
||||
* Used in sales/growth columns to provide at-a-glance trend information.
|
||||
*/
|
||||
function trendIndicator(value: any, _product?: ProductMetric): ReactNode {
|
||||
if (value === null || value === undefined) return '-';
|
||||
const num = typeof value === 'number' ? value : parseFloat(value);
|
||||
if (isNaN(num)) return '-';
|
||||
if (num === 0) return createElement('span', { className: 'text-muted-foreground' }, '0%');
|
||||
const isPositive = num > 0;
|
||||
const color = isPositive ? 'text-green-600' : 'text-red-600';
|
||||
const arrow = isPositive ? '\u2191' : '\u2193'; // ↑ or ↓
|
||||
return createElement('span', { className: `font-medium ${color}` }, `${arrow} ${Math.abs(num).toFixed(1)}%`);
|
||||
}
|
||||
|
||||
// Define available columns with their groups
|
||||
export const AVAILABLE_COLUMNS: ColumnDef[] = [
|
||||
// Identity & Basic Info
|
||||
{ key: 'imageUrl', label: 'Image', group: 'Product Identity', noLabel: true, width: 'w-[60px]' },
|
||||
{ key: 'title', label: 'Name', group: 'Product Identity'},
|
||||
{ key: 'sku', label: 'Item Number', group: 'Product Identity' },
|
||||
{ key: 'barcode', label: 'UPC', group: 'Product Identity' },
|
||||
{ key: 'brand', label: 'Company', group: 'Product Identity' },
|
||||
{ key: 'line', label: 'Line', group: 'Product Identity' },
|
||||
{ key: 'subline', label: 'Subline', group: 'Product Identity' },
|
||||
{ key: 'artist', label: 'Artist', group: 'Product Identity' },
|
||||
{ key: 'isVisible', label: 'Visible', group: 'Product Identity' },
|
||||
{ key: 'isReplenishable', label: 'Replenishable', group: 'Product Identity' },
|
||||
{ key: 'abcClass', label: 'ABC Class', group: 'Product Identity' },
|
||||
{ key: 'status', label: 'Status', group: 'Product Identity' },
|
||||
{ key: 'dateCreated', label: 'Created', group: 'Dates' },
|
||||
|
||||
// Supply Chain
|
||||
{ key: 'vendor', label: 'Supplier', group: 'Supply Chain' },
|
||||
{ key: 'vendorReference', label: 'Supplier #', group: 'Supply Chain' },
|
||||
{ key: 'notionsReference', label: 'Notions #', group: 'Supply Chain' },
|
||||
{ key: 'harmonizedTariffCode', label: 'Tariff Code', group: 'Supply Chain' },
|
||||
{ key: 'countryOfOrigin', label: 'Country', group: 'Supply Chain' },
|
||||
{ key: 'location', label: 'Location', group: 'Supply Chain' },
|
||||
{ key: 'moq', label: 'MOQ', group: 'Supply Chain', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
|
||||
// Physical Properties
|
||||
{ key: 'weight', label: 'Weight', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'length', label: 'Length', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'width', label: 'Width', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'height', label: 'Height', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'dimensions', label: 'Dimensions', group: 'Physical', format: (_, product) => {
|
||||
const length = product?.length;
|
||||
const width = product?.width;
|
||||
const height = product?.height;
|
||||
if (length && width && height) {
|
||||
return `${length}\u00d7${width}\u00d7${height}`;
|
||||
}
|
||||
return '-';
|
||||
}},
|
||||
|
||||
// Customer Engagement
|
||||
{ key: 'rating', label: 'Rating', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'reviews', label: 'Reviews', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'baskets', label: 'Basket Adds', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'notifies', label: 'Stock Alerts', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
|
||||
// Inventory & Stock
|
||||
{ key: 'currentStock', label: 'Current Stock', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'preorderCount', label: 'Preorders', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'notionsInvCount', label: 'Notions Inv.', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'configSafetyStock', label: 'Safety Stock', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'replenishmentUnits', label: 'Replenish Units', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'onOrderQty', label: 'On Order', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'earliestExpectedDate', label: 'Expected Date', group: 'Inventory' },
|
||||
{ key: 'isOldStock', label: 'Old Stock', group: 'Inventory' },
|
||||
{ key: 'overstockedUnits', label: 'Overstock Qty', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'stockoutDays30d', label: 'Stockout Days (30d)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'stockoutRate30d', label: 'Stockout Rate %', group: 'Inventory', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'avgStockUnits30d', label: 'Avg Stock Units (30d)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'receivedQty30d', label: 'Received Qty (30d)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'poCoverInDays', label: 'PO Cover (Days)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
|
||||
// Pricing & Costs
|
||||
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentStockRetail', label: 'Stock Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentStockGross', label: 'Stock Gross', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'onOrderCost', label: 'On Order Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'onOrderRetail', label: 'On Order Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'overstockedCost', label: 'Overstock Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'overstockedRetail', label: 'Overstock Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'avgStockCost30d', label: 'Avg Stock Cost (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'avgStockRetail30d', label: 'Avg Stock Retail (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'avgStockGross30d', label: 'Avg Stock Gross (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'receivedCost30d', label: 'Received Cost (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'replenishmentCost', label: 'Replenishment Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'replenishmentRetail', label: 'Replenishment Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'replenishmentProfit', label: 'Replenishment Profit', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
|
||||
// Dates & Timing
|
||||
{ key: 'dateFirstReceived', label: 'First Received', group: 'Dates' },
|
||||
{ key: 'dateLastReceived', label: 'Last Received', group: 'Dates' },
|
||||
{ key: 'dateFirstSold', label: 'First Sold', group: 'Dates' },
|
||||
{ key: 'dateLastSold', label: 'Last Sold', group: 'Dates' },
|
||||
{ key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'replenishDate', label: 'Replenish Date', group: 'Dates' },
|
||||
{ key: 'planningPeriodDays', label: 'Planning Period (Days)', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
|
||||
// Sales & Revenue
|
||||
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'revenue7d', label: 'Revenue (7d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v, product) => {
|
||||
if (v === null || v === undefined) return '-';
|
||||
const display = v === 0 ? '0' : v.toString();
|
||||
const growth = product?.salesGrowth30dVsPrev;
|
||||
if (growth === null || growth === undefined || growth === 0) return display;
|
||||
const isUp = growth > 0;
|
||||
const arrow = isUp ? '\u2191' : '\u2193';
|
||||
const color = isUp ? 'text-green-600' : 'text-red-600';
|
||||
return createElement('span', { className: 'inline-flex items-center gap-1.5' },
|
||||
createElement('span', null, display),
|
||||
createElement('span', { className: `text-[10px] ${color}` }, `${arrow}${Math.abs(growth).toFixed(0)}%`),
|
||||
);
|
||||
}},
|
||||
{ key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v, product) => {
|
||||
if (v === null || v === undefined) return '-';
|
||||
const display = v === 0 ? '0' : v.toFixed(2);
|
||||
const growth = product?.revenueGrowth30dVsPrev;
|
||||
if (growth === null || growth === undefined || growth === 0) return display;
|
||||
const isUp = growth > 0;
|
||||
const arrow = isUp ? '\u2191' : '\u2193';
|
||||
const color = isUp ? 'text-green-600' : 'text-red-600';
|
||||
return createElement('span', { className: 'inline-flex items-center gap-1.5' },
|
||||
createElement('span', null, display),
|
||||
createElement('span', { className: `text-[10px] ${color}` }, `${arrow}${Math.abs(growth).toFixed(0)}%`),
|
||||
);
|
||||
}},
|
||||
{ key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'avgSalesPerDay30d', label: 'Avg Sales/Day (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'avgSalesPerMonth30d', label: 'Avg Sales/Month (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'asp30d', label: 'ASP (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'acp30d', label: 'ACP (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'avgRos30d', label: 'Avg ROS (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'returnsUnits30d', label: 'Returns Units (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'returnsRevenue30d', label: 'Returns Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'discounts30d', label: 'Discounts (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'grossRevenue30d', label: 'Gross Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'grossRegularRevenue30d', label: 'Gross Regular Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'lifetimeSales', label: 'Lifetime Sales', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'lifetimeRevenue', label: 'Lifetime Revenue', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
|
||||
// Financial Performance
|
||||
{ key: 'cogs30d', label: 'COGS (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'profit30d', label: 'Profit (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'markup30d', label: 'Markup %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'returnRate30d', label: 'Return Rate %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'discountRate30d', label: 'Discount Rate %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'markdown30d', label: 'Markdown (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'markdownRate30d', label: 'Markdown Rate %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
|
||||
// Forecasting
|
||||
{ key: 'leadTimeForecastUnits', label: 'Lead Time Forecast Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'daysOfStockForecastUnits', label: 'Days of Stock Forecast Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'planningPeriodForecastUnits', label: 'Planning Period Forecast Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'leadTimeClosingStock', label: 'Lead Time Closing Stock', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'daysOfStockClosingStock', label: 'Days of Stock Closing Stock', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'replenishmentNeededRaw', label: 'Replenishment Needed Raw', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'forecastLostSalesUnits', label: 'Forecast Lost Sales Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'forecastLostRevenue', label: 'Forecast Lost Revenue', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
|
||||
// First Period Performance
|
||||
{ key: 'first7DaysSales', label: 'First 7 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'first7DaysRevenue', label: 'First 7 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'first30DaysSales', label: 'First 30 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'first30DaysRevenue', label: 'First 30 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'first60DaysSales', label: 'First 60 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'first60DaysRevenue', label: 'First 60 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'first90DaysSales', label: 'First 90 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'first90DaysRevenue', label: 'First 90 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
|
||||
// Growth Metrics — uses trendIndicator for colored arrows
|
||||
{ key: 'salesGrowth30dVsPrev', label: 'Sales Growth (30d)', group: 'Growth Analysis', format: trendIndicator },
|
||||
{ key: 'revenueGrowth30dVsPrev', label: 'Rev Growth (30d)', group: 'Growth Analysis', format: trendIndicator },
|
||||
{ key: 'salesGrowthYoy', label: 'Sales Growth YoY', group: 'Growth Analysis', format: trendIndicator },
|
||||
{ key: 'revenueGrowthYoy', label: 'Rev Growth YoY', group: 'Growth Analysis', format: trendIndicator },
|
||||
|
||||
// Demand Variability Metrics
|
||||
{ key: 'salesVariance30d', label: 'Sales Variance (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'salesStdDev30d', label: 'Sales Std Dev (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'salesCv30d', label: 'Sales CV % (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'demandPattern', label: 'Demand Pattern', group: 'Demand Variability' },
|
||||
|
||||
// Service Level Metrics
|
||||
{ key: 'fillRate30d', label: 'Fill Rate % (30d)', group: 'Service Level', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'stockoutIncidents30d', label: 'Stockout Incidents (30d)', group: 'Service Level', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'serviceLevel30d', label: 'Service Level % (30d)', group: 'Service Level', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'lostSalesIncidents30d', label: 'Lost Sales Incidents (30d)', group: 'Service Level', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
|
||||
// Seasonality Metrics
|
||||
{ key: 'seasonalityIndex', label: 'Seasonality Index', group: 'Seasonality', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'seasonalPattern', label: 'Seasonal Pattern', group: 'Seasonality' },
|
||||
{ key: 'peakSeason', label: 'Peak Season', group: 'Seasonality' },
|
||||
|
||||
// Quality Indicators
|
||||
{ key: 'lifetimeRevenueQuality', label: 'Lifetime Revenue Quality', group: 'Data Quality' },
|
||||
];
|
||||
|
||||
// Define default columns for each view
|
||||
export const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||
// General overview: identification + headline KPIs + trend
|
||||
all: [
|
||||
'imageUrl', 'title', 'sku', 'brand', 'status', 'abcClass', 'currentStock', 'currentPrice',
|
||||
'salesVelocityDaily', 'sales30d', 'revenue30d', 'profit30d', 'margin30d', 'stockCoverInDays',
|
||||
'salesGrowth30dVsPrev'
|
||||
],
|
||||
// Urgency-focused: what's out, how fast, what's coming, cost of inaction
|
||||
critical: [
|
||||
'status', 'imageUrl', 'title', 'vendor', 'currentStock', 'configSafetyStock', 'sellsOutInDays',
|
||||
'salesVelocityDaily', 'sales30d', 'replenishmentUnits', 'onOrderQty', 'earliestExpectedDate',
|
||||
'avgLeadTimeDays', 'stockoutDays30d', 'forecastLostRevenue'
|
||||
],
|
||||
// Purchase planning: what to order, how much, cost, supplier, timeline
|
||||
reorder: [
|
||||
'status', 'imageUrl', 'title', 'vendor', 'currentStock', 'sellsOutInDays', 'stockCoverInDays',
|
||||
'salesVelocityDaily', 'sales30d', 'replenishmentUnits', 'replenishmentCost', 'replenishDate',
|
||||
'currentCostPrice', 'moq', 'avgLeadTimeDays', 'onOrderQty'
|
||||
],
|
||||
// Excess inventory: how much, what it costs, is it moving, is it old
|
||||
overstocked: [
|
||||
'status', 'imageUrl', 'title', 'currentStock', 'overstockedUnits', 'overstockedCost',
|
||||
'currentStockCost', 'salesVelocityDaily', 'sales30d', 'stockCoverInDays', 'stockturn30d',
|
||||
'dateLastSold', 'isOldStock', 'salesGrowth30dVsPrev', 'margin30d'
|
||||
],
|
||||
// Early warnings: stock trajectory, is help on the way, what to do
|
||||
'at-risk': [
|
||||
'status', 'imageUrl', 'title', 'vendor', 'currentStock', 'stockCoverInDays', 'sellsOutInDays',
|
||||
'salesVelocityDaily', 'sales30d', 'salesGrowth30dVsPrev', 'onOrderQty', 'earliestExpectedDate',
|
||||
'replenishmentUnits', 'avgLeadTimeDays', 'fillRate30d'
|
||||
],
|
||||
// New product monitoring: early performance, benchmarks, engagement
|
||||
new: [
|
||||
'status', 'imageUrl', 'title', 'brand', 'currentStock', 'currentPrice',
|
||||
'salesVelocityDaily', 'sales30d', 'revenue30d', 'margin30d',
|
||||
'dateFirstReceived', 'ageDays', 'first7DaysSales', 'first30DaysSales', 'reviews'
|
||||
],
|
||||
// Performance monitoring: profitability, efficiency, trends
|
||||
healthy: [
|
||||
'status', 'imageUrl', 'title', 'abcClass', 'currentStock', 'stockCoverInDays',
|
||||
'salesVelocityDaily', 'sales30d', 'revenue30d', 'profit30d', 'margin30d',
|
||||
'gmroi30d', 'stockturn30d', 'sellThrough30d', 'salesGrowth30dVsPrev'
|
||||
],
|
||||
};
|
||||
|
||||
// Pre-computed column lookup map for O(1) access by key
|
||||
export const COLUMN_DEF_MAP = new Map<ProductMetricColumnKey, ColumnDef>(
|
||||
AVAILABLE_COLUMNS.map(col => [col.key, col])
|
||||
);
|
||||
|
||||
// Pre-computed columns grouped by their group property
|
||||
export const COLUMNS_BY_GROUP = AVAILABLE_COLUMNS.reduce((acc, col) => {
|
||||
if (!acc[col.group]) acc[col.group] = [];
|
||||
acc[col.group].push(col);
|
||||
return acc;
|
||||
}, {} as Record<string, ColumnDef[]>);
|
||||
+282
-542
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,7 @@ export interface ProductMetric {
|
||||
imageUrl: string | null;
|
||||
isVisible: boolean;
|
||||
isReplenishable: boolean;
|
||||
lastCalculated: string | null;
|
||||
|
||||
// Additional Product Fields
|
||||
barcode: string | null;
|
||||
@@ -334,11 +335,6 @@ export type ProductMetricColumnKey =
|
||||
| 'configDaysOfStock'
|
||||
| 'poCoverInDays'
|
||||
| 'toOrderUnits'
|
||||
| 'costPrice'
|
||||
| 'valueAtCost'
|
||||
| 'profit'
|
||||
| 'margin'
|
||||
| 'targetPrice'
|
||||
| 'replenishmentCost'
|
||||
| 'replenishmentRetail'
|
||||
| 'replenishmentProfit'
|
||||
@@ -347,20 +343,13 @@ export type ProductMetricColumnKey =
|
||||
| 'sales14d'
|
||||
| 'revenue14d'
|
||||
| 'sales30d'
|
||||
| 'units30d'
|
||||
| 'revenue30d'
|
||||
| 'sales365d'
|
||||
| 'revenue365d'
|
||||
| 'avgSalePrice30d'
|
||||
| 'avgDailySales30d'
|
||||
| 'avgDailyRevenue30d'
|
||||
| 'stockturnRate30d'
|
||||
| 'margin30d'
|
||||
| 'markup30d'
|
||||
| 'cogs30d'
|
||||
| 'profit30d'
|
||||
| 'roas30d'
|
||||
| 'adSpend30d'
|
||||
| 'gmroi30d'
|
||||
| 'stockturn30d'
|
||||
| 'sellThrough30d'
|
||||
@@ -421,100 +410,14 @@ export type ProductMetricColumnKey =
|
||||
| 'peakSeason'
|
||||
| 'imageUrl';
|
||||
|
||||
// Mapping frontend keys to backend query param keys
|
||||
export const FRONTEND_TO_BACKEND_KEY_MAP: Record<string, string> = {
|
||||
pid: 'pid',
|
||||
sku: 'sku',
|
||||
title: 'title',
|
||||
brand: 'brand',
|
||||
vendor: 'vendor',
|
||||
imageUrl: 'imageUrl',
|
||||
isVisible: 'isVisible',
|
||||
isReplenishable: 'isReplenishable',
|
||||
currentPrice: 'currentPrice',
|
||||
currentRegularPrice: 'currentRegularPrice',
|
||||
currentCostPrice: 'currentCostPrice',
|
||||
currentLandingCostPrice: 'currentLandingCostPrice',
|
||||
currentStock: 'currentStock',
|
||||
currentStockCost: 'currentStockCost',
|
||||
currentStockRetail: 'currentStockRetail',
|
||||
currentStockGross: 'currentStockGross',
|
||||
onOrderQty: 'onOrderQty',
|
||||
onOrderCost: 'onOrderCost',
|
||||
onOrderRetail: 'onOrderRetail',
|
||||
earliestExpectedDate: 'earliestExpectedDate',
|
||||
dateCreated: 'dateCreated',
|
||||
dateFirstReceived: 'dateFirstReceived',
|
||||
dateLastReceived: 'dateLastReceived',
|
||||
dateFirstSold: 'dateFirstSold',
|
||||
dateLastSold: 'dateLastSold',
|
||||
ageDays: 'ageDays',
|
||||
sales7d: 'sales7d',
|
||||
revenue7d: 'revenue7d',
|
||||
sales14d: 'sales14d',
|
||||
revenue14d: 'revenue14d',
|
||||
sales30d: 'sales30d',
|
||||
revenue30d: 'revenue30d',
|
||||
cogs30d: 'cogs30d',
|
||||
profit30d: 'profit30d',
|
||||
stockoutDays30d: 'stockoutDays30d',
|
||||
sales365d: 'sales365d',
|
||||
revenue365d: 'revenue365d',
|
||||
avgStockUnits30d: 'avgStockUnits30d',
|
||||
avgStockCost30d: 'avgStockCost30d',
|
||||
receivedQty30d: 'receivedQty30d',
|
||||
receivedCost30d: 'receivedCost30d',
|
||||
asp30d: 'asp30d',
|
||||
acp30d: 'acp30d',
|
||||
margin30d: 'margin30d',
|
||||
gmroi30d: 'gmroi30d',
|
||||
stockturn30d: 'stockturn30d',
|
||||
sellThrough30d: 'sellThrough30d',
|
||||
avgLeadTimeDays: 'avgLeadTimeDays',
|
||||
abcClass: 'abcClass',
|
||||
salesVelocityDaily: 'salesVelocityDaily',
|
||||
configLeadTime: 'configLeadTime',
|
||||
configDaysOfStock: 'configDaysOfStock',
|
||||
stockCoverInDays: 'stockCoverInDays',
|
||||
sellsOutInDays: 'sellsOutInDays',
|
||||
replenishDate: 'replenishDate',
|
||||
overstockedUnits: 'overstockedUnits',
|
||||
overstockedCost: 'overstockedCost',
|
||||
isOldStock: 'isOldStock',
|
||||
yesterdaySales: 'yesterdaySales',
|
||||
status: 'status', // Frontend-only field
|
||||
// New metrics from P3-P5 implementation
|
||||
salesGrowth30dVsPrev: 'salesGrowth30dVsPrev',
|
||||
revenueGrowth30dVsPrev: 'revenueGrowth30dVsPrev',
|
||||
salesGrowthYoy: 'salesGrowthYoy',
|
||||
revenueGrowthYoy: 'revenueGrowthYoy',
|
||||
salesVariance30d: 'salesVariance30d',
|
||||
salesStdDev30d: 'salesStdDev30d',
|
||||
salesCv30d: 'salesCv30d',
|
||||
demandPattern: 'demandPattern',
|
||||
fillRate30d: 'fillRate30d',
|
||||
stockoutIncidents30d: 'stockoutIncidents30d',
|
||||
serviceLevel30d: 'serviceLevel30d',
|
||||
lostSalesIncidents30d: 'lostSalesIncidents30d',
|
||||
seasonalityIndex: 'seasonalityIndex',
|
||||
seasonalPattern: 'seasonalPattern',
|
||||
peakSeason: 'peakSeason',
|
||||
lifetimeRevenueQuality: 'lifetimeRevenueQuality'
|
||||
};
|
||||
|
||||
// Function to get backend key safely
|
||||
export function getBackendKey(frontendKey: string): string | null {
|
||||
return FRONTEND_TO_BACKEND_KEY_MAP[frontendKey] || null;
|
||||
}
|
||||
|
||||
export type ActiveFilterValue = FilterValue | FilterValueWithOperator;
|
||||
|
||||
interface FilterValueWithOperator {
|
||||
export interface FilterValueWithOperator {
|
||||
value: FilterValue | string[] | number[];
|
||||
operator: ComparisonOperator;
|
||||
}
|
||||
|
||||
type FilterValue = string | number | boolean;
|
||||
export type FilterValue = string | number | boolean;
|
||||
|
||||
export type ComparisonOperator =
|
||||
| "=" | "!=" | ">" | ">=" | "<" | "<=" | "between"
|
||||
|
||||
@@ -1,109 +1,19 @@
|
||||
import { ProductMetric, ProductStatus } from "@/types/products";
|
||||
|
||||
//Calculates the product status based on various metrics
|
||||
|
||||
export function getProductStatus(product: ProductMetric): ProductStatus {
|
||||
if (!product.isReplenishable) {
|
||||
return "Healthy"; // Non-replenishable items default to Healthy
|
||||
}
|
||||
|
||||
const {
|
||||
currentStock,
|
||||
stockCoverInDays,
|
||||
sellsOutInDays,
|
||||
overstockedUnits,
|
||||
configLeadTime,
|
||||
avgLeadTimeDays,
|
||||
dateLastSold,
|
||||
ageDays,
|
||||
isOldStock
|
||||
} = product;
|
||||
|
||||
const leadTime = configLeadTime ?? avgLeadTimeDays ?? 30; // Default lead time if none configured
|
||||
const safetyThresholdDays = leadTime * 0.5; // Safety threshold is 50% of lead time
|
||||
|
||||
// Check for overstock first
|
||||
if (overstockedUnits != null && overstockedUnits > 0) {
|
||||
return "Overstock";
|
||||
}
|
||||
|
||||
// Check for critical stock
|
||||
if (stockCoverInDays != null) {
|
||||
// Stock is <= 0 or very low compared to lead time
|
||||
if (currentStock <= 0 || stockCoverInDays <= 0) {
|
||||
return "Critical";
|
||||
}
|
||||
if (stockCoverInDays < safetyThresholdDays) {
|
||||
return "Critical";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for products that will need reordering soon
|
||||
if (sellsOutInDays != null && sellsOutInDays < (leadTime + 7)) { // Within lead time + 1 week
|
||||
// If also critically low, keep Critical status
|
||||
if (stockCoverInDays != null && stockCoverInDays < safetyThresholdDays) {
|
||||
return "Critical";
|
||||
}
|
||||
return "Reorder Soon";
|
||||
}
|
||||
|
||||
// Check for 'At Risk' - e.g., old stock or hasn't sold in a long time
|
||||
const ninetyDaysAgo = new Date();
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
|
||||
|
||||
if (isOldStock) {
|
||||
return "At Risk";
|
||||
}
|
||||
|
||||
if (dateLastSold && new Date(dateLastSold) < ninetyDaysAgo && (ageDays ?? 0) > 180) {
|
||||
return "At Risk";
|
||||
}
|
||||
|
||||
// Very high stock cover (more than a year) is at risk too
|
||||
if (stockCoverInDays != null && stockCoverInDays > 365) {
|
||||
return "At Risk";
|
||||
}
|
||||
|
||||
// If none of the above, assume Healthy
|
||||
return "Healthy";
|
||||
}
|
||||
|
||||
//Returns a Badge component HTML string for a given product status
|
||||
export function getStatusBadge(status: ProductStatus): string {
|
||||
switch (status) {
|
||||
case 'Critical':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-red-600 text-white">Critical</div>';
|
||||
case 'Reorder Soon':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-secondary bg-yellow-500 text-black">Reorder Soon</div>';
|
||||
case 'Healthy':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-green-600 text-white">Healthy</div>';
|
||||
case 'Overstock':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-secondary bg-blue-600 text-white">Overstock</div>';
|
||||
case 'At Risk':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-orange-500 text-orange-600">At Risk</div>';
|
||||
case 'New':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-purple-600 text-white">New</div>';
|
||||
default:
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">Unknown</div>';
|
||||
}
|
||||
}
|
||||
|
||||
//Formatting utilities for displaying metrics
|
||||
// Formatting utilities for displaying metrics
|
||||
export const formatCurrency = (value: number | null | undefined, digits = 2): string => {
|
||||
if (value == null) return 'N/A';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
export const formatNumber = (value: number | null | undefined, digits = 0): string => {
|
||||
if (value == null) return 'N/A';
|
||||
return value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits
|
||||
return value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits
|
||||
});
|
||||
};
|
||||
|
||||
@@ -120,10 +30,10 @@ export const formatDays = (value: number | null | undefined, digits = 0): string
|
||||
export const formatDate = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
return 'Invalid Date';
|
||||
@@ -133,4 +43,4 @@ export const formatDate = (dateString: string | null | undefined): string => {
|
||||
export const formatBoolean = (value: boolean | null | undefined): string => {
|
||||
if (value == null) return 'N/A';
|
||||
return value ? 'Yes' : 'No';
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Shared utilities for transforming data between backend (snake_case) and frontend (camelCase) formats.
|
||||
*/
|
||||
|
||||
// Fields that should remain as strings even if they look numeric
|
||||
const STRING_FIELDS = new Set([
|
||||
'sku', 'title', 'brand', 'vendor', 'barcode',
|
||||
'vendor_reference', 'notions_reference', 'harmonized_tariff_code'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Converts a snake_case key to camelCase.
|
||||
* Handles numeric suffixes like sales_7d -> sales7d
|
||||
*/
|
||||
export function snakeToCamelCase(key: string): string {
|
||||
let result = key;
|
||||
// First handle cases like sales_7d -> sales7d
|
||||
result = result.replace(/_(\d+[a-z])/g, '$1');
|
||||
// Then handle regular snake_case -> camelCase
|
||||
result = result.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a single metrics row from snake_case DB format to camelCase frontend format.
|
||||
* Converts numeric strings to actual numbers except for known string fields and dates.
|
||||
*/
|
||||
export function transformMetricsRow(row: Record<string, any>): Record<string, any> {
|
||||
const transformed: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
const camelKey = snakeToCamelCase(key);
|
||||
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
value !== '' &&
|
||||
!isNaN(Number(value)) &&
|
||||
!key.toLowerCase().includes('date') &&
|
||||
!STRING_FIELDS.has(key)
|
||||
) {
|
||||
transformed[camelKey] = Number(value);
|
||||
} else {
|
||||
transformed[camelKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure pid is a number
|
||||
if (transformed.pid !== undefined) {
|
||||
transformed.pid = typeof transformed.pid === 'string'
|
||||
? parseInt(transformed.pid, 10)
|
||||
: transformed.pid;
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps frontend operator names to backend query param suffixes.
|
||||
*/
|
||||
export const OPERATOR_MAP: Record<string, string> = {
|
||||
'=': 'eq', '!=': 'ne', '>': 'gt', '>=': 'gte', '<': 'lt', '<=': 'lte',
|
||||
'between': 'between', 'in': 'in', 'not_in': 'ne',
|
||||
'contains': 'ilike', 'equals': 'eq',
|
||||
'starts_with': 'starts_with', 'ends_with': 'ends_with', 'not_contains': 'not_contains',
|
||||
'is_empty': 'is_empty', 'is_not_empty': 'is_not_empty',
|
||||
'is_true': 'is_true', 'is_false': 'is_false',
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user