- {isLoading ? (
+ {isLoadingProduct ? (
@@ -234,6 +223,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
+
+ Customer Engagement
+
+
+
+
+
+
+
@@ -248,7 +246,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
Stock Position
- } />
+
} />
@@ -264,6 +262,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
+
+ Service Level (30 Days)
+
+
+
+
+
+
+
@@ -288,36 +295,29 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
- {
- if (name === 'revenue' || name === 'profit') {
+ if (name === 'Revenue') {
return [formatCurrency(value), name];
}
return [value, name];
}}
/>
-
-
-
@@ -342,59 +342,16 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
- {/* Inventory KPIs Chart */}
-
- Key Inventory Metrics
-
-
- {isLoading ? (
-
- ) : (
-
-
-
-
-
- {
- if (name === 'Sell Through %' || name === 'Margin %') {
- return [`${value.toFixed(1)}%`, name];
- }
- return [value.toFixed(2), name];
- }}
- />
-
-
-
- )}
+ Growth Analysis
+
+
+
+
+
-
+
Inventory Performance (30 Days)
@@ -503,16 +460,18 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
) : 'N/A'
}
/>
- {
- 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'
- }
+ }
/>
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) {
+
+ Demand & Seasonality
+
+
+
+
+
+
+
+
+
+
+ Lifetime & First Period
+
+
+
+
+
+
+
+
+
+
) : null}
diff --git a/inventory/src/components/products/ProductFilters.tsx b/inventory/src/components/products/ProductFilters.tsx
index 2a6788c..ae0dec9 100644
--- a/inventory/src/components/products/ProductFilters.tsx
+++ b/inventory/src/components/products/ProductFilters.tsx
@@ -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}
- {/* 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 */}
@@ -606,8 +604,42 @@ export function ProductFilters({
{/* Select and Boolean types are handled via CommandList below */}
- {/* CommandList for Select and Boolean */}
- {(selectedFilter.type === 'select' || selectedFilter.type === 'boolean') && (
+ {/* Boolean type: show Yes/No buttons */}
+ {selectedFilter.type === 'boolean' && (
+
+
+
+
+ )}
+
+ {/* CommandList for Select type */}
+ {selectedFilter.type === 'select' && (
= 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 = {
+ 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({
+ 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 (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ const cards = getCardsForView(data, activeView);
+
+ return (
+
+ {cards.map((card) => (
+
+
+
{card.value}
+ {card.subValue && (
+
{card.subValue}
+ )}
+
+ ))}
+
+ );
+}
diff --git a/inventory/src/components/products/ProductTable.tsx b/inventory/src/components/products/ProductTable.tsx
index d67a5fb..26e6059 100644
--- a/inventory/src/components/products/ProductTable.tsx
+++ b/inventory/src/components/products/ProductTable.tsx
@@ -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 ;
+ return ;
}
// Special handling for boolean values
@@ -204,9 +196,7 @@ export function ProductTable({
{}}
>
{isLoading && (
@@ -226,7 +216,7 @@ export function ProductTable({
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 (
(
{orderedVisibleColumns.map(key => {
- const colDef = columnDefs.find(c => c.key === key);
+ const colDef = columnDefMap.get(key);
return (
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 (
- {PRODUCT_VIEWS.map((view) => (
-
-
- {view.label}
-
- ))}
+ {PRODUCT_VIEWS.map((view) => {
+ const count = viewCounts ? getCountForView(view.id, viewCounts) : null;
+ return (
+
+
+ {view.label}
+ {count !== null && (
+
+ {count.toLocaleString()}
+
+ )}
+
+ );
+ })}
)
diff --git a/inventory/src/components/products/Products.tsx b/inventory/src/components/products/Products.tsx
deleted file mode 100644
index 270d56e..0000000
--- a/inventory/src/components/products/Products.tsx
+++ /dev/null
@@ -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(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({
- 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) => {
- // 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 = {};
-
- if (filterType && filterValue) {
- filters[filterType] = filterValue;
- }
-
- if (searchQuery) {
- filters.search = searchQuery;
- }
-
- if (statusFilter) {
- filters.status = statusFilter;
- }
-
- return filters;
- }, [filterType, filterValue, searchQuery, statusFilter]);
-
- return (
-
-
-
Products
-
-
-
-
- {isLoading ? (
-
-
-
- Loading products...
-
-
- ) : error ? (
-
- Error loading products: {(error as Error).message}
-
- ) : (
-
- )}
-
-
-
- );
-}
\ No newline at end of file
diff --git a/inventory/src/components/products/StatusBadge.tsx b/inventory/src/components/products/StatusBadge.tsx
new file mode 100644
index 0000000..c48d2e5
--- /dev/null
+++ b/inventory/src/components/products/StatusBadge.tsx
@@ -0,0 +1,33 @@
+import { cn } from "@/lib/utils";
+
+const STATUS_STYLES: Record = {
+ '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 (
+
+ {displayStatus}
+
+ );
+}
diff --git a/inventory/src/components/products/columnDefinitions.ts b/inventory/src/components/products/columnDefinitions.ts
new file mode 100644
index 0000000..443a7f3
--- /dev/null
+++ b/inventory/src/components/products/columnDefinitions.ts
@@ -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 = {
+ // 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(
+ 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);
diff --git a/inventory/src/pages/Products.tsx b/inventory/src/pages/Products.tsx
index 4a395ff..4c98382 100644
--- a/inventory/src/pages/Products.tsx
+++ b/inventory/src/pages/Products.tsx
@@ -1,14 +1,15 @@
-import React, { useState, useEffect, useMemo } from 'react';
+import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { motion } from 'framer-motion';
-import { Settings2 } from 'lucide-react';
+import { Settings2, Search, X } from 'lucide-react';
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
-import {
- DropdownMenu,
- DropdownMenuContent,
+import {
+ DropdownMenu,
+ DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
@@ -29,320 +30,123 @@ import { ProductFilters } from "@/components/products/ProductFilters";
import { ProductDetail } from "@/components/products/ProductDetail";
import { ProductViews } from "@/components/products/ProductViews";
import { ProductTableSkeleton } from "@/components/products/ProductTableSkeleton";
+import { ProductSummaryCards } from "@/components/products/ProductSummaryCards";
import { useToast } from "@/hooks/use-toast";
+import { useDebounce } from "@/hooks/useDebounce";
+import { AVAILABLE_COLUMNS, VIEW_COLUMNS, COLUMNS_BY_GROUP } from "@/components/products/columnDefinitions";
+import { transformMetricsRow, OPERATOR_MAP } from "@/utils/transformUtils";
-// Column definition type
-interface ColumnDef {
- key: ProductMetricColumnKey;
- label: string;
- group: string;
- noLabel?: boolean;
- width?: string;
- format?: (value: any, product?: ProductMetric) => React.ReactNode;
+// --- ColumnToggle Component ---
+// Extracted as a proper component for stable React identity and correct internal state management.
+
+interface ColumnToggleProps {
+ visibleColumns: Set;
+ onColumnVisibilityChange: (column: ProductMetricColumnKey, isVisible: boolean) => void;
+ onResetToDefault: () => void;
}
-// Define available columns with their groups
-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() : '-' },
+function ColumnToggle({ visibleColumns, onColumnVisibilityChange, onResetToDefault }: ColumnToggleProps) {
+ const [open, setOpen] = React.useState(false);
- // 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) => {
- // Handle dimensions as separate length, width, height fields
- const length = product?.length;
- const width = product?.width;
- const height = product?.height;
- if (length && width && height) {
- return `${length}×${width}×${height}`;
+ return (
+
+
+
+
+ e.preventDefault()}
+ onPointerDownOutside={(e) => {
+ if (!(e.target as HTMLElement).closest('[role="dialog"]')) {
+ setOpen(false);
+ }
+ }}
+ onInteractOutside={(e) => {
+ if ((e.target as HTMLElement).closest('[role="dialog"]')) {
+ e.preventDefault();
+ }
+ }}
+ >
+
+ Toggle columns
+
+
+
+
+ {Object.entries(COLUMNS_BY_GROUP).map(([group, columns]) => (
+
+
+ {group}
+
+
+ {columns.map((column) => (
+ {
+ onColumnVisibilityChange(column.key, checked);
+ }}
+ onSelect={(e) => {
+ e.preventDefault();
+ }}
+ >
+ {column.label}
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
+
+// --- Helper: Transform frontend filter state to API query params ---
+
+function transformFilters(filters: Record): Record {
+ const transformedFilters: Record = {};
+
+ Object.entries(filters).forEach(([key, value]) => {
+ if (typeof value === 'object' && value !== null && 'operator' in value) {
+ const operatorSuffix = OPERATOR_MAP[value.operator] || 'eq';
+
+ // For between, send as comma-separated string (backend splits on comma)
+ if (value.operator === 'between' && Array.isArray(value.value)) {
+ transformedFilters[`${key}_${operatorSuffix}`] = value.value.join(',');
+ } else {
+ transformedFilters[`${key}_${operatorSuffix}`] = value.value;
+ }
+ } else {
+ // Simple values (from select/boolean filters) are passed as-is
+ transformedFilters[key] = value;
}
- 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) => v === 0 ? '0' : v ? v.toString() : '-' },
- { key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
- { 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
- { key: 'salesGrowth30dVsPrev', label: 'Sales Growth % (30d vs Prev)', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
- { key: 'revenueGrowth30dVsPrev', label: 'Revenue Growth % (30d vs Prev)', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
- { key: 'salesGrowthYoy', label: 'Sales Growth % YoY', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
- { key: 'revenueGrowthYoy', label: 'Revenue Growth % YoY', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
-
- // 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
-const VIEW_COLUMNS: Record = {
- all: [
- 'imageUrl',
- 'title',
- 'brand',
- 'status',
- 'currentStock',
- 'currentPrice',
- 'salesVelocityDaily',
- 'sales30d',
- 'revenue30d',
- 'profit30d',
- 'stockCoverInDays',
- 'currentStockCost',
- 'salesGrowth30dVsPrev'
- ],
- critical: [
- 'status',
- 'imageUrl',
- 'title',
- 'currentStock',
- 'configSafetyStock',
- 'replenishmentUnits',
- 'salesVelocityDaily',
- 'sales7d',
- 'sales30d',
- 'onOrderQty',
- 'earliestExpectedDate',
- 'vendor',
- 'dateLastReceived',
- 'avgLeadTimeDays',
- 'serviceLevel30d',
- 'stockoutIncidents30d'
- ],
- reorder: [
- 'status',
- 'imageUrl',
- 'title',
- 'currentStock',
- 'configSafetyStock',
- 'replenishmentUnits',
- 'salesVelocityDaily',
- 'sellsOutInDays',
- 'currentCostPrice',
- 'sales30d',
- 'vendor',
- 'avgLeadTimeDays',
- 'dateLastReceived',
- 'demandPattern'
- ],
- overstocked: [
- 'status',
- 'imageUrl',
- 'title',
- 'currentStock',
- 'overstockedUnits',
- 'sales7d',
- 'sales30d',
- 'salesVelocityDaily',
- 'stockCoverInDays',
- 'stockturn30d',
- 'currentStockCost',
- 'overstockedCost',
- 'dateLastSold',
- 'salesVariance30d'
- ],
- 'at-risk': [
- 'status',
- 'imageUrl',
- 'title',
- 'currentStock',
- 'configSafetyStock',
- 'salesVelocityDaily',
- 'sales7d',
- 'sales30d',
- 'stockCoverInDays',
- 'sellsOutInDays',
- 'dateLastSold',
- 'avgLeadTimeDays',
- 'profit30d',
- 'fillRate30d',
- 'salesGrowth30dVsPrev'
- ],
- new: [
- 'status',
- 'imageUrl',
- 'title',
- 'currentStock',
- 'salesVelocityDaily',
- 'sales7d',
- 'vendor',
- 'brand',
- 'currentPrice',
- 'currentCostPrice',
- 'dateFirstReceived',
- 'ageDays',
- 'abcClass',
- 'first7DaysSales',
- 'first30DaysSales'
- ],
- healthy: [
- 'status',
- 'imageUrl',
- 'title',
- 'currentStock',
- 'stockCoverInDays',
- 'salesVelocityDaily',
- 'sales30d',
- 'revenue30d',
- 'profit30d',
- 'margin30d',
- 'gmroi30d',
- 'stockturn30d',
- 'salesGrowth30dVsPrev',
- 'serviceLevel30d'
- ],
-};
+ return transformedFilters;
+}
+
+// --- Main Products Page Component ---
export function Products() {
const [searchParams, setSearchParams] = useSearchParams();
const [filters, setFilters] = useState>({});
+ const [searchQuery, setSearchQuery] = useState('');
+ const debouncedSearch = useDebounce(searchQuery, 300);
const [sortColumn, setSortColumn] = useState('title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// Track last sort direction for each column
@@ -353,10 +157,11 @@ export function Products() {
const [activeView, setActiveView] = useState(searchParams.get('view') || "all");
const [pageSize] = useState(50);
const [showNonReplenishable, setShowNonReplenishable] = useState(false);
+ const [showInvisible, setShowInvisible] = useState(false);
const [selectedProductId, setSelectedProductId] = useState(null);
- const [, setIsLoading] = useState(false);
const { toast } = useToast();
-
+ const searchInputRef = React.useRef(null);
+
// Store visible columns and order for each view
const [viewColumns, setViewColumns] = useState>>(() => {
const initialColumns: Record> = {};
@@ -365,7 +170,7 @@ export function Products() {
});
return initialColumns;
});
-
+
const [viewColumnOrder, setViewColumnOrder] = useState>(() => {
const initialOrder: Record = {};
Object.entries(VIEW_COLUMNS).forEach(([view, defaultColumns]) => {
@@ -377,40 +182,49 @@ export function Products() {
return initialOrder;
});
- // Get current view's columns
+ // Get current view's columns, auto-adding indicator columns when toggles are active
const visibleColumns = useMemo(() => {
const columns = new Set(viewColumns[activeView] || VIEW_COLUMNS.all);
-
+
// Add isReplenishable column when showing non-replenishable products for better visibility
if (showNonReplenishable) {
columns.add('isReplenishable');
}
-
+ // Add isVisible column when showing invisible products so users can distinguish them
+ if (showInvisible) {
+ columns.add('isVisible');
+ }
+
return columns;
- }, [viewColumns, activeView, showNonReplenishable]);
+ }, [viewColumns, activeView, showNonReplenishable, showInvisible]);
const columnOrder = viewColumnOrder[activeView] || viewColumnOrder.all;
// Handle column visibility changes
- const handleColumnVisibilityChange = (column: ProductMetricColumnKey, isVisible: boolean) => {
+ const handleColumnVisibilityChange = useCallback((column: ProductMetricColumnKey, isVisible: boolean) => {
setViewColumns(prev => ({
...prev,
- [activeView]: isVisible
+ [activeView]: isVisible
? new Set([...prev[activeView], column])
: new Set([...prev[activeView]].filter(col => col !== column))
}));
- };
+ }, [activeView]);
- // Handle column order changes
- const handleColumnOrderChange = (newOrder: ProductMetricColumnKey[]) => {
- setViewColumnOrder(prev => ({
- ...prev,
- [activeView]: newOrder
- }));
+ // Handle column order changes (newVisibleOrder only contains visible columns from drag reorder)
+ const handleColumnOrderChange = (newVisibleOrder: ProductMetricColumnKey[]) => {
+ setViewColumnOrder(prev => {
+ const oldFullOrder = prev[activeView] || [];
+ // Append hidden columns (preserving their relative order) after the reordered visible columns
+ const hiddenColumns = oldFullOrder.filter(col => !visibleColumns.has(col));
+ return {
+ ...prev,
+ [activeView]: [...newVisibleOrder, ...hiddenColumns]
+ };
+ });
};
// Reset columns to default for current view
- const resetColumnsToDefault = () => {
+ const resetColumnsToDefault = useCallback(() => {
setViewColumns(prev => ({
...prev,
[activeView]: new Set(VIEW_COLUMNS[activeView] || VIEW_COLUMNS.all)
@@ -423,145 +237,73 @@ export function Products() {
.filter(key => !(VIEW_COLUMNS[activeView] || VIEW_COLUMNS.all).includes(key))
]
}));
- };
+ }, [activeView]);
- // Function to fetch products data
- const transformFilters = (filters: Record) => {
- const transformedFilters: Record = {};
-
- Object.entries(filters).forEach(([key, value]) => {
- if (typeof value === 'object' && 'operator' in value) {
- // Convert the operator format to match what the backend expects
- // Backend expects keys like "sales30d_gt" instead of separate operator parameters
- const operatorSuffix = value.operator === '=' ? 'eq' :
- value.operator === '>' ? 'gt' :
- value.operator === '>=' ? 'gte' :
- value.operator === '<' ? 'lt' :
- value.operator === '<=' ? 'lte' :
- value.operator === 'between' ? 'between' : 'eq';
-
- // Create a key with the correct suffix format: key_operator
- transformedFilters[`${key}_${operatorSuffix}`] = value.value;
- } else {
- // Simple values are passed as-is
- transformedFilters[key] = value;
- }
- });
-
- return transformedFilters;
- };
+ // Merge search query into filters for the API call
+ const effectiveFilters = useMemo(() => {
+ const merged = { ...filters };
+ if (debouncedSearch.trim()) {
+ merged['title'] = { value: debouncedSearch.trim(), operator: 'contains' as const };
+ }
+ return merged;
+ }, [filters, debouncedSearch]);
- const fetchProducts = async () => {
- setIsLoading(true);
+ // Fetch products data with stable callback reference
+ const fetchProducts = useCallback(async () => {
try {
const params = new URLSearchParams();
params.append('page', currentPage.toString());
params.append('limit', pageSize.toString());
-
+
if (sortColumn) {
- // Don't convert camelCase to snake_case - use the column name directly
- // as defined in the backend's COLUMN_MAP
- console.log(`Sorting: ${sortColumn} (${sortDirection})`);
params.append('sort', sortColumn);
params.append('order', sortDirection);
}
-
+
if (activeView && activeView !== 'all') {
- const stockStatus = activeView === 'at-risk' ? 'At Risk' :
- activeView === 'reorder' ? 'Reorder Soon' :
- activeView === 'overstocked' ? 'Overstock' :
+ const stockStatus = activeView === 'at-risk' ? 'At Risk' :
+ activeView === 'reorder' ? 'Reorder Soon' :
+ activeView === 'overstocked' ? 'Overstock' :
activeView === 'new' ? 'New' :
activeView.charAt(0).toUpperCase() + activeView.slice(1);
-
- console.log(`View: ${activeView} → Stock Status: ${stockStatus}`);
params.append('stock_status', stockStatus);
}
// Transform filters to match API expectations
- const transformedFilters = transformFilters(filters);
+ const transformedFilters = transformFilters(effectiveFilters);
Object.entries(transformedFilters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
- // Don't convert camelCase to snake_case - use the filter name directly
if (Array.isArray(value)) {
- params.append(key, JSON.stringify(value));
+ params.append(key, value.join(','));
} else {
params.append(key, value.toString());
}
}
});
- if (!showNonReplenishable) {
- params.append('showNonReplenishable', 'false');
+ if (showNonReplenishable) {
+ params.append('showNonReplenishable', 'true');
}
- // Log the final query parameters for debugging
- console.log('API Query:', params.toString());
+ if (showInvisible) {
+ params.append('showInvisible', 'true');
+ }
- const response = await fetch(`/api/metrics?${params.toString()}`);
+ const response = await fetch(`/api/metrics?${params.toString()}`, {credentials: 'include'});
if (!response.ok) throw new Error('Failed to fetch products');
-
+
const data = await response.json();
-
- // Transform snake_case keys to camelCase and convert string numbers to actual numbers
- const transformedProducts = data.metrics?.map((product: any) => {
- const transformed: any = {};
-
- // Process all keys to convert from snake_case to camelCase
- Object.entries(product).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, but handle empty strings properly
- if (typeof value === 'string' && value !== '' && !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;
-
- return transformed;
- }) || [];
-
- // Debug: Log the first item to check field mapping
- if (transformedProducts.length > 0) {
- console.log('Sample product after transformation:');
- console.log('sales7d:', transformedProducts[0].sales7d);
- console.log('sales30d:', transformedProducts[0].sales30d);
- console.log('revenue30d:', transformedProducts[0].revenue30d);
- console.log('margin30d:', transformedProducts[0].margin30d);
- console.log('markup30d:', transformedProducts[0].markup30d);
-
- // Debug specific fields with issues
- console.log('configSafetyStock:', transformedProducts[0].configSafetyStock);
- console.log('length:', transformedProducts[0].length);
- console.log('width:', transformedProducts[0].width);
- console.log('height:', transformedProducts[0].height);
- console.log('first7DaysSales:', transformedProducts[0].first7DaysSales);
- console.log('first30DaysSales:', transformedProducts[0].first30DaysSales);
- console.log('first7DaysRevenue:', transformedProducts[0].first7DaysRevenue);
- console.log('first30DaysRevenue:', transformedProducts[0].first30DaysRevenue);
- }
-
- // Transform the metrics response to match our expected format
+
+ // Use shared transform utility instead of inline conversion
+ const transformedProducts = data.metrics?.map((product: any) => transformMetricsRow(product)) || [];
+
return {
products: transformedProducts,
- pagination: data.pagination || {
- total: 0,
+ pagination: data.pagination || {
+ total: 0,
pages: 0,
- currentPage: 1,
- limit: pageSize
+ currentPage: 1,
+ limit: pageSize
},
filters: data.appliedQuery?.filters || {}
};
@@ -573,10 +315,8 @@ export function Products() {
variant: "destructive",
});
return null;
- } finally {
- setIsLoading(false);
}
- };
+ }, [currentPage, pageSize, sortColumn, sortDirection, activeView, effectiveFilters, showNonReplenishable, showInvisible, toast]);
// Query for filter options
const { data: filterOptionsData, isLoading: isLoadingFilterOptions } = useQuery({
@@ -586,7 +326,7 @@ export function Products() {
const response = await fetch('/api/metrics/filter-options');
if (!response.ok) throw new Error('Failed to fetch filter options');
const data = await response.json();
-
+
// Ensure we have the expected structure with correct casing
return {
vendors: data.vendors || [],
@@ -601,13 +341,32 @@ export function Products() {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
+ // Query for summary data (powers KPI cards and view counts)
+ const { data: summaryData } = useQuery({
+ 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,
+ });
+
// Query for products data
const { data, isFetching } = useQuery({
- queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, filters, showNonReplenishable],
+ queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, effectiveFilters, showNonReplenishable, showInvisible],
queryFn: fetchProducts,
placeholderData: keepPreviousData,
});
+ // Reset page when debounced search changes
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [debouncedSearch]);
+
// Update current page if it exceeds the total pages
useEffect(() => {
if (data?.pagination.pages && currentPage > data.pagination.pages) {
@@ -615,10 +374,22 @@ export function Products() {
}
}, [currentPage, data?.pagination.pages]);
+ // Global keyboard shortcut: "/" to focus search
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement).tagName)) {
+ e.preventDefault();
+ searchInputRef.current?.focus();
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, []);
+
// Handle sort column change with improved column-specific direction memory
const handleSort = (column: ProductMetricColumnKey) => {
let nextDirection: 'asc' | 'desc';
-
+
if (sortColumn === column) {
// If clicking the same column, toggle direction
nextDirection = sortDirection === 'asc' ? 'desc' : 'asc';
@@ -627,28 +398,28 @@ export function Products() {
// 1. If this column has been sorted before, use the stored direction
// 2. Otherwise use a sensible default (asc for text, desc for numeric columns)
const prevDirection = columnSortDirections[column];
-
+
if (prevDirection) {
// Use the stored direction
nextDirection = prevDirection;
} else {
// Determine sensible default based on column type
const columnDef = AVAILABLE_COLUMNS.find(c => c.key === column);
- const isNumeric = columnDef?.group === 'Sales' ||
- columnDef?.group === 'Financial' ||
- columnDef?.group === 'Stock' ||
+ const isNumeric = columnDef?.group === 'Sales' ||
+ columnDef?.group === 'Financial' ||
+ columnDef?.group === 'Stock' ||
['currentPrice', 'currentRegularPrice', 'currentCostPrice', 'currentStock'].includes(column);
-
+
// Start with descending for numeric columns (to see highest values first)
// Start with ascending for text columns (alphabetical order)
nextDirection = isNumeric ? 'desc' : 'asc';
}
}
-
+
// Update the current sort state
setSortDirection(nextDirection);
setSortColumn(column);
-
+
// Remember this column's sort direction for next time
setColumnSortDirections(prev => ({
...prev,
@@ -664,6 +435,7 @@ export function Products() {
const handleClearFilters = () => {
setFilters({});
+ setSearchQuery('');
setCurrentPage(1);
};
@@ -673,89 +445,6 @@ export function Products() {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
- // Group columns by their group property
- const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
- if (!acc[col.group]) {
- acc[col.group] = [];
- }
- acc[col.group].push(col);
- return acc;
- }, {} as Record);
-
- const renderColumnToggle = () => {
- const [open, setOpen] = React.useState(false);
-
- return (
-
-
-
-
- e.preventDefault()}
- onPointerDownOutside={(e) => {
- // Only close if clicking outside the dropdown
- if (!(e.target as HTMLElement).closest('[role="dialog"]')) {
- setOpen(false);
- }
- }}
- onInteractOutside={(e) => {
- // Prevent closing when interacting with checkboxes
- if ((e.target as HTMLElement).closest('[role="dialog"]')) {
- e.preventDefault();
- }
- }}
- >
-
- Toggle columns
-
-
-
-
- {Object.entries(columnsByGroup).map(([group, columns]) => (
-
-
- {group}
-
-
- {columns.map((column) => (
- {
- handleColumnVisibilityChange(column.key, checked);
- }}
- onSelect={(e) => {
- e.preventDefault();
- }}
- >
- {column.label}
-
- ))}
-
-
- ))}
-
-
-
- );
- };
-
// Calculate pagination numbers
const totalPages = data?.pagination.pages || 1;
const showEllipsis = totalPages > 7;
@@ -767,10 +456,20 @@ export function Products() {
: [currentPage - 2, currentPage - 1, currentPage, currentPage + 1, currentPage + 2]
: Array.from({ length: totalPages }, (_, i) => i + 1);
- // Update URL when view changes
+ // Update URL when view changes, reset filters and conditionally reset sort
const handleViewChange = (view: string) => {
setActiveView(view);
setCurrentPage(1);
+ setFilters({}); // Clear filters when switching views to avoid confusion
+ setSearchQuery(''); // Clear search when switching views
+
+ // Reset sort if the current sort column isn't visible in the new view
+ const newViewColumns = VIEW_COLUMNS[view] || VIEW_COLUMNS.all;
+ if (!newViewColumns.includes(sortColumn)) {
+ setSortColumn('title');
+ setSortDirection('asc');
+ }
+
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
newParams.set('view', view);
@@ -784,6 +483,7 @@ export function Products() {
if (viewParam && viewParam !== activeView) {
setActiveView(viewParam);
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
return (
@@ -795,11 +495,37 @@ export function Products() {
>
-
+
+
@@ -827,12 +553,27 @@ export function Products() {
/>
+
+ {
+ setShowInvisible(checked);
+ setCurrentPage(1);
+ }}
+ />
+
+
{data?.pagination?.total !== undefined && (
{data.pagination.total.toLocaleString()} products
)}
- {renderColumnToggle()}
+