Tweaks and fixes for products table

This commit is contained in:
2025-04-05 09:52:36 -04:00
parent d081a60662
commit c9b656d34b
10 changed files with 788 additions and 310 deletions

View File

@@ -53,6 +53,28 @@ CREATE TABLE public.product_metrics (
image_url VARCHAR, -- (e.g., products.image_175) image_url VARCHAR, -- (e.g., products.image_175)
is_visible BOOLEAN, is_visible BOOLEAN,
is_replenishable BOOLEAN, is_replenishable BOOLEAN,
-- Additional product fields
barcode VARCHAR,
harmonized_tariff_code VARCHAR,
vendor_reference VARCHAR,
notions_reference VARCHAR,
line VARCHAR,
subline VARCHAR,
artist VARCHAR,
moq INT,
rating NUMERIC(10, 2),
reviews INT,
weight NUMERIC(14, 4),
length NUMERIC(14, 4),
width NUMERIC(14, 4),
height NUMERIC(14, 4),
country_of_origin VARCHAR,
location VARCHAR,
baskets INT,
notifies INT,
preorder_count INT,
notions_inv_count INT,
-- Current Status (Refreshed Hourly) -- Current Status (Refreshed Hourly)
current_price NUMERIC(10, 2), current_price NUMERIC(10, 2),

View File

@@ -8,7 +8,29 @@ dotenv.config({ path: path.join(__dirname, "../../.env") });
// Utility functions // Utility functions
const imageUrlBase = process.env.PRODUCT_IMAGE_URL_BASE || 'https://sbing.com/i/products/0000/'; const imageUrlBase = process.env.PRODUCT_IMAGE_URL_BASE || 'https://sbing.com/i/products/0000/';
const getImageUrls = (pid, iid = 1) => {
// Modified to accept a db connection for querying product_images
const getImageUrls = async (pid, prodConnection, iid = null) => {
// If iid isn't provided, try to get it from product_images
if (iid === null && prodConnection) {
try {
// Query for images with order=255 (default/primary images)
const [primaryImages] = await prodConnection.query(
'SELECT iid FROM product_images WHERE pid = ? AND `order` = 255 LIMIT 1',
[pid]
);
// Use the found iid or default to 1
iid = primaryImages.length > 0 ? primaryImages[0].iid : 1;
} catch (error) {
console.error(`Error fetching primary image for pid ${pid}:`, error);
iid = 1; // Fallback to default
}
} else {
// Use default if connection not provided
iid = iid || 1;
}
const paddedPid = pid.toString().padStart(6, '0'); const paddedPid = pid.toString().padStart(6, '0');
// Use padded PID only for the first 3 digits // Use padded PID only for the first 3 digits
const prefix = paddedPid.slice(0, 3); const prefix = paddedPid.slice(0, 3);
@@ -237,9 +259,11 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`; return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
}).join(','); }).join(',');
const values = batch.flatMap(row => { // Process image URLs for the batch
const imageUrls = getImageUrls(row.pid); const processedValues = [];
return [ for (const row of batch) {
const imageUrls = await getImageUrls(row.pid, prodConnection);
processedValues.push([
row.pid, row.pid,
row.title, row.title,
row.description, row.description,
@@ -287,8 +311,10 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
imageUrls.image_full, imageUrls.image_full,
null, null,
null null
]; ]);
}); }
const values = processedValues.flat();
const [result] = await localConnection.query(` const [result] = await localConnection.query(`
WITH inserted_products AS ( WITH inserted_products AS (
@@ -442,9 +468,11 @@ async function materializeCalculations(prodConnection, localConnection, incremen
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`; return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
}).join(','); }).join(',');
const values = batch.flatMap(row => { // Process image URLs for the batch
const imageUrls = getImageUrls(row.pid); const processedValues = [];
return [ for (const row of batch) {
const imageUrls = await getImageUrls(row.pid, prodConnection);
processedValues.push([
row.pid, row.pid,
row.title, row.title,
row.description, row.description,
@@ -492,8 +520,10 @@ async function materializeCalculations(prodConnection, localConnection, incremen
imageUrls.image_full, imageUrls.image_full,
null, null,
null null
]; ]);
}); }
const values = processedValues.flat();
await localConnection.query(` await localConnection.query(`
INSERT INTO temp_products ( INSERT INTO temp_products (
@@ -665,9 +695,11 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`; return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
}).join(','); }).join(',');
const values = batch.flatMap(row => { // Process image URLs for the batch
const imageUrls = getImageUrls(row.pid); const processedValues = [];
return [ for (const row of batch) {
const imageUrls = await getImageUrls(row.pid, prodConnection);
processedValues.push([
row.pid, row.pid,
row.title, row.title,
row.description, row.description,
@@ -715,8 +747,10 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
imageUrls.image_full, imageUrls.image_full,
row.options, row.options,
row.tags row.tags
]; ]);
}); }
const values = processedValues.flat();
const [result] = await localConnection.query(` const [result] = await localConnection.query(`
WITH upserted AS ( WITH upserted AS (

View File

@@ -28,6 +28,27 @@ BEGIN
COALESCE(p.image_175, p.image) as image_url, COALESCE(p.image_175, p.image) as image_url,
p.visible as is_visible, p.visible as is_visible,
p.replenishable as is_replenishable, p.replenishable as is_replenishable,
-- Add new product fields
p.barcode,
p.harmonized_tariff_code,
p.vendor_reference,
p.notions_reference,
p.line,
p.subline,
p.artist,
p.moq,
p.rating,
p.reviews,
p.weight,
p.length,
p.width,
p.height,
p.country_of_origin,
p.location,
p.baskets,
p.notifies,
p.preorder_count,
p.notions_inv_count,
COALESCE(p.price, 0.00) as current_price, COALESCE(p.price, 0.00) as current_price,
COALESCE(p.regular_price, 0.00) as current_regular_price, COALESCE(p.regular_price, 0.00) as current_regular_price,
COALESCE(p.cost_price, 0.00) as current_cost_price, COALESCE(p.cost_price, 0.00) as current_cost_price,
@@ -36,7 +57,6 @@ BEGIN
p.created_at, p.created_at,
p.first_received, p.first_received,
p.date_last_sold, p.date_last_sold,
p.moq,
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each) p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
FROM public.products p FROM public.products p
), ),
@@ -185,6 +205,9 @@ BEGIN
-- Final UPSERT into product_metrics -- Final UPSERT into product_metrics
INSERT INTO public.product_metrics ( INSERT INTO public.product_metrics (
pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable, pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable,
barcode, harmonized_tariff_code, vendor_reference, notions_reference, line, subline, artist,
moq, rating, reviews, weight, length, width, height, country_of_origin, location,
baskets, notifies, preorder_count, notions_inv_count,
current_price, current_regular_price, current_cost_price, current_landing_cost_price, current_price, current_regular_price, current_cost_price, current_landing_cost_price,
current_stock, current_stock_cost, current_stock_retail, current_stock_gross, current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date, on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
@@ -214,6 +237,9 @@ BEGIN
) )
SELECT SELECT
ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.is_replenishable, ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.is_replenishable,
ci.barcode, ci.harmonized_tariff_code, ci.vendor_reference, ci.notions_reference, ci.line, ci.subline, ci.artist,
ci.moq, ci.rating, ci.reviews, ci.weight, ci.length, ci.width, ci.height, ci.country_of_origin, ci.location,
ci.baskets, ci.notifies, ci.preorder_count, ci.notions_inv_count,
ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost, ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost,
ci.current_stock, ci.current_stock * ci.current_effective_cost, ci.current_stock * ci.current_price, ci.current_stock * ci.current_regular_price, ci.current_stock, ci.current_stock * ci.current_effective_cost, ci.current_stock * ci.current_price, ci.current_stock * ci.current_regular_price,
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00), COALESCE(ooi.on_order_qty, 0) * ci.current_price, ooi.earliest_expected_date, COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00), COALESCE(ooi.on_order_qty, 0) * ci.current_price, ooi.earliest_expected_date,
@@ -694,6 +720,9 @@ BEGIN
ON CONFLICT (pid) DO UPDATE SET ON CONFLICT (pid) DO UPDATE SET
last_calculated = EXCLUDED.last_calculated, last_calculated = EXCLUDED.last_calculated,
sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable, sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable,
barcode = EXCLUDED.barcode, harmonized_tariff_code = EXCLUDED.harmonized_tariff_code, vendor_reference = EXCLUDED.vendor_reference, notions_reference = EXCLUDED.notions_reference, line = EXCLUDED.line, subline = EXCLUDED.subline, artist = EXCLUDED.artist,
moq = EXCLUDED.moq, rating = EXCLUDED.rating, reviews = EXCLUDED.reviews, weight = EXCLUDED.weight, length = EXCLUDED.length, width = EXCLUDED.width, height = EXCLUDED.height, country_of_origin = EXCLUDED.country_of_origin, location = EXCLUDED.location,
baskets = EXCLUDED.baskets, notifies = EXCLUDED.notifies, preorder_count = EXCLUDED.preorder_count, notions_inv_count = EXCLUDED.notions_inv_count,
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price, current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price,
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross, current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date, on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,

View File

@@ -19,6 +19,27 @@ const COLUMN_MAP = {
imageUrl: 'pm.image_url', imageUrl: 'pm.image_url',
isVisible: 'pm.is_visible', isVisible: 'pm.is_visible',
isReplenishable: 'pm.is_replenishable', isReplenishable: 'pm.is_replenishable',
// Additional Product Fields
barcode: 'pm.barcode',
harmonizedTariffCode: 'pm.harmonized_tariff_code',
vendorReference: 'pm.vendor_reference',
notionsReference: 'pm.notions_reference',
line: 'pm.line',
subline: 'pm.subline',
artist: 'pm.artist',
moq: 'pm.moq',
rating: 'pm.rating',
reviews: 'pm.reviews',
weight: 'pm.weight',
length: 'pm.length',
width: 'pm.width',
height: 'pm.height',
countryOfOrigin: 'pm.country_of_origin',
location: 'pm.location',
baskets: 'pm.baskets',
notifies: 'pm.notifies',
preorderCount: 'pm.preorder_count',
notionsInvCount: 'pm.notions_inv_count',
// Current Status // Current Status
currentPrice: 'pm.current_price', currentPrice: 'pm.current_price',
currentRegularPrice: 'pm.current_regular_price', currentRegularPrice: 'pm.current_regular_price',
@@ -125,50 +146,49 @@ const COLUMN_MAP = {
status: 'pm.status' status: 'pm.status'
}; };
// Map of column types for proper sorting // Define column types for use in sorting/filtering
// This helps apply correct comparison operators and sorting logic
const COLUMN_TYPES = { const COLUMN_TYPES = {
// Numeric columns // Numeric columns (use numeric operators and sorting)
pid: 'number', numeric: [
currentPrice: 'number', 'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice', 'currentLandingCostPrice',
currentRegularPrice: 'number', 'currentStock', 'currentStockCost', 'currentStockRetail', 'currentStockGross',
currentCostPrice: 'number', 'onOrderQty', 'onOrderCost', 'onOrderRetail', 'ageDays',
currentLandingCostPrice: 'number', 'sales7d', 'revenue7d', 'sales14d', 'revenue14d', 'sales30d', 'revenue30d',
currentStock: 'number', 'cogs30d', 'profit30d', 'returnsUnits30d', 'returnsRevenue30d', 'discounts30d',
currentStockCost: 'number', 'grossRevenue30d', 'grossRegularRevenue30d', 'stockoutDays30d', 'sales365d', 'revenue365d',
currentStockRetail: 'number', 'avgStockUnits30d', 'avgStockCost30d', 'avgStockRetail30d', 'avgStockGross30d',
currentStockGross: 'number', 'receivedQty30d', 'receivedCost30d', 'lifetimeSales', 'lifetimeRevenue',
onOrderQty: 'number', 'first7DaysSales', 'first7DaysRevenue', 'first30DaysSales', 'first30DaysRevenue',
onOrderCost: 'number', 'first60DaysSales', 'first60DaysRevenue', 'first90DaysSales', 'first90DaysRevenue',
onOrderRetail: 'number', 'asp30d', 'acp30d', 'avgRos30d', 'avgSalesPerDay30d', 'avgSalesPerMonth30d',
ageDays: 'number', 'margin30d', 'markup30d', 'gmroi30d', 'stockturn30d', 'returnRate30d', 'discountRate30d',
sales7d: 'number', 'stockoutRate30d', 'markdown30d', 'markdownRate30d', 'sellThrough30d', 'avgLeadTimeDays',
revenue7d: 'number', 'salesVelocityDaily', 'configLeadTime', 'configDaysOfStock', 'configSafetyStock',
sales14d: 'number', 'planningPeriodDays', 'leadTimeForecastUnits', 'daysOfStockForecastUnits',
revenue14d: 'number', 'planningPeriodForecastUnits', 'leadTimeClosingStock', 'daysOfStockClosingStock',
sales30d: 'number', 'replenishmentNeededRaw', 'replenishmentUnits', 'replenishmentCost', 'replenishmentRetail',
revenue30d: 'number', 'replenishmentProfit', 'toOrderUnits', 'forecastLostSalesUnits', 'forecastLostRevenue',
cogs30d: 'number', 'stockCoverInDays', 'poCoverInDays', 'sellsOutInDays', 'overstockedUnits',
profit30d: 'number', 'overstockedCost', 'overstockedRetail', 'yesterdaySales',
// ... other numeric columns // New numeric columns
'moq', 'rating', 'reviews', 'weight', 'length', 'width', 'height',
// Date columns 'baskets', 'notifies', 'preorderCount', 'notionsInvCount'
dateCreated: 'date', ],
dateFirstReceived: 'date', // Date columns (use date operators and sorting)
dateLastReceived: 'date', date: [
dateFirstSold: 'date', 'dateCreated', 'dateFirstReceived', 'dateLastReceived', 'dateFirstSold', 'dateLastSold',
dateLastSold: 'date', 'earliestExpectedDate', 'replenishDate', 'forecastedOutOfStockDate'
earliestExpectedDate: 'date', ],
replenishDate: 'date', // String columns (use string operators and sorting)
string: [
// Status column - special handling 'sku', 'title', 'brand', 'vendor', 'imageUrl', 'abcClass', 'status',
status: 'status', // New string columns
'barcode', 'harmonizedTariffCode', 'vendorReference', 'notionsReference',
// String columns default to 'string' type 'line', 'subline', 'artist', 'countryOfOrigin', 'location'
],
// Boolean columns // Boolean columns (use boolean operators and sorting)
isVisible: 'boolean', boolean: ['isVisible', 'isReplenishable', 'isOldStock']
isReplenishable: 'boolean',
isOldStock: 'boolean'
}; };
// Special sort handling for certain columns // Special sort handling for certain columns

View File

@@ -21,17 +21,37 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { ProductFilterOptions, ProductMetricColumnKey } from "@/types/products"; import { ProductFilterOptions, ProductMetricColumnKey } 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"];
interface FilterOption {
id: ProductMetricColumnKey | 'search';
label: string;
type: "select" | "number" | "boolean" | "text" | "date" | "string";
options?: { label: string; value: string }[];
group: string;
operators?: ComparisonOperator[];
}
type FilterValue = string | number | boolean; type FilterValue = string | number | boolean;
type ComparisonOperator = "=" | ">" | ">=" | "<" | "<=" | "between";
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 { interface FilterValueWithOperator {
value: FilterValue | string[] | number[]; value: FilterValue | string[] | number[];
operator: ComparisonOperator; operator: ComparisonOperator;
} }
// Support both simple values and complex ones with operators
export type ActiveFilterValue = FilterValue | FilterValueWithOperator;
interface ActiveFilterDisplay { interface ActiveFilterDisplay {
id: string; id: string;
label: string; label: string;
@@ -39,71 +59,149 @@ interface ActiveFilterDisplay {
displayValue: string; displayValue: string;
} }
export interface FilterOption { // Base filter options available to users
id: ProductMetricColumnKey | 'search';
label: string;
type: "select" | "number" | "boolean" | "text" | "date";
options?: { label: string; value: string }[];
group: string;
operators?: ComparisonOperator[];
}
// Base filter options - static part of the filters, which will be merged with dynamic options
const BASE_FILTER_OPTIONS: FilterOption[] = [ const BASE_FILTER_OPTIONS: FilterOption[] = [
// Search Group // Basic Info group
{ id: "search", label: "Search (Title, SKU...)", type: "text", group: "Search" }, { id: 'sku', label: 'SKU', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
{ id: 'title', label: 'Name', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
{ id: 'barcode', label: 'UPC', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
{ id: 'vendor', label: 'Supplier', type: 'select', group: 'Basic Info', operators: SELECT_OPERATORS, options: [] },
{ id: 'brand', label: 'Company', type: 'select', group: 'Basic Info', operators: SELECT_OPERATORS, options: [] },
{ id: 'line', label: 'Line', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
{ id: 'subline', label: 'Subline', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
{ id: 'artist', label: 'Artist', type: 'text', group: 'Basic Info', operators: STRING_OPERATORS },
{ id: 'isVisible', label: 'Visible', type: 'boolean', group: 'Basic Info', operators: BOOLEAN_OPERATORS },
{ id: 'isReplenishable', label: 'Replenishable', type: 'boolean', group: 'Basic Info', operators: BOOLEAN_OPERATORS },
{ id: 'abcClass', label: 'ABC Class', type: 'select', group: 'Basic Info', operators: SELECT_OPERATORS, options: [] },
{ id: 'status', label: 'Status', type: 'select', group: 'Basic Info', operators: SELECT_OPERATORS, options: [
{ value: 'in_stock', label: 'In Stock' },
{ value: 'low_stock', label: 'Low Stock' },
{ value: 'out_of_stock', label: 'Out of Stock' },
{ value: 'discontinued', label: 'Discontinued' },
]},
{ id: 'dateCreated', label: 'Created Date', type: 'date', group: 'Basic Info', operators: DATE_OPERATORS },
// Basic Info Group // Supply Chain group
{ id: "sku", label: "SKU", type: "text", group: "Basic Info" }, { id: 'vendorReference', label: 'Supplier #', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
{ id: "vendor", label: "Vendor", type: "select", group: "Basic Info" }, { id: 'notionsReference', label: 'Notions #', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
{ id: "brand", label: "Brand", type: "select", group: "Basic Info" }, { id: 'harmonizedTariffCode', label: 'Tariff Code', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
{ id: "isVisible", label: "Is Visible", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Basic Info" }, { id: 'countryOfOrigin', label: 'Country of Origin', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
{ id: "isReplenishable", label: "Is Replenishable", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Basic Info" }, { id: 'location', label: 'Location', type: 'text', group: 'Supply Chain', operators: STRING_OPERATORS },
{ id: "dateCreated", label: "Date Created", type: "date", group: "Basic Info", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: 'moq', label: 'MOQ', type: 'number', group: 'Supply Chain', operators: NUMBER_OPERATORS },
{ id: "ageDays", label: "Age (Days)", type: "number", group: "Basic Info", operators: ["=", ">", ">=", "<", "<=", "between"] },
// Status Group
// { id: "status", label: "Status", type: "select", options: [...] } - Will be populated dynamically or handled via views
// Stock Group // Physical Properties group
{ id: "abcClass", label: "ABC Class", type: "select", group: "Stock" }, { id: 'weight', label: 'Weight', type: 'number', group: 'Physical', operators: NUMBER_OPERATORS },
{ id: "currentStock", label: "Current Stock", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: 'dimensions', label: 'Dimensions', type: 'text', group: 'Physical', operators: STRING_OPERATORS },
{ id: "currentStockCost", label: "Stock Cost", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "currentStockRetail", label: "Stock Retail", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, // Customer Engagement group
{ id: "onOrderQty", label: "On Order Qty", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: 'rating', label: 'Rating', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
{ id: "stockCoverInDays", label: "Stock Cover (Days)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: 'reviews', label: 'Reviews Count', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
{ id: "sellsOutInDays", label: "Sells Out In (Days)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: 'baskets', label: 'Basket Adds', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
{ id: "overstockedUnits", label: "Overstock Units", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: 'notifies', label: 'Stock Alerts', type: 'number', group: 'Customer', operators: NUMBER_OPERATORS },
{ id: "isOldStock", label: "Is Old Stock", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Stock" },
// Stock group
{ id: 'currentStock', label: 'Current Stock', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
{ id: 'preorderCount', label: 'Preorders', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
{ id: 'notionsInvCount', label: 'Notions Inventory', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
{ id: 'onOrderQty', label: 'On Order', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
{ id: 'configSafetyStock', label: 'Safety Stock', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
{ id: 'replenishmentUnits', label: 'Replenish Qty', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
{ id: 'toOrderUnits', label: 'To Order', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
{ id: 'stockCoverInDays', label: 'Stock Cover (Days)', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
{ id: 'sellsOutInDays', label: 'Sells Out In (Days)', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
{ id: 'isOldStock', label: 'Old Stock', type: 'boolean', group: 'Stock', operators: BOOLEAN_OPERATORS },
{ id: 'overstockedUnits', label: 'Overstock Qty', type: 'number', group: 'Stock', operators: NUMBER_OPERATORS },
// Pricing Group // Pricing Group
{ id: "currentPrice", label: "Current Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "currentPrice", label: "Current Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "currentRegularPrice", label: "Regular Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "currentCostPrice", label: "Cost Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "currentCostPrice", label: "Cost Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "currentLandingCostPrice", label: "Landing Cost", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "currentLandingCostPrice", label: "Landing Cost", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] },
// Valuation Group
{ id: "avgStockCost30d", label: "Avg Stock Cost (30d)", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "avgStockRetail30d", label: "Avg Stock Retail (30d)", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "avgStockGross30d", label: "Avg Stock Gross (30d)", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "receivedCost30d", label: "Received Cost (30d)", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "replenishmentCost", label: "Replenishment Cost", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "replenishmentRetail", label: "Replenishment Retail", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "replenishmentProfit", label: "Replenishment Profit", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "onOrderCost", label: "On Order Cost", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "onOrderRetail", label: "On Order Retail", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "overstockedCost", label: "Overstock Cost", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "overstockedRetail", label: "Overstock Retail", type: "number", group: "Valuation", operators: ["=", ">", ">=", "<", "<=", "between"] },
// Sales Metrics Group // Sales Metrics Group
{ id: "sales7d", label: "Sales (7d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "sales7d", label: "Sales (7d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "revenue7d", label: "Revenue (7d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "revenue7d", label: "Revenue (7d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "sales14d", label: "Sales (14d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "revenue14d", label: "Revenue (14d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "sales30d", label: "Sales (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "sales30d", label: "Sales (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "revenue30d", label: "Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "revenue30d", label: "Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "profit30d", label: "Profit (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "sales365d", label: "Sales (365d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "sales365d", label: "Sales (365d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "revenue365d", label: "Revenue (365d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "revenue365d", label: "Revenue (365d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "salesVelocityDaily", label: "Daily Velocity", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "salesVelocityDaily", label: "Daily Velocity", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "dateLastSold", label: "Date Last Sold", type: "date", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "dateLastSold", label: "Date Last Sold", type: "date", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "yesterdaySales", label: "Sales (Yesterday)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "yesterdaySales", label: "Sales (Yesterday)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "avgSalesPerDay30d", label: "Avg Sales/Day (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "avgSalesPerMonth30d", label: "Avg Sales/Month (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "returnsUnits30d", label: "Returns Units (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "returnsRevenue30d", label: "Returns Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "discounts30d", label: "Discounts (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "grossRevenue30d", label: "Gross Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "grossRegularRevenue30d", label: "Gross Regular Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "asp30d", label: "ASP (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "acp30d", label: "ACP (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "avgRos30d", label: "Avg ROS (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "lifetimeSales", label: "Lifetime Sales", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "lifetimeRevenue", label: "Lifetime Revenue", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
// First Period Group
{ id: "first7DaysSales", label: "First 7 Days Sales", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "first7DaysRevenue", label: "First 7 Days Revenue", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "first30DaysSales", label: "First 30 Days Sales", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "first30DaysRevenue", label: "First 30 Days Revenue", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "first60DaysSales", label: "First 60 Days Sales", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "first60DaysRevenue", label: "First 60 Days Revenue", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "first90DaysSales", label: "First 90 Days Sales", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "first90DaysRevenue", label: "First 90 Days Revenue", type: "number", group: "First Period", operators: ["=", ">", ">=", "<", "<=", "between"] },
// Financial KPIs // Financial KPIs
{ id: "cogs30d", label: "COGS (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "profit30d", label: "Profit (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "margin30d", label: "Margin % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "margin30d", label: "Margin % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "markup30d", label: "Markup % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "gmroi30d", label: "GMROI (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "gmroi30d", label: "GMROI (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "stockturn30d", label: "Stock Turn (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "stockturn30d", label: "Stock Turn (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "sellThrough30d", label: "Sell Thru % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "sellThrough30d", label: "Sell Thru % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "returnRate30d", label: "Return Rate % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "discountRate30d", label: "Discount Rate % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "markdown30d", label: "Markdown (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "markdownRate30d", label: "Markdown Rate % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] },
// Forecasting Group
{ id: "leadTimeForecastUnits", label: "Lead Time Forecast Units", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "daysOfStockForecastUnits", label: "Days of Stock Forecast Units", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "planningPeriodForecastUnits", label: "Planning Period Forecast Units", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "leadTimeClosingStock", label: "Lead Time Closing Stock", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "daysOfStockClosingStock", label: "Days of Stock Closing Stock", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "replenishmentNeededRaw", label: "Replenishment Needed Raw", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "forecastLostSalesUnits", label: "Forecast Lost Sales Units", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "forecastLostRevenue", label: "Forecast Lost Revenue", type: "number", group: "Forecasting", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "stockoutDays30d", label: "Stockout Days (30d)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "stockoutRate30d", label: "Stockout Rate %", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "avgStockUnits30d", label: "Avg Stock Units (30d)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "receivedQty30d", label: "Received Qty (30d)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "poCoverInDays", label: "PO Cover (Days)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] },
// Lead Time & Replenishment // Lead Time & Replenishment
{ id: "avgLeadTimeDays", label: "Avg Lead Time", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "configLeadTime", label: "Config Lead Time", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "configLeadTime", label: "Config Lead Time", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "configDaysOfStock", label: "Config Days of Stock", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "avgLeadTimeDays", label: "Avg Lead Time", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "earliestExpectedDate", label: "Next Arrival Date", type: "date", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "earliestExpectedDate", label: "Next Arrival Date", type: "date", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "dateLastReceived", label: "Date Last Received", type: "date", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] }, { id: "dateLastReceived", label: "Date Last Received", type: "date", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "dateFirstReceived", label: "First Received", type: "date", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
{ id: "dateFirstSold", label: "First Sold", type: "date", group: "Dates", operators: ["=", ">", ">=", "<", "<=", "between"] },
]; ];
interface ProductFiltersProps { interface ProductFiltersProps {
@@ -285,13 +383,12 @@ export function ProductFilters({
const activeFiltersList = React.useMemo((): ActiveFilterDisplay[] => { const activeFiltersList = React.useMemo((): ActiveFilterDisplay[] => {
return Object.entries(activeFilters) return Object.entries(activeFilters)
.map(([id, value]): ActiveFilterDisplay | null => { .map(([id, value]): ActiveFilterDisplay => {
const option = processedFilterOptions.find(opt => opt.id === id); const option = processedFilterOptions.find(opt => opt.id === id);
if (!option) return null; // Should not happen if state is clean
return { return {
id, id,
label: option.label, label: option?.label || id,
value, value,
displayValue: getFilterDisplayValue(id, value), displayValue: getFilterDisplayValue(id, value),
}; };

View File

@@ -78,6 +78,8 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
zIndex: isDragging ? 10 : 1, zIndex: isDragging ? 10 : 1,
position: 'relative' as const, position: 'relative' as const,
touchAction: 'none' as const, touchAction: 'none' as const,
width: columnDef?.width ? undefined : 'auto',
minWidth: columnDef?.key === 'imageUrl' ? '60px' : '100px',
}; };
// Skip rendering content for 'noLabel' columns (like image) // Skip rendering content for 'noLabel' columns (like image)
@@ -85,8 +87,8 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
return ( return (
<TableHead <TableHead
ref={setNodeRef} ref={setNodeRef}
style={{...style, width: columnDef?.width || 'auto', padding: '0.5rem' }} style={style}
className={cn(columnDef?.width, "select-none")} className={cn(columnDef?.width, "select-none", "whitespace-nowrap")}
{...attributes} {...attributes}
{...listeners} {...listeners}
/> />
@@ -98,7 +100,7 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={cn( className={cn(
"cursor-pointer select-none group", "cursor-pointer select-none group whitespace-nowrap",
columnDef?.width, columnDef?.width,
sortColumn === column && "bg-accent/50" sortColumn === column && "bg-accent/50"
)} )}
@@ -207,84 +209,102 @@ export function ProductTable({
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={() => setActiveId(null)} onDragCancel={() => setActiveId(null)}
> >
<div className="rounded-md border overflow-x-auto relative"> <div className="border rounded-md relative">
{isLoading && ( {isLoading && (
<div className="absolute inset-0 bg-background/70 flex items-center justify-center z-20"> <div className="absolute inset-0 bg-background/70 flex items-center justify-center z-20">
<Skeleton className="h-8 w-32" /> <Skeleton className="h-8 w-32" />
</div> </div>
)} )}
<Table className={isLoading ? 'opacity-50' : ''}> <div className="overflow-x-auto">
<TableHeader className="sticky top-0 bg-background z-10"> <Table className={cn(isLoading ? 'opacity-50' : '', "w-max min-w-full")}>
<TableRow> <TableHeader className="sticky top-0 bg-background z-10">
<SortableContext
items={orderedVisibleColumns}
strategy={horizontalListSortingStrategy}
>
{orderedVisibleColumns.map((columnKey) => (
<SortableHeader
key={columnKey}
column={columnKey}
columnDef={columnDefs.find(def => def.key === columnKey)}
onSort={onSort}
sortColumn={sortColumn}
sortDirection={sortDirection}
/>
))}
</SortableContext>
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell <SortableContext
colSpan={orderedVisibleColumns.length} items={orderedVisibleColumns}
className="text-center py-8 text-muted-foreground" strategy={horizontalListSortingStrategy}
>
No products found matching your criteria.
</TableCell>
</TableRow>
) : (
products.map((product) => (
<TableRow
key={product.pid}
onClick={() => onRowClick?.(product)}
className="cursor-pointer hover:bg-muted/50"
data-state={isLoading ? 'loading' : undefined}
> >
{orderedVisibleColumns.map((columnKey) => ( {orderedVisibleColumns.map((columnKey) => (
<TableCell key={`${product.pid}-${columnKey}`} className={cn(columnDefs.find(c=>c.key===columnKey)?.width)}> <SortableHeader
{columnKey === 'imageUrl' ? ( key={columnKey}
<div className="flex items-center justify-center h-12 w-[60px]"> column={columnKey}
{product.imageUrl ? ( columnDef={columnDefs.find(def => def.key === columnKey)}
<img onSort={onSort}
src={product.imageUrl} sortColumn={sortColumn}
alt={product.title || 'Product image'} sortDirection={sortDirection}
className="max-h-full max-w-full object-contain bg-white p-0.5 border rounded" />
loading="lazy"
/>
) : (
<div className="h-10 w-10 bg-muted rounded flex items-center justify-center text-muted-foreground text-xs">No Image</div>
)}
</div>
) : (
formatColumnValue(product, columnKey)
)}
</TableCell>
))} ))}
</TableRow> </SortableContext>
))
)}
{isLoading && products.length === 0 && Array.from({length: 10}).map((_, i) => (
<TableRow key={`skel-${i}`}>
{orderedVisibleColumns.map(key => (
<TableCell key={`skel-${i}-${key}`} className={cn(columnDefs.find(c=>c.key===key)?.width)}>
<Skeleton className={`h-5 ${key==='imageUrl' ? 'w-10 h-10' : 'w-full'}`} />
</TableCell>
))}
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {products.length === 0 && !isLoading ? (
<TableRow>
<TableCell
colSpan={orderedVisibleColumns.length}
className="text-center py-8 text-muted-foreground"
>
No products found matching your criteria.
</TableCell>
</TableRow>
) : (
products.map((product) => (
<TableRow
key={product.pid}
onClick={() => onRowClick?.(product)}
className="cursor-pointer hover:bg-muted/50"
data-state={isLoading ? 'loading' : undefined}
>
{orderedVisibleColumns.map((columnKey) => {
const colDef = columnDefs.find(c => c.key === columnKey);
return (
<TableCell
key={`${product.pid}-${columnKey}`}
className={cn(
colDef?.width,
"whitespace-nowrap",
columnKey === 'title' && "max-w-[300px] truncate"
)}
>
{columnKey === 'imageUrl' ? (
<div className="flex items-center justify-center h-12 w-[60px]">
{product.imageUrl ? (
<img
src={product.imageUrl}
alt={product.title || 'Product image'}
className="max-h-full max-w-full object-contain bg-white p-0.5 border rounded"
loading="lazy"
/>
) : (
<div className="h-10 w-10 bg-muted rounded flex items-center justify-center text-muted-foreground text-xs">No Image</div>
)}
</div>
) : (
formatColumnValue(product, columnKey)
)}
</TableCell>
);
})}
</TableRow>
))
)}
{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);
return (
<TableCell
key={`skel-${i}-${key}`}
className={cn(colDef?.width, "whitespace-nowrap")}
>
<Skeleton className={`h-5 ${key==='imageUrl' ? 'w-10 h-10' : 'w-full'}`} />
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div> </div>
</DndContext> </DndContext>
); );

View File

@@ -1,5 +1,4 @@
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Product } from "@/types/products"
import { import {
AlertTriangle, AlertTriangle,
CheckCircle2, CheckCircle2,
@@ -15,7 +14,6 @@ export type ProductView = {
label: string label: string
icon: any icon: any
iconClassName: string iconClassName: string
columns: (keyof Product)[]
} }
export const PRODUCT_VIEWS: ProductView[] = [ export const PRODUCT_VIEWS: ProductView[] = [
@@ -23,50 +21,43 @@ export const PRODUCT_VIEWS: ProductView[] = [
id: "all", id: "all",
label: "All Products", label: "All Products",
icon: PackageSearch, icon: PackageSearch,
iconClassName: "", iconClassName: ""
columns: ["image", "title", "SKU", "stock_quantity", "price", "stock_status"]
}, },
{ {
id: "critical", id: "critical",
label: "Critical Stock", label: "Critical Stock",
icon: AlertTriangle, icon: AlertTriangle,
iconClassName: "", iconClassName: ""
columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "reorder_qty", "last_purchase_date", "lead_time_status"]
}, },
{ {
id: "reorder", id: "reorder",
label: "Reorder Soon", label: "Reorder Soon",
icon: PackagePlus, icon: PackagePlus,
iconClassName: "", iconClassName: ""
columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "reorder_qty", "last_purchase_date", "lead_time_status"]
}, },
{ {
id: "healthy", id: "healthy",
label: "Healthy Stock", label: "Healthy Stock",
icon: CheckCircle2, icon: CheckCircle2,
iconClassName: "", iconClassName: ""
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "stock_status", "abc_class"]
}, },
{ {
id: "at-risk", id: "at-risk",
label: "At Risk", label: "At Risk",
icon: Timer, icon: Timer,
iconClassName: "", iconClassName: ""
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "weekly_sales_avg", "days_of_inventory", "last_sale_date"]
}, },
{ {
id: "overstocked", id: "overstocked",
label: "Overstock", label: "Overstock",
icon: PackageX, icon: PackageX,
iconClassName: "", iconClassName: ""
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "overstocked_amt", "days_of_inventory", "last_sale_date"]
}, },
{ {
id: "new", id: "new",
label: "New Products", label: "New Products",
icon: Sparkles, icon: Sparkles,
iconClassName: "", iconClassName: ""
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "stock_status", "abc_class"]
} }
] ]

View File

@@ -8,8 +8,7 @@ import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton
import { ProductDetail } from '@/components/products/ProductDetail'; import { ProductDetail } from '@/components/products/ProductDetail';
import { ProductViews } from '@/components/products/ProductViews'; import { ProductViews } from '@/components/products/ProductViews';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Product, ProductMetric, ProductMetricColumnKey } from '@/types/products'; import { ProductMetric, ProductMetricColumnKey } from '@/types/products';
import { getProductStatus } from '@/utils/productUtils';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
@@ -45,73 +44,144 @@ interface ColumnDef {
// Define available columns with their groups // Define available columns with their groups
const AVAILABLE_COLUMNS: ColumnDef[] = [ const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'imageUrl', label: 'Image', group: 'Basic Info', noLabel: true, width: 'w-[60px]' }, // Identity & Basic Info
{ key: 'title', label: 'Name', group: 'Basic Info' }, { key: 'imageUrl', label: 'Image', group: 'Product Identity', noLabel: true, width: 'w-[60px]' },
{ key: 'sku', label: 'SKU', group: 'Basic Info' }, { key: 'title', label: 'Name', group: 'Product Identity'},
{ key: 'brand', label: 'Company', group: 'Basic Info' }, { key: 'sku', label: 'Item Number', group: 'Product Identity' },
{ key: 'vendor', label: 'Supplier', group: 'Basic Info' }, { key: 'barcode', label: 'UPC', group: 'Product Identity' },
{ key: 'isVisible', label: 'Visible', group: 'Basic Info' }, { key: 'brand', label: 'Company', group: 'Product Identity' },
{ key: 'isReplenishable', label: 'Replenishable', group: 'Basic Info' }, { key: 'line', label: 'Line', group: 'Product Identity' },
{ key: 'dateCreated', label: 'Created', group: 'Basic Info' }, { 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' },
// Current Status // 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: 'dimensions', label: 'Dimensions', group: 'Physical', format: (v) => v ? `${v.length}×${v.width}×${v.height}` : '-' },
// 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: '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: '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: '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: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentStock', label: 'Stock', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, { key: 'currentStockCost', label: 'Stock Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Stock', 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: 'currentStockRetail', label: 'Stock Retail', group: 'Stock', 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: 'currentStockGross', label: 'Stock Gross', group: 'Stock', 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: 'onOrderQty', label: 'On Order', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, { key: 'onOrderRetail', label: 'On Order Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'onOrderCost', label: 'On Order Cost', group: 'Stock', 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: 'onOrderRetail', label: 'On Order Retail', group: 'Stock', 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: 'earliestExpectedDate', label: 'Expected Date', group: 'Stock' }, { 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 // Dates & Timing
{ key: 'dateFirstReceived', label: 'First Received', group: 'Dates' }, { key: 'dateFirstReceived', label: 'First Received', group: 'Dates' },
{ key: 'dateLastReceived', label: 'Last Received', group: 'Dates' }, { key: 'dateLastReceived', label: 'Last Received', group: 'Dates' },
{ key: 'dateFirstSold', label: 'First Sold', group: 'Dates' }, { key: 'dateFirstSold', label: 'First Sold', group: 'Dates' },
{ key: 'dateLastSold', label: 'Last 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: '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() : '-' },
// Product Status // Sales & Revenue
{ key: 'status', label: 'Status', group: 'Status' }, { 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() : '-' },
// Rolling Metrics
{ key: 'sales7d', label: 'Sales (7d)', 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: '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: '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: '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: '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: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ 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: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, { 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: '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) : '-' },
// KPIs // 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: '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: '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: '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: '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: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Lead Time', 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)}%` : '-' },
// Replenishment // Forecasting
{ key: 'abcClass', label: 'ABC Class', group: 'Stock' }, { key: 'leadTimeForecastUnits', label: 'Lead Time Forecast Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', 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: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Stock', 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: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', 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: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, { key: 'daysOfStockClosingStock', label: 'Days of Stock Closing Stock', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
{ key: 'overstockedCost', label: 'Overstock Cost', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, { key: 'replenishmentNeededRaw', label: 'Replenishment Needed Raw', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
{ key: 'overstockedRetail', label: 'Overstock Retail', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, { key: 'forecastLostSalesUnits', label: 'Forecast Lost Sales Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
{ key: 'isOldStock', label: 'Old Stock', group: 'Stock' }, { key: 'forecastLostRevenue', label: 'Forecast Lost Revenue', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
// Config & Replenishment columns // First Period Performance
{ key: 'configSafetyStock', label: 'Safety Stock', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, { key: 'first7DaysSales', label: 'First 7 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'replenishmentUnits', label: 'Replenish Units', group: 'Stock', 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) : '-' },
]; ];
// Define default columns for each view // Define default columns for each view
@@ -120,84 +190,105 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
'imageUrl', 'imageUrl',
'title', 'title',
'brand', 'brand',
'vendor',
'currentStock',
'status', 'status',
'salesVelocityDaily', 'currentStock',
'currentPrice', 'currentPrice',
'currentRegularPrice', 'salesVelocityDaily',
'sales7d',
'sales30d', 'sales30d',
'revenue30d', 'revenue30d',
'currentStockCost', 'profit30d',
'stockCoverInDays',
'currentStockCost'
], ],
critical: [ critical: [
'status',
'imageUrl', 'imageUrl',
'title', 'title',
'currentStock', 'currentStock',
'configSafetyStock', 'configSafetyStock',
'sales7d',
'sales30d',
'replenishmentUnits', 'replenishmentUnits',
'salesVelocityDaily', 'salesVelocityDaily',
'sales7d',
'sales30d',
'onOrderQty',
'earliestExpectedDate',
'vendor', 'vendor',
'dateLastReceived', 'dateLastReceived',
'avgLeadTimeDays', 'avgLeadTimeDays'
], ],
reorder: [ reorder: [
'status',
'imageUrl',
'title',
'currentStock',
'configSafetyStock',
'replenishmentUnits',
'salesVelocityDaily',
'sellsOutInDays',
'currentCostPrice',
'sales30d',
'vendor',
'avgLeadTimeDays',
'dateLastReceived'
],
overstocked: [
'status',
'imageUrl',
'title',
'currentStock',
'overstockedUnits',
'sales7d',
'sales30d',
'salesVelocityDaily',
'stockCoverInDays',
'stockturn30d',
'currentStockCost',
'overstockedCost',
'dateLastSold'
],
'at-risk': [
'status',
'imageUrl',
'title',
'currentStock',
'configSafetyStock',
'salesVelocityDaily',
'sales7d',
'sales30d',
'stockCoverInDays',
'sellsOutInDays',
'dateLastSold',
'avgLeadTimeDays',
'profit30d'
],
new: [
'status',
'imageUrl', 'imageUrl',
'title', 'title',
'currentStock', 'currentStock',
'salesVelocityDaily', 'salesVelocityDaily',
'sales7d', 'sales7d',
'sales30d',
'replenishmentUnits',
'vendor',
'dateLastReceived',
'avgLeadTimeDays',
],
overstocked: [
'imageUrl',
'title',
'currentStock',
'sales7d',
'sales30d',
'overstockedUnits',
'stockCoverInDays',
'currentStockCost',
'stockturn30d',
],
'at-risk': [
'imageUrl',
'title',
'currentStock',
'configSafetyStock',
'sales7d',
'sales30d',
'stockCoverInDays',
'dateLastSold',
'avgLeadTimeDays',
],
new: [
'imageUrl',
'title',
'currentStock',
'vendor', 'vendor',
'brand', 'brand',
'currentPrice', 'currentPrice',
'currentRegularPrice', 'currentCostPrice',
'dateFirstReceived', 'dateFirstReceived',
'ageDays',
'abcClass'
], ],
healthy: [ healthy: [
'status',
'imageUrl', 'imageUrl',
'title', 'title',
'currentStock', 'currentStock',
'sales7d', 'stockCoverInDays',
'salesVelocityDaily',
'sales30d', 'sales30d',
'revenue30d', 'revenue30d',
'stockCoverInDays',
'profit30d', 'profit30d',
'margin30d',
'gmroi30d', 'gmroi30d',
'stockturn30d'
], ],
}; };
@@ -327,7 +418,14 @@ export function Products() {
} }
if (activeView && activeView !== 'all') { if (activeView && activeView !== 'all') {
params.append('stock_status', activeView === 'at-risk' ? 'At Risk' : activeView); 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 // Transform filters to match API expectations
@@ -534,7 +632,7 @@ export function Products() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
className="w-[500px] max-h-[calc(100vh-4rem)] overflow-y-auto" className="w-[600px] max-h-[calc(100vh-16rem)] overflow-y-auto"
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
onPointerDownOutside={(e) => { onPointerDownOutside={(e) => {
// Only close if clicking outside the dropdown // Only close if clicking outside the dropdown
@@ -549,45 +647,47 @@ export function Products() {
} }
}} }}
> >
<DropdownMenuLabel className="sticky top-0 bg-background z-10">Toggle columns</DropdownMenuLabel> <div className="sticky top-0 bg-background z-10 flex items-center justify-between">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
resetColumnsToDefault();
// Prevent closing by stopping propagation
e.stopPropagation();
}}
>
Reset to Default
</Button>
</div>
<DropdownMenuSeparator className="sticky top-[29px] bg-background z-10" /> <DropdownMenuSeparator className="sticky top-[29px] bg-background z-10" />
<div className="grid grid-cols-2 gap-4"> <div style={{ columnCount: 3, columnGap: '2rem' }} className="p-2">
{Object.entries(columnsByGroup).map(([group, columns]) => ( {Object.entries(columnsByGroup).map(([group, columns]) => (
<div key={group}> <div key={group} style={{ breakInside: 'avoid' }} className="mb-4">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> <DropdownMenuLabel className="text-xs font-semibold text-muted-foreground mb-2">
{group} {group}
</DropdownMenuLabel> </DropdownMenuLabel>
{columns.map((column) => ( <div className="flex flex-col gap-1">
<DropdownMenuCheckboxItem {columns.map((column) => (
key={column.key} <DropdownMenuCheckboxItem
className="capitalize" key={column.key}
checked={visibleColumns.has(column.key)} className="capitalize"
onCheckedChange={(checked) => { checked={visibleColumns.has(column.key)}
handleColumnVisibilityChange(column.key, checked); onCheckedChange={(checked) => {
}} handleColumnVisibilityChange(column.key, checked);
onSelect={(e) => { }}
// Prevent closing by stopping propagation onSelect={(e) => {
e.preventDefault(); e.preventDefault();
}} }}
> >
{column.label} {column.label}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
))} ))}
</div>
</div> </div>
))} ))}
</div> </div>
<DropdownMenuSeparator />
<Button
variant="ghost"
className="w-full justify-start"
onClick={(e) => {
resetColumnsToDefault();
// Prevent closing by stopping propagation
e.stopPropagation();
}}
>
Reset to Default
</Button>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@@ -80,7 +80,7 @@ export interface Product {
} }
// Type for product status (used for calculated statuses) // Type for product status (used for calculated statuses)
export type ProductStatus = "Critical" | "Reorder Soon" | "Healthy" | "Overstock" | "At Risk" | "Unknown"; export type ProductStatus = "Critical" | "Reorder Soon" | "Healthy" | "Overstock" | "At Risk" | "New" | "Unknown";
// Represents data returned by the /metrics endpoint (from product_metrics table) // Represents data returned by the /metrics endpoint (from product_metrics table)
export interface ProductMetric { export interface ProductMetric {
@@ -93,6 +93,30 @@ export interface ProductMetric {
isVisible: boolean; isVisible: boolean;
isReplenishable: boolean; isReplenishable: boolean;
// Additional Product Fields
barcode: string | null;
vendorReference: string | null; // Supplier #
notionsReference: string | null; // Notions #
preorderCount: number | null;
notionsInvCount: number | null;
harmonizedTariffCode: string | null;
line: string | null;
subline: string | null;
artist: string | null;
moq: number | null;
rating: number | null;
reviews: number | null;
weight: number | null;
dimensions: {
length: number | null;
width: number | null;
height: number | null;
} | null;
countryOfOrigin: string | null;
location: string | null;
baskets: number | null; // Number of times added to basket
notifies: number | null; // Number of stock notifications
// Current Status // Current Status
currentPrice: number | null; currentPrice: number | null;
currentRegularPrice: number | null; currentRegularPrice: number | null;
@@ -201,7 +225,146 @@ export interface ProductFilterOptions {
} }
// Type for keys used in sorting/filtering (matching frontend state/UI) // Type for keys used in sorting/filtering (matching frontend state/UI)
export type ProductMetricColumnKey = keyof Omit<ProductMetric, 'pid'> | 'pid' | 'status'; export type ProductMetricColumnKey =
| 'pid'
| 'title'
| 'sku'
| 'barcode'
| 'brand'
| 'line'
| 'subline'
| 'artist'
| 'vendor'
| 'vendorReference'
| 'notionsReference'
| 'harmonizedTariffCode'
| 'countryOfOrigin'
| 'location'
| 'moq'
| 'weight'
| 'dimensions'
| 'rating'
| 'reviews'
| 'baskets'
| 'notifies'
| 'preorderCount'
| 'notionsInvCount'
| 'isVisible'
| 'isReplenishable'
| 'abcClass'
| 'status'
| 'dateCreated'
| 'currentStock'
| 'currentStockCost'
| 'currentStockRetail'
| 'currentStockGross'
| 'ageDays'
| 'replenishDate'
| 'planningPeriodDays'
| 'currentPrice'
| 'currentRegularPrice'
| 'currentCostPrice'
| 'currentLandingCostPrice'
| 'configSafetyStock'
| 'replenishmentUnits'
| 'stockCoverInDays'
| 'sellsOutInDays'
| 'onOrderQty'
| 'earliestExpectedDate'
| 'isOldStock'
| 'overstockedUnits'
| 'stockoutDays30d'
| 'stockoutRate30d'
| 'avgStockUnits30d'
| 'avgStockCost30d'
| 'avgStockRetail30d'
| 'avgStockGross30d'
| 'receivedQty30d'
| 'receivedCost30d'
| 'configLeadTime'
| 'configDaysOfStock'
| 'poCoverInDays'
| 'toOrderUnits'
| 'costPrice'
| 'valueAtCost'
| 'profit'
| 'margin'
| 'targetPrice'
| 'replenishmentCost'
| 'replenishmentRetail'
| 'replenishmentProfit'
| 'onOrderCost'
| 'onOrderRetail'
| 'overstockedCost'
| 'overstockedRetail'
| 'sales7d'
| 'revenue7d'
| 'sales14d'
| 'revenue14d'
| 'sales30d'
| 'units30d'
| 'revenue30d'
| 'sales365d'
| 'revenue365d'
| 'avgSalePrice30d'
| 'avgDailySales30d'
| 'avgDailyRevenue30d'
| 'stockturnRate30d'
| 'margin30d'
| 'cogs30d'
| 'profit30d'
| 'roas30d'
| 'adSpend30d'
| 'gmroi30d'
| 'first7DaysSales'
| 'first7DaysRevenue'
| 'first30DaysSales'
| 'first30DaysRevenue'
| 'first60DaysSales'
| 'first60DaysRevenue'
| 'first90DaysSales'
| 'first90DaysRevenue'
| 'lifetimeSales'
| 'lifetimeRevenue'
| 'lifetimeAvgPrice'
| 'forecastSalesUnits'
| 'forecastSalesValue'
| 'forecastStockCover'
| 'forecastedOutOfStockDate'
| 'salesVelocity'
| 'salesVelocityDaily'
| 'dateLastSold'
| 'yesterdaySales'
| 'avgSalesPerDay30d'
| 'avgSalesPerMonth30d'
| 'returnsUnits30d'
| 'returnsRevenue30d'
| 'discounts30d'
| 'grossRevenue30d'
| 'grossRegularRevenue30d'
| 'asp30d'
| 'acp30d'
| 'avgRos30d'
| 'markup30d'
| 'stockturn30d'
| 'sellThrough30d'
| 'returnRate30d'
| 'discountRate30d'
| 'markdown30d'
| 'markdownRate30d'
| 'leadTimeForecastUnits'
| 'daysOfStockForecastUnits'
| 'planningPeriodForecastUnits'
| 'leadTimeClosingStock'
| 'daysOfStockClosingStock'
| 'replenishmentNeededRaw'
| 'forecastLostSalesUnits'
| 'forecastLostRevenue'
| 'avgLeadTimeDays'
| 'dateLastReceived'
| 'dateFirstReceived'
| 'dateFirstSold'
| 'imageUrl';
// Mapping frontend keys to backend query param keys // Mapping frontend keys to backend query param keys
export const FRONTEND_TO_BACKEND_KEY_MAP: Record<string, string> = { export const FRONTEND_TO_BACKEND_KEY_MAP: Record<string, string> = {

View File

@@ -81,6 +81,8 @@ export function getStatusBadge(status: ProductStatus): string {
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>'; 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': 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>'; 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: 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>'; 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>';
} }