Updates and fixes for products page

This commit is contained in:
2026-02-07 09:30:22 -05:00
parent b5469440bf
commit 8044771301
18 changed files with 1424 additions and 1274 deletions
@@ -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">
&middot; 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[]>);
File diff suppressed because it is too large Load Diff
+3 -100
View File
@@ -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"
+14 -104
View File
@@ -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';
};
};
+67
View File
@@ -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