Move product status calculation to database, fix up products table, more categories tweaks

This commit is contained in:
2025-04-03 17:12:10 -04:00
parent 2601a04211
commit 4552fa4862
9 changed files with 735 additions and 219 deletions

View File

@@ -151,6 +151,9 @@ CREATE TABLE public.product_metrics (
-- Yesterday's Metrics (Refreshed Hourly from daily_product_snapshots) -- Yesterday's Metrics (Refreshed Hourly from daily_product_snapshots)
yesterday_sales INT, yesterday_sales INT,
-- Product Status (Calculated from metrics)
status VARCHAR, -- Stores status values like: Critical, Reorder Soon, Healthy, Overstock, At Risk, New
CONSTRAINT fk_product_metrics_pid FOREIGN KEY (pid) REFERENCES public.products(pid) ON DELETE CASCADE ON UPDATE CASCADE CONSTRAINT fk_product_metrics_pid FOREIGN KEY (pid) REFERENCES public.products(pid) ON DELETE CASCADE ON UPDATE CASCADE
); );
@@ -163,6 +166,7 @@ CREATE INDEX idx_product_metrics_revenue_30d ON public.product_metrics(revenue_3
CREATE INDEX idx_product_metrics_sales_30d ON public.product_metrics(sales_30d DESC NULLS LAST); -- Example sorting index CREATE INDEX idx_product_metrics_sales_30d ON public.product_metrics(sales_30d DESC NULLS LAST); -- Example sorting index
CREATE INDEX idx_product_metrics_current_stock ON public.product_metrics(current_stock); CREATE INDEX idx_product_metrics_current_stock ON public.product_metrics(current_stock);
CREATE INDEX idx_product_metrics_sells_out_in_days ON public.product_metrics(sells_out_in_days ASC NULLS LAST); -- Example sorting index CREATE INDEX idx_product_metrics_sells_out_in_days ON public.product_metrics(sells_out_in_days ASC NULLS LAST); -- Example sorting index
CREATE INDEX idx_product_metrics_status ON public.product_metrics(status); -- Index for status filtering
-- Add new vendor, category, and brand metrics tables -- Add new vendor, category, and brand metrics tables
-- Drop tables in reverse order if they exist -- Drop tables in reverse order if they exist

View File

@@ -5,11 +5,11 @@ const { Pool } = require('pg'); // Assuming you use 'pg'
// --- Configuration --- // --- Configuration ---
// Toggle these constants to enable/disable specific steps for testing // Toggle these constants to enable/disable specific steps for testing
const RUN_DAILY_SNAPSHOTS = false; const RUN_DAILY_SNAPSHOTS = true;
const RUN_PRODUCT_METRICS = false; const RUN_PRODUCT_METRICS = true;
const RUN_PERIODIC_METRICS = false; const RUN_PERIODIC_METRICS = true;
const RUN_BRAND_METRICS = false; const RUN_BRAND_METRICS = true;
const RUN_VENDOR_METRICS = false; const RUN_VENDOR_METRICS = true;
const RUN_CATEGORY_METRICS = true; const RUN_CATEGORY_METRICS = true;
// Maximum execution time for the entire sequence (e.g., 90 minutes) // Maximum execution time for the entire sequence (e.g., 90 minutes)

View File

@@ -209,7 +209,8 @@ BEGIN
to_order_units, forecast_lost_sales_units, forecast_lost_revenue, to_order_units, forecast_lost_sales_units, forecast_lost_revenue,
stock_cover_in_days, po_cover_in_days, sells_out_in_days, replenish_date, stock_cover_in_days, po_cover_in_days, sells_out_in_days, replenish_date,
overstocked_units, overstocked_cost, overstocked_retail, is_old_stock, overstocked_units, overstocked_cost, overstocked_retail, is_old_stock,
yesterday_sales yesterday_sales,
status -- Add status field for calculated status
) )
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,
@@ -568,7 +569,119 @@ BEGIN
COALESCE(ooi.on_order_qty, 0) = 0 COALESCE(ooi.on_order_qty, 0) = 0
AS is_old_stock, AS is_old_stock,
sa.yesterday_sales sa.yesterday_sales,
-- Calculate status using direct CASE statements (inline logic)
CASE
-- Non-replenishable items default to Healthy
WHEN NOT ci.is_replenishable THEN 'Healthy'
-- Calculate lead time and thresholds
ELSE
CASE
-- Check for overstock first
WHEN GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock))) > 0 THEN 'Overstock'
-- Check for Critical stock
WHEN ci.current_stock <= 0 OR
(ci.current_stock / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) <= 0 THEN 'Critical'
WHEN (ci.current_stock / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
-- Check for reorder soon
WHEN ((ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) < (COALESCE(s.effective_lead_time, 30) + 7) THEN
CASE
WHEN (ci.current_stock / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
ELSE 'Reorder Soon'
END
-- Check for 'At Risk' - old stock
WHEN (ci.created_at::date < _current_date - INTERVAL '60 day') AND
(COALESCE(ci.date_last_sold, hd.max_order_date) IS NULL OR COALESCE(ci.date_last_sold, hd.max_order_date) < _current_date - INTERVAL '60 day') AND
(hd.date_last_received_calc IS NULL OR hd.date_last_received_calc < _current_date - INTERVAL '60 day') AND
COALESCE(ooi.on_order_qty, 0) = 0 THEN 'At Risk'
-- Check for 'At Risk' - hasn't sold in a long time
WHEN COALESCE(ci.date_last_sold, hd.max_order_date) IS NOT NULL
AND COALESCE(ci.date_last_sold, hd.max_order_date) < (_current_date - INTERVAL '90 days')
AND (CASE
WHEN ci.created_at IS NULL AND hd.date_first_sold IS NULL THEN 0
WHEN ci.created_at IS NULL THEN (_current_date - hd.date_first_sold)::integer
WHEN hd.date_first_sold IS NULL THEN (_current_date - ci.created_at::date)::integer
ELSE (_current_date - LEAST(ci.created_at::date, hd.date_first_sold))::integer
END) > 180 THEN 'At Risk'
-- Very high stock cover is at risk too
WHEN (ci.current_stock / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) > 365 THEN 'At Risk'
-- New products (less than 30 days old)
WHEN (CASE
WHEN ci.created_at IS NULL AND hd.date_first_sold IS NULL THEN 0
WHEN ci.created_at IS NULL THEN (_current_date - hd.date_first_sold)::integer
WHEN hd.date_first_sold IS NULL THEN (_current_date - ci.created_at::date)::integer
ELSE (_current_date - LEAST(ci.created_at::date, hd.date_first_sold))::integer
END) <= 30 THEN 'New'
-- If none of the above, assume Healthy
ELSE 'Healthy'
END
END AS status
FROM CurrentInfo ci FROM CurrentInfo ci
LEFT JOIN OnOrderInfo ooi ON ci.pid = ooi.pid LEFT JOIN OnOrderInfo ooi ON ci.pid = ooi.pid
@@ -605,7 +718,8 @@ BEGIN
to_order_units = EXCLUDED.to_order_units, forecast_lost_sales_units = EXCLUDED.forecast_lost_sales_units, forecast_lost_revenue = EXCLUDED.forecast_lost_revenue, to_order_units = EXCLUDED.to_order_units, forecast_lost_sales_units = EXCLUDED.forecast_lost_sales_units, forecast_lost_revenue = EXCLUDED.forecast_lost_revenue,
stock_cover_in_days = EXCLUDED.stock_cover_in_days, po_cover_in_days = EXCLUDED.po_cover_in_days, sells_out_in_days = EXCLUDED.sells_out_in_days, replenish_date = EXCLUDED.replenish_date, stock_cover_in_days = EXCLUDED.stock_cover_in_days, po_cover_in_days = EXCLUDED.po_cover_in_days, sells_out_in_days = EXCLUDED.sells_out_in_days, replenish_date = EXCLUDED.replenish_date,
overstocked_units = EXCLUDED.overstocked_units, overstocked_cost = EXCLUDED.overstocked_cost, overstocked_retail = EXCLUDED.overstocked_retail, is_old_stock = EXCLUDED.is_old_stock, overstocked_units = EXCLUDED.overstocked_units, overstocked_cost = EXCLUDED.overstocked_cost, overstocked_retail = EXCLUDED.overstocked_retail, is_old_stock = EXCLUDED.is_old_stock,
yesterday_sales = EXCLUDED.yesterday_sales yesterday_sales = EXCLUDED.yesterday_sales,
status = EXCLUDED.status
; ;
-- Update the status table with the timestamp from the START of this run -- Update the status table with the timestamp from the START of this run

View File

@@ -178,6 +178,8 @@ router.get('/', async (req, res) => {
const params = []; const params = [];
let paramCounter = 1; let paramCounter = 1;
console.log("Starting to process filters from query:", req.query);
// Add filters based on req.query using COLUMN_MAP and parseValue // Add filters based on req.query using COLUMN_MAP and parseValue
for (const key in req.query) { for (const key in req.query) {
if (['page', 'limit', 'sort', 'order'].includes(key)) continue; if (['page', 'limit', 'sort', 'order'].includes(key)) continue;
@@ -186,10 +188,13 @@ router.get('/', async (req, res) => {
let operator = '='; // Default operator let operator = '='; // Default operator
const value = req.query[key]; const value = req.query[key];
console.log(`Processing filter key: "${key}" with value: "${value}"`);
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/); const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
if (operatorMatch) { if (operatorMatch) {
filterKey = operatorMatch[1]; filterKey = operatorMatch[1];
operator = operatorMatch[2]; operator = operatorMatch[2];
console.log(`Parsed filter key: "${filterKey}" with operator: "${operator}"`);
} }
// Special case for parentName requires join // Special case for parentName requires join
@@ -197,6 +202,7 @@ router.get('/', async (req, res) => {
const columnInfo = getSafeColumnInfo(filterKey); const columnInfo = getSafeColumnInfo(filterKey);
if (columnInfo) { if (columnInfo) {
console.log(`Column info for "${filterKey}":`, columnInfo);
const dbColumn = columnInfo.dbCol; const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type; const valueType = columnInfo.type;
try { try {
@@ -232,22 +238,46 @@ router.get('/', async (req, res) => {
} }
if (needsParam) { if (needsParam) {
try {
// Special handling for categoryType to ensure it works
if (filterKey === 'categoryType') {
console.log(`Special handling for categoryType: ${value}`);
// Force conversion to integer
const numericValue = parseInt(value, 10);
if (!isNaN(numericValue)) {
console.log(`Successfully converted categoryType to integer: ${numericValue}`);
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType)); params.push(numericValue);
} else {
console.error(`Failed to convert categoryType to integer: "${value}"`);
throw new Error(`Invalid categoryType value: "${value}"`);
}
} else {
// Normal handling for other fields
const parsedValue = parseValue(value, valueType);
console.log(`Parsed "${value}" as ${valueType}: ${parsedValue}`);
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(parsedValue);
}
} catch (innerError) {
console.error(`Failed to parse "${value}" as ${valueType}:`, innerError);
throw innerError;
}
} else if (!conditionFragment) { // For LIKE/ILIKE where needsParam is false } else if (!conditionFragment) { // For LIKE/ILIKE where needsParam is false
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; // paramCounter was already incremented in push conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; // paramCounter was already incremented in push
} }
if (conditionFragment) { if (conditionFragment) {
console.log(`Adding condition: ${conditionFragment}`);
conditions.push(`(${conditionFragment})`); conditions.push(`(${conditionFragment})`);
} }
} catch (parseError) { } catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`); console.error(`Skipping filter for key "${key}" due to parsing error:`, parseError);
if (needsParam) paramCounter--; // Roll back counter if param push failed if (needsParam) paramCounter--; // Roll back counter if param push failed
} }
} else { } else {
console.warn(`Invalid filter key ignored: ${key}`); console.warn(`Invalid filter key ignored: "${key}", not found in COLUMN_MAP`);
} }
} }

View File

@@ -7,90 +7,210 @@ const { Pool } = require('pg'); // Assuming pg driver
const DEFAULT_PAGE_LIMIT = 50; const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200; // Prevent excessive data requests const MAX_PAGE_LIMIT = 200; // Prevent excessive data requests
/** // Define direct mapping from frontend column names to database columns
* Maps user-friendly query parameter keys (camelCase) to database column names. // This simplifies the code by eliminating conversion logic
* Also validates if the column is safe for sorting or filtering.
* Add ALL columns from product_metrics that should be filterable/sortable.
*/
const COLUMN_MAP = { const COLUMN_MAP = {
// Product Info // Product Info
pid: { dbCol: 'pm.pid', type: 'number' }, pid: 'pm.pid',
sku: { dbCol: 'pm.sku', type: 'string' }, sku: 'pm.sku',
title: { dbCol: 'pm.title', type: 'string' }, title: 'pm.title',
brand: { dbCol: 'pm.brand', type: 'string' }, brand: 'pm.brand',
vendor: { dbCol: 'pm.vendor', type: 'string' }, vendor: 'pm.vendor',
imageUrl: { dbCol: 'pm.image_url', type: 'string' }, imageUrl: 'pm.image_url',
isVisible: { dbCol: 'pm.is_visible', type: 'boolean' }, isVisible: 'pm.is_visible',
isReplenishable: { dbCol: 'pm.is_replenishable', type: 'boolean' }, isReplenishable: 'pm.is_replenishable',
// Current Status // Current Status
currentPrice: { dbCol: 'pm.current_price', type: 'number' }, currentPrice: 'pm.current_price',
currentRegularPrice: { dbCol: 'pm.current_regular_price', type: 'number' }, currentRegularPrice: 'pm.current_regular_price',
currentCostPrice: { dbCol: 'pm.current_cost_price', type: 'number' }, currentCostPrice: 'pm.current_cost_price',
currentLandingCostPrice: { dbCol: 'pm.current_landing_cost_price', type: 'number' }, currentLandingCostPrice: 'pm.current_landing_cost_price',
currentStock: { dbCol: 'pm.current_stock', type: 'number' }, currentStock: 'pm.current_stock',
currentStockCost: { dbCol: 'pm.current_stock_cost', type: 'number' }, currentStockCost: 'pm.current_stock_cost',
currentStockRetail: { dbCol: 'pm.current_stock_retail', type: 'number' }, currentStockRetail: 'pm.current_stock_retail',
currentStockGross: { dbCol: 'pm.current_stock_gross', type: 'number' }, currentStockGross: 'pm.current_stock_gross',
onOrderQty: { dbCol: 'pm.on_order_qty', type: 'number' }, onOrderQty: 'pm.on_order_qty',
onOrderCost: { dbCol: 'pm.on_order_cost', type: 'number' }, onOrderCost: 'pm.on_order_cost',
onOrderRetail: { dbCol: 'pm.on_order_retail', type: 'number' }, onOrderRetail: 'pm.on_order_retail',
earliestExpectedDate: { dbCol: 'pm.earliest_expected_date', type: 'date' }, earliestExpectedDate: 'pm.earliest_expected_date',
// Historical Dates // Historical Dates
dateCreated: { dbCol: 'pm.date_created', type: 'date' }, dateCreated: 'pm.date_created',
dateFirstReceived: { dbCol: 'pm.date_first_received', type: 'date' }, dateFirstReceived: 'pm.date_first_received',
dateLastReceived: { dbCol: 'pm.date_last_received', type: 'date' }, dateLastReceived: 'pm.date_last_received',
dateFirstSold: { dbCol: 'pm.date_first_sold', type: 'date' }, dateFirstSold: 'pm.date_first_sold',
dateLastSold: { dbCol: 'pm.date_last_sold', type: 'date' }, dateLastSold: 'pm.date_last_sold',
ageDays: { dbCol: 'pm.age_days', type: 'number' }, ageDays: 'pm.age_days',
// Rolling Period Metrics // Rolling Period Metrics
sales7d: { dbCol: 'pm.sales_7d', type: 'number' }, revenue7d: { dbCol: 'pm.revenue_7d', type: 'number' }, sales7d: 'pm.sales_7d',
sales14d: { dbCol: 'pm.sales_14d', type: 'number' }, revenue14d: { dbCol: 'pm.revenue_14d', type: 'number' }, revenue7d: 'pm.revenue_7d',
sales30d: { dbCol: 'pm.sales_30d', type: 'number' }, revenue30d: { dbCol: 'pm.revenue_30d', type: 'number' }, sales14d: 'pm.sales_14d',
cogs30d: { dbCol: 'pm.cogs_30d', type: 'number' }, profit30d: { dbCol: 'pm.profit_30d', type: 'number' }, revenue14d: 'pm.revenue_14d',
returnsUnits30d: { dbCol: 'pm.returns_units_30d', type: 'number' }, returnsRevenue30d: { dbCol: 'pm.returns_revenue_30d', type: 'number' }, sales30d: 'pm.sales_30d',
discounts30d: { dbCol: 'pm.discounts_30d', type: 'number' }, grossRevenue30d: { dbCol: 'pm.gross_revenue_30d', type: 'number' }, revenue30d: 'pm.revenue_30d',
grossRegularRevenue30d: { dbCol: 'pm.gross_regular_revenue_30d', type: 'number' }, cogs30d: 'pm.cogs_30d',
stockoutDays30d: { dbCol: 'pm.stockout_days_30d', type: 'number' }, profit30d: 'pm.profit_30d',
sales365d: { dbCol: 'pm.sales_365d', type: 'number' }, revenue365d: { dbCol: 'pm.revenue_365d', type: 'number' }, returnsUnits30d: 'pm.returns_units_30d',
avgStockUnits30d: { dbCol: 'pm.avg_stock_units_30d', type: 'number' }, avgStockCost30d: { dbCol: 'pm.avg_stock_cost_30d', type: 'number' }, returnsRevenue30d: 'pm.returns_revenue_30d',
avgStockRetail30d: { dbCol: 'pm.avg_stock_retail_30d', type: 'number' }, avgStockGross30d: { dbCol: 'pm.avg_stock_gross_30d', type: 'number' }, discounts30d: 'pm.discounts_30d',
receivedQty30d: { dbCol: 'pm.received_qty_30d', type: 'number' }, receivedCost30d: { dbCol: 'pm.received_cost_30d', type: 'number' }, grossRevenue30d: 'pm.gross_revenue_30d',
grossRegularRevenue30d: 'pm.gross_regular_revenue_30d',
stockoutDays30d: 'pm.stockout_days_30d',
sales365d: 'pm.sales_365d',
revenue365d: 'pm.revenue_365d',
avgStockUnits30d: 'pm.avg_stock_units_30d',
avgStockCost30d: 'pm.avg_stock_cost_30d',
avgStockRetail30d: 'pm.avg_stock_retail_30d',
avgStockGross30d: 'pm.avg_stock_gross_30d',
receivedQty30d: 'pm.received_qty_30d',
receivedCost30d: 'pm.received_cost_30d',
// Lifetime Metrics // Lifetime Metrics
lifetimeSales: { dbCol: 'pm.lifetime_sales', type: 'number' }, lifetimeRevenue: { dbCol: 'pm.lifetime_revenue', type: 'number' }, lifetimeSales: 'pm.lifetime_sales',
lifetimeRevenue: 'pm.lifetime_revenue',
// First Period Metrics // First Period Metrics
first7DaysSales: { dbCol: 'pm.first_7_days_sales', type: 'number' }, first7DaysRevenue: { dbCol: 'pm.first_7_days_revenue', type: 'number' }, first7DaysSales: 'pm.first_7_days_sales',
first30DaysSales: { dbCol: 'pm.first_30_days_sales', type: 'number' }, first30DaysRevenue: { dbCol: 'pm.first_30_days_revenue', type: 'number' }, first7DaysRevenue: 'pm.first_7_days_revenue',
first60DaysSales: { dbCol: 'pm.first_60_days_sales', type: 'number' }, first60DaysRevenue: { dbCol: 'pm.first_60_days_revenue', type: 'number' }, first30DaysSales: 'pm.first_30_days_sales',
first90DaysSales: { dbCol: 'pm.first_90_days_sales', type: 'number' }, first90DaysRevenue: { dbCol: 'pm.first_90_days_revenue', type: 'number' }, first30DaysRevenue: 'pm.first_30_days_revenue',
first60DaysSales: 'pm.first_60_days_sales',
first60DaysRevenue: 'pm.first_60_days_revenue',
first90DaysSales: 'pm.first_90_days_sales',
first90DaysRevenue: 'pm.first_90_days_revenue',
// Calculated KPIs // Calculated KPIs
asp30d: { dbCol: 'pm.asp_30d', type: 'number' }, acp30d: { dbCol: 'pm.acp_30d', type: 'number' }, avgRos30d: { dbCol: 'pm.avg_ros_30d', type: 'number' }, asp30d: 'pm.asp_30d',
avgSalesPerDay30d: { dbCol: 'pm.avg_sales_per_day_30d', type: 'number' }, avgSalesPerMonth30d: { dbCol: 'pm.avg_sales_per_month_30d', type: 'number' }, acp30d: 'pm.acp_30d',
margin30d: { dbCol: 'pm.margin_30d', type: 'number' }, markup30d: { dbCol: 'pm.markup_30d', type: 'number' }, gmroi30d: { dbCol: 'pm.gmroi_30d', type: 'number' }, avgRos30d: 'pm.avg_ros_30d',
stockturn30d: { dbCol: 'pm.stockturn_30d', type: 'number' }, returnRate30d: { dbCol: 'pm.return_rate_30d', type: 'number' }, avgSalesPerDay30d: 'pm.avg_sales_per_day_30d',
discountRate30d: { dbCol: 'pm.discount_rate_30d', type: 'number' }, stockoutRate30d: { dbCol: 'pm.stockout_rate_30d', type: 'number' }, avgSalesPerMonth30d: 'pm.avg_sales_per_month_30d',
markdown30d: { dbCol: 'pm.markdown_30d', type: 'number' }, markdownRate30d: { dbCol: 'pm.markdown_rate_30d', type: 'number' }, margin30d: 'pm.margin_30d',
sellThrough30d: { dbCol: 'pm.sell_through_30d', type: 'number' }, avgLeadTimeDays: { dbCol: 'pm.avg_lead_time_days', type: 'number' }, markup30d: 'pm.markup_30d',
gmroi30d: 'pm.gmroi_30d',
stockturn30d: 'pm.stockturn_30d',
returnRate30d: 'pm.return_rate_30d',
discountRate30d: 'pm.discount_rate_30d',
stockoutRate30d: 'pm.stockout_rate_30d',
markdown30d: 'pm.markdown_30d',
markdownRate30d: 'pm.markdown_rate_30d',
sellThrough30d: 'pm.sell_through_30d',
avgLeadTimeDays: 'pm.avg_lead_time_days',
// Forecasting & Replenishment // Forecasting & Replenishment
abcClass: { dbCol: 'pm.abc_class', type: 'string' }, salesVelocityDaily: { dbCol: 'pm.sales_velocity_daily', type: 'number' }, abcClass: 'pm.abc_class',
configLeadTime: { dbCol: 'pm.config_lead_time', type: 'number' }, configDaysOfStock: { dbCol: 'pm.config_days_of_stock', type: 'number' }, salesVelocityDaily: 'pm.sales_velocity_daily',
configSafetyStock: { dbCol: 'pm.config_safety_stock', type: 'number' }, planningPeriodDays: { dbCol: 'pm.planning_period_days', type: 'number' }, configLeadTime: 'pm.config_lead_time',
leadTimeForecastUnits: { dbCol: 'pm.lead_time_forecast_units', type: 'number' }, daysOfStockForecastUnits: { dbCol: 'pm.days_of_stock_forecast_units', type: 'number' }, configDaysOfStock: 'pm.config_days_of_stock',
planningPeriodForecastUnits: { dbCol: 'pm.planning_period_forecast_units', type: 'number' }, leadTimeClosingStock: { dbCol: 'pm.lead_time_closing_stock', type: 'number' }, configSafetyStock: 'pm.config_safety_stock',
daysOfStockClosingStock: { dbCol: 'pm.days_of_stock_closing_stock', type: 'number' }, replenishmentNeededRaw: { dbCol: 'pm.replenishment_needed_raw', type: 'number' }, planningPeriodDays: 'pm.planning_period_days',
replenishmentUnits: { dbCol: 'pm.replenishment_units', type: 'number' }, replenishmentCost: { dbCol: 'pm.replenishment_cost', type: 'number' }, leadTimeForecastUnits: 'pm.lead_time_forecast_units',
replenishmentRetail: { dbCol: 'pm.replenishment_retail', type: 'number' }, replenishmentProfit: { dbCol: 'pm.replenishment_profit', type: 'number' }, daysOfStockForecastUnits: 'pm.days_of_stock_forecast_units',
toOrderUnits: { dbCol: 'pm.to_order_units', type: 'number' }, forecastLostSalesUnits: { dbCol: 'pm.forecast_lost_sales_units', type: 'number' }, planningPeriodForecastUnits: 'pm.planning_period_forecast_units',
forecastLostRevenue: { dbCol: 'pm.forecast_lost_revenue', type: 'number' }, stockCoverInDays: { dbCol: 'pm.stock_cover_in_days', type: 'number' }, leadTimeClosingStock: 'pm.lead_time_closing_stock',
poCoverInDays: { dbCol: 'pm.po_cover_in_days', type: 'number' }, sellsOutInDays: { dbCol: 'pm.sells_out_in_days', type: 'number' }, daysOfStockClosingStock: 'pm.days_of_stock_closing_stock',
replenishDate: { dbCol: 'pm.replenish_date', type: 'date' }, overstockedUnits: { dbCol: 'pm.overstocked_units', type: 'number' }, replenishmentNeededRaw: 'pm.replenishment_needed_raw',
overstockedCost: { dbCol: 'pm.overstocked_cost', type: 'number' }, overstockedRetail: { dbCol: 'pm.overstocked_retail', type: 'number' }, replenishmentUnits: 'pm.replenishment_units',
isOldStock: { dbCol: 'pm.is_old_stock', type: 'boolean' }, replenishmentCost: 'pm.replenishment_cost',
replenishmentRetail: 'pm.replenishment_retail',
replenishmentProfit: 'pm.replenishment_profit',
toOrderUnits: 'pm.to_order_units',
forecastLostSalesUnits: 'pm.forecast_lost_sales_units',
forecastLostRevenue: 'pm.forecast_lost_revenue',
stockCoverInDays: 'pm.stock_cover_in_days',
poCoverInDays: 'pm.po_cover_in_days',
sellsOutInDays: 'pm.sells_out_in_days',
replenishDate: 'pm.replenish_date',
overstockedUnits: 'pm.overstocked_units',
overstockedCost: 'pm.overstocked_cost',
overstockedRetail: 'pm.overstocked_retail',
isOldStock: 'pm.is_old_stock',
// Yesterday // Yesterday
yesterdaySales: { dbCol: 'pm.yesterday_sales', type: 'number' }, yesterdaySales: 'pm.yesterday_sales',
// Map status column - directly mapped now instead of calculated on frontend
status: 'pm.status'
}; };
function getSafeColumnInfo(queryParamKey) { // Map of column types for proper sorting
return COLUMN_MAP[queryParamKey] || null; const COLUMN_TYPES = {
// Numeric columns
pid: 'number',
currentPrice: 'number',
currentRegularPrice: 'number',
currentCostPrice: 'number',
currentLandingCostPrice: 'number',
currentStock: 'number',
currentStockCost: 'number',
currentStockRetail: 'number',
currentStockGross: 'number',
onOrderQty: 'number',
onOrderCost: 'number',
onOrderRetail: 'number',
ageDays: 'number',
sales7d: 'number',
revenue7d: 'number',
sales14d: 'number',
revenue14d: 'number',
sales30d: 'number',
revenue30d: 'number',
cogs30d: 'number',
profit30d: 'number',
// ... other numeric columns
// Date columns
dateCreated: 'date',
dateFirstReceived: 'date',
dateLastReceived: 'date',
dateFirstSold: 'date',
dateLastSold: 'date',
earliestExpectedDate: 'date',
replenishDate: 'date',
// Status column - special handling
status: 'status',
// String columns default to 'string' type
// Boolean columns
isVisible: 'boolean',
isReplenishable: 'boolean',
isOldStock: 'boolean'
};
// Special sort handling for certain columns
const SPECIAL_SORT_COLUMNS = {
// Percentage columns where we want to sort by the numeric value
margin30d: true,
markup30d: true,
sellThrough30d: true,
discountRate30d: true,
stockoutRate30d: true,
returnRate30d: true,
markdownRate30d: true,
// Columns where we may want to sort by absolute value
profit30d: 'abs',
// Velocity columns
salesVelocityDaily: true,
// Status column needs special ordering
status: 'priority'
};
// Status priority for sorting (lower number = higher priority)
const STATUS_PRIORITY = {
'Critical': 1,
'At Risk': 2,
'Reorder': 3,
'Overstocked': 4,
'Healthy': 5,
'New': 6
// Any other status will be sorted alphabetically after these
};
// Get database column name from frontend column name
function getDbColumn(frontendColumn) {
return COLUMN_MAP[frontendColumn] || 'pm.title'; // Default to title if not found
}
// Get column type for proper sorting
function getColumnType(frontendColumn) {
return COLUMN_TYPES[frontendColumn] || 'string';
} }
// --- Route Handlers --- // --- Route Handlers ---
@@ -121,7 +241,7 @@ router.get('/filter-options', async (req, res) => {
// GET /metrics/ - List all product metrics with filtering, sorting, pagination // GET /metrics/ - List all product metrics with filtering, sorting, pagination
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const pool = req.app.locals.pool; // Get pool from app instance const pool = req.app.locals.pool;
console.log('GET /metrics received query:', req.query); console.log('GET /metrics received query:', req.query);
try { try {
@@ -135,10 +255,45 @@ router.get('/', async (req, res) => {
// --- Sorting --- // --- Sorting ---
const sortQueryKey = req.query.sort || 'title'; // Default sort field key const sortQueryKey = req.query.sort || 'title'; // Default sort field key
const sortColumnInfo = getSafeColumnInfo(sortQueryKey); const dbColumn = getDbColumn(sortQueryKey);
const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'pm.title'; // Default DB column const columnType = getColumnType(sortQueryKey);
console.log(`Sorting request: ${sortQueryKey} -> ${dbColumn} (${columnType})`);
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST'); // Consistent null handling
// Always put nulls last regardless of sort direction or column type
const nullsOrder = 'NULLS LAST';
// Build the ORDER BY clause based on column type and special handling
let orderByClause;
if (SPECIAL_SORT_COLUMNS[sortQueryKey] === 'abs') {
// Sort by absolute value for columns where negative values matter
orderByClause = `ABS(${dbColumn}::numeric) ${sortDirection} ${nullsOrder}`;
} else if (columnType === 'number' || SPECIAL_SORT_COLUMNS[sortQueryKey] === true) {
// For numeric columns, cast to numeric to ensure proper sorting
orderByClause = `${dbColumn}::numeric ${sortDirection} ${nullsOrder}`;
} else if (columnType === 'date') {
// For date columns, cast to timestamp to ensure proper sorting
orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn}::timestamp ${sortDirection}`;
} else if (columnType === 'status' || SPECIAL_SORT_COLUMNS[sortQueryKey] === 'priority') {
// Special handling for status column, using priority for known statuses
orderByClause = `
CASE WHEN ${dbColumn} IS NULL THEN 999
WHEN ${dbColumn} = 'Critical' THEN 1
WHEN ${dbColumn} = 'At Risk' THEN 2
WHEN ${dbColumn} = 'Reorder' THEN 3
WHEN ${dbColumn} = 'Overstocked' THEN 4
WHEN ${dbColumn} = 'Healthy' THEN 5
WHEN ${dbColumn} = 'New' THEN 6
ELSE 100
END ${sortDirection} ${nullsOrder},
${dbColumn} ${sortDirection}`;
} else {
// For string and boolean columns, no special casting needed
orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn} ${sortDirection}`;
}
// --- Filtering --- // --- Filtering ---
const conditions = []; const conditions = [];
@@ -149,9 +304,24 @@ router.get('/', async (req, res) => {
if (req.query.showInvisible !== 'true') conditions.push(`pm.is_visible = true`); if (req.query.showInvisible !== 'true') conditions.push(`pm.is_visible = true`);
if (req.query.showNonReplenishable !== 'true') conditions.push(`pm.is_replenishable = true`); if (req.query.showNonReplenishable !== 'true') conditions.push(`pm.is_replenishable = true`);
// Special handling for stock_status
if (req.query.stock_status) {
const status = req.query.stock_status;
// Handle special case for "at-risk" which is stored as "At Risk" in the database
if (status.toLowerCase() === 'at-risk') {
conditions.push(`pm.status = $${paramCounter++}`);
params.push('At Risk');
} else {
// Capitalize first letter to match database values
conditions.push(`pm.status = $${paramCounter++}`);
params.push(status.charAt(0).toUpperCase() + status.slice(1));
}
}
// Process other filters from query parameters // Process other filters from query parameters
for (const key in req.query) { for (const key in req.query) {
if (['page', 'limit', 'sort', 'order', 'showInvisible', 'showNonReplenishable'].includes(key)) continue; // Skip control params // Skip control params
if (['page', 'limit', 'sort', 'order', 'showInvisible', 'showNonReplenishable', 'stock_status'].includes(key)) continue;
let filterKey = key; let filterKey = key;
let operator = '='; // Default operator let operator = '='; // Default operator
@@ -164,15 +334,15 @@ router.get('/', async (req, res) => {
operator = operatorMatch[2]; // e.g., "gt" operator = operatorMatch[2]; // e.g., "gt"
} }
const columnInfo = getSafeColumnInfo(filterKey); // Get the database column for this filter key
if (!columnInfo) { const dbColumn = getDbColumn(filterKey);
const valueType = getColumnType(filterKey);
if (!dbColumn) {
console.warn(`Invalid filter key ignored: ${key}`); console.warn(`Invalid filter key ignored: ${key}`);
continue; // Skip if the key doesn't map to a known column continue; // Skip if the key doesn't map to a known column
} }
const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type;
// --- Build WHERE clause fragment --- // --- Build WHERE clause fragment ---
try { try {
let conditionFragment = ''; let conditionFragment = '';
@@ -234,6 +404,10 @@ router.get('/', async (req, res) => {
// --- Construct and Execute Queries --- // --- Construct and Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Debug log of conditions and parameters
console.log('Constructed WHERE conditions:', conditions);
console.log('Parameters:', params);
// Count Query // Count Query
const countSql = `SELECT COUNT(*) AS total FROM public.product_metrics pm ${whereClause}`; const countSql = `SELECT COUNT(*) AS total FROM public.product_metrics pm ${whereClause}`;
console.log('Executing Count Query:', countSql, params); console.log('Executing Count Query:', countSql, params);
@@ -244,11 +418,20 @@ router.get('/', async (req, res) => {
SELECT pm.* SELECT pm.*
FROM public.product_metrics pm FROM public.product_metrics pm
${whereClause} ${whereClause}
ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder} ORDER BY ${orderByClause}
LIMIT $${paramCounter} OFFSET $${paramCounter + 1} LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`; `;
const dataParams = [...params, limit, offset]; const dataParams = [...params, limit, offset];
console.log('Executing Data Query:', dataSql, dataParams);
// Log detailed query information for debugging
console.log('Executing Data Query:');
console.log(' - Sort Column:', dbColumn);
console.log(' - Column Type:', columnType);
console.log(' - Sort Direction:', sortDirection);
console.log(' - Order By Clause:', orderByClause);
console.log(' - Full SQL:', dataSql);
console.log(' - Parameters:', dataParams);
const dataPromise = pool.query(dataSql, dataParams); const dataPromise = pool.query(dataSql, dataParams);
// Execute queries in parallel // Execute queries in parallel

View File

@@ -5,18 +5,28 @@
function parseValue(value, type) { function parseValue(value, type) {
if (value === null || value === undefined || value === '') return null; if (value === null || value === undefined || value === '') return null;
console.log(`Parsing value: "${value}" as type: "${type}"`);
switch (type) { switch (type) {
case 'number': case 'number':
const num = parseFloat(value); const num = parseFloat(value);
if (isNaN(num)) throw new Error(`Invalid number format: "${value}"`); if (isNaN(num)) {
console.error(`Invalid number format: "${value}"`);
throw new Error(`Invalid number format: "${value}"`);
}
return num; return num;
case 'integer': // Specific type for integer IDs etc. case 'integer': // Specific type for integer IDs etc.
const int = parseInt(value, 10); const int = parseInt(value, 10);
if (isNaN(int)) throw new Error(`Invalid integer format: "${value}"`); if (isNaN(int)) {
console.error(`Invalid integer format: "${value}"`);
throw new Error(`Invalid integer format: "${value}"`);
}
console.log(`Successfully parsed integer: ${int}`);
return int; return int;
case 'boolean': case 'boolean':
if (String(value).toLowerCase() === 'true') return true; if (String(value).toLowerCase() === 'true') return true;
if (String(value).toLowerCase() === 'false') return false; if (String(value).toLowerCase() === 'false') return false;
console.error(`Invalid boolean format: "${value}"`);
throw new Error(`Invalid boolean format: "${value}"`); throw new Error(`Invalid boolean format: "${value}"`);
case 'date': case 'date':
// Basic ISO date format validation (YYYY-MM-DD) // Basic ISO date format validation (YYYY-MM-DD)

View File

@@ -107,7 +107,7 @@ export function AppSidebar() {
className="w-6 h-6 object-contain -rotate-12 transform hover:rotate-0 transition-transform ease-in-out duration-300" className="w-6 h-6 object-contain -rotate-12 transform hover:rotate-0 transition-transform ease-in-out duration-300"
/> />
</div> </div>
<div className="ml-2 transition-all duration-200 whitespace-nowrap group-[.group[data-state=collapsed]]:hidden"> <div className="ml-1 transition-all duration-200 whitespace-nowrap group-[.group[data-state=collapsed]]:hidden">
<span className="font-bold text-lg">A Cherry On Bottom</span> <span className="font-bold text-lg">A Cherry On Bottom</span>
</div> </div>
</div> </div>

View File

@@ -53,12 +53,14 @@ type CategorySortableColumns =
| "activeProductCount" | "activeProductCount"
| "currentStockUnits" | "currentStockUnits"
| "currentStockCost" | "currentStockCost"
| "revenue_7d" | "currentStockRetail"
| "revenue_30d" | "revenue7d"
| "profit_30d" | "revenue30d"
| "sales_30d" | "profit30d"
| "avg_margin_30d" | "sales30d"
| "stock_turn_30d"; | "avgMargin30d"
| "stockTurn30d"
| "status";
interface CategoryMetric { interface CategoryMetric {
// Assuming category_id is unique primary identifier in category_metrics // Assuming category_id is unique primary identifier in category_metrics
@@ -375,17 +377,26 @@ export function Categories() {
if (filters.search) { if (filters.search) {
params.set("categoryName_ilike", filters.search); params.set("categoryName_ilike", filters.search);
} }
if (filters.type !== "all") { if (filters.type !== "all") {
// The backend expects categoryType_eq
// The type is stored as integer in the database
console.log(`Setting categoryType_eq to: ${filters.type}`);
params.set("categoryType_eq", filters.type); params.set("categoryType_eq", filters.type);
} }
if (filters.status !== "all") { if (filters.status !== "all") {
params.set("status", filters.status); params.set("status", filters.status);
} }
// Only filter by active products if explicitly requested // Only filter by active products if explicitly requested
if (!filters.showInactive) { if (!filters.showInactive) {
params.set("activeProductCount_gt", "0"); params.set("activeProductCount_gt", "0");
} }
console.log("Filters:", filters);
console.log("Query params:", params.toString());
return params; return params;
}, [sortColumn, sortDirection, filters]); }, [sortColumn, sortDirection, filters]);
@@ -397,21 +408,34 @@ export function Categories() {
} = useQuery<CategoryResponse, Error>({ } = useQuery<CategoryResponse, Error>({
queryKey: ["categories-all", queryParams.toString()], queryKey: ["categories-all", queryParams.toString()],
queryFn: async () => { queryFn: async () => {
const response = await fetch( const url = `${config.apiUrl}/categories-aggregate?${queryParams.toString()}`;
`${config.apiUrl}/categories-aggregate?${queryParams.toString()}`, console.log("Fetching categories from URL:", url);
{
const response = await fetch(url, {
credentials: "include", credentials: "include",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
}, },
} });
);
if (!response.ok) { if (!response.ok) {
throw new Error(`Network response was not ok (${response.status})`); throw new Error(`Network response was not ok (${response.status})`);
} }
return response.json();
const data = await response.json();
console.log(`Received ${data.categories.length} categories from API`);
if (filters.type !== "all") {
// Check if any categories match the filter
const matchingCategories = data.categories.filter(
(cat: CategoryMetric) => cat.category_type.toString() === filters.type
);
console.log(`Filter type=${filters.type}: ${matchingCategories.length} matching categories found`);
}
return data;
}, },
staleTime: 0, staleTime: 0,
}); });
@@ -447,7 +471,9 @@ export function Categories() {
); );
if (!response.ok) if (!response.ok)
throw new Error("Failed to fetch category filter options"); throw new Error("Failed to fetch category filter options");
return response.json(); const data = await response.json();
console.log("Filter options:", data);
return data;
}, },
}); });
@@ -459,6 +485,7 @@ export function Categories() {
// Build the hierarchical tree structure // Build the hierarchical tree structure
const hierarchicalCategories = useMemo(() => { const hierarchicalCategories = useMemo(() => {
console.log(`Building hierarchical structure from ${categories.length} categories`);
if (!categories || categories.length === 0) return []; if (!categories || categories.length === 0) return [];
// DIRECT CALCULATION: Create a map to directly calculate accurate totals // DIRECT CALCULATION: Create a map to directly calculate accurate totals
@@ -479,6 +506,11 @@ export function Categories() {
} }
}); });
// For filtered results, we need a different approach to handle missing parents
// If we've filtered by type, we might have categories without their parents in the results
const isFiltered = filters.type !== 'all' || filters.search !== '';
console.log(`isFiltered: ${isFiltered}, type: ${filters.type}, search: "${filters.search}"`);
// Build sets of all descendants for each category // Build sets of all descendants for each category
const allDescendantsMap = new Map<string | number, Set<string | number>>(); const allDescendantsMap = new Map<string | number, Set<string | number>>();
@@ -544,6 +576,60 @@ export function Categories() {
categoryMap.set(cat.category_id, { ...cat, children: [] }); categoryMap.set(cat.category_id, { ...cat, children: [] });
}); });
// When filtering by type, we need to change our approach - all categories should
// be treated as root level categories since we explicitly want to see them
if (isFiltered) {
console.log(`Using flat structure for filtered results (${categories.length} items)`);
// For filtered results, just show a flat list
const filteredCategories = categories.map(cat => {
const processedCat = categoryMap.get(cat.category_id);
if (!processedCat) return null;
// Give these categories a direct parent-child relationship based on parent_id
// if the parent also exists in the filtered results
if (cat.parent_id && categoryMap.has(cat.parent_id)) {
const parent = categoryMap.get(cat.parent_id);
if (parent) {
parent.children.push(processedCat);
}
}
return processedCat;
}).filter(Boolean) as CategoryWithChildren[];
// Only return top-level categories after creating parent-child relationships
const rootFilteredCategories = filteredCategories.filter(cat =>
!cat.parent_id || !categoryMap.has(cat.parent_id)
);
console.log(`Returning ${rootFilteredCategories.length} root filtered categories with type = ${filters.type}`);
// Apply hierarchy levels
const computeHierarchyAndLevels = (
categories: CategoryWithChildren[],
level = 0
) => {
return categories.map((cat, index, arr) => {
// Set hierarchy level
cat.hierarchyLevel = level;
cat.isLast = index === arr.length - 1;
cat.isExpanded = expandedCategories.has(cat.category_id);
// Process children to set their hierarchy levels
const children =
cat.children.length > 0
? computeHierarchyAndLevels(cat.children, level + 1)
: [];
// Aggregated stats already set above
return cat;
});
};
return computeHierarchyAndLevels(rootFilteredCategories);
}
// Regular hierarchical structure for unfiltered results
// Then organize into a hierarchical structure // Then organize into a hierarchical structure
const rootCategories: CategoryWithChildren[] = []; const rootCategories: CategoryWithChildren[] = [];
@@ -618,9 +704,18 @@ export function Categories() {
// Apply hierarchy levels and use our pre-calculated totals // Apply hierarchy levels and use our pre-calculated totals
const result = computeHierarchyAndLevels(rootCategories); const result = computeHierarchyAndLevels(rootCategories);
console.log(`Returning ${result.length} hierarchical categories`);
return result; return result;
}, [categories, expandedCategories]); }, [categories, expandedCategories, filters.type, filters.search]);
// Check if there are no categories to display and explain why
useEffect(() => {
if (hierarchicalCategories.length === 0 && categories.length > 0) {
console.log("Warning: No hierarchical categories to display even though API returned", categories.length, "categories");
console.log("Filter settings:", filters);
}
}, [hierarchicalCategories, categories, filters]);
// Recursive function to render category rows with streamlined stat display // Recursive function to render category rows with streamlined stat display
const renderCategoryRow = ( const renderCategoryRow = (
@@ -882,45 +977,51 @@ export function Categories() {
// where it has access to all required variables // where it has access to all required variables
const renderGroupedCategories = () => { const renderGroupedCategories = () => {
if (isLoadingAll) { if (isLoadingAll) {
return Array.from({ length: 5 }).map((_, i) => ( return Array.from({ length: 10 }).map((_, i) => (
<TableRow key={`skel-${i}`} className="h-16"> <TableRow key={`skel-${i}`} className="h-16">
<TableCell> <TableCell className="w-[25%]">
<div className="flex items-center">
<span className="inline-block h-6 w-6 mr-1"></span>
<Skeleton className="h-5 w-40" /> <Skeleton className="h-5 w-40" />
</div>
</TableCell> </TableCell>
<TableCell> <TableCell className="w-[95px]">
<Skeleton className="h-5 w-20" /> <Skeleton className="h-5 w-full" />
</TableCell> </TableCell>
<TableCell> <TableCell className="w-[15%]">
<Skeleton className="h-5 w-20" /> <Skeleton className="h-5 w-full" />
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-16 ml-auto" /> <Skeleton className="h-5 w-full ml-auto" />
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-16 ml-auto" /> <Skeleton className="h-5 w-full ml-auto" />
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-20 ml-auto" /> <Skeleton className="h-5 w-full ml-auto" />
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-20 ml-auto" /> <Skeleton className="h-5 w-full ml-auto" />
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-20 ml-auto" /> <Skeleton className="h-5 w-full ml-auto" />
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-16 ml-auto" /> <Skeleton className="h-5 w-full ml-auto" />
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right w-[6%]">
<Skeleton className="h-5 w-16 ml-auto" /> <Skeleton className="h-5 w-full ml-auto" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-5 w-16 ml-auto" />
</TableCell> </TableCell>
</TableRow> </TableRow>
)); ));
} }
console.log("Rendering categories:", {
hierarchicalCategories: hierarchicalCategories?.length || 0,
categories: categories?.length || 0,
filters
});
if (!hierarchicalCategories || hierarchicalCategories.length === 0) { if (!hierarchicalCategories || hierarchicalCategories.length === 0) {
// Check hierarchicalCategories directly // Check hierarchicalCategories directly
return ( return (
@@ -929,18 +1030,28 @@ export function Categories() {
colSpan={11} colSpan={11}
className="h-16 text-center py-8 text-muted-foreground" className="h-16 text-center py-8 text-muted-foreground"
> >
{filters.search || filters.type !== "all" || !filters.showInactive {categories && categories.length > 0 ? (
<>
<p>We found {categories.length} matching categories but encountered an issue displaying them.</p>
<p className="mt-2">Try adjusting your filter criteria or refreshing the page.</p>
</>
) : (
filters.search || filters.type !== "all" || !filters.showInactive
? "No categories found matching your criteria. Try adjusting filters." ? "No categories found matching your criteria. Try adjusting filters."
: "No categories available."} : "No categories available."
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
} }
// Directly render the hierarchical tree roots // Directly render the hierarchical tree roots
return hierarchicalCategories const rows = hierarchicalCategories
.map((category) => renderCategoryRow(category)) .map((category) => renderCategoryRow(category))
.flat(); .flat();
console.log(`Rendering ${rows.length} total rows`);
return rows;
}; };
// --- Event Handlers --- // --- Event Handlers ---
@@ -948,8 +1059,8 @@ export function Categories() {
const handleSort = useCallback( const handleSort = useCallback(
(column: CategorySortableColumns) => { (column: CategorySortableColumns) => {
setSortDirection((prev) => { setSortDirection((prev) => {
if (sortColumn !== column) return "asc"; if (sortColumn !== column) return "desc";
return prev === "asc" ? "desc" : "asc"; return prev === "asc" ? "asc" : "desc";
}); });
setSortColumn(column); setSortColumn(column);
@@ -961,7 +1072,13 @@ export function Categories() {
const handleFilterChange = useCallback( const handleFilterChange = useCallback(
(filterName: keyof CategoryFilters, value: string | boolean) => { (filterName: keyof CategoryFilters, value: string | boolean) => {
console.log(`Filter change: ${filterName} = ${value} (${typeof value})`);
setFilters((prev) => ({ ...prev, [filterName]: value })); setFilters((prev) => ({ ...prev, [filterName]: value }));
// Debug the type filter when changed
if (filterName === 'type') {
console.log(`Type filter changed to: ${value}`);
}
}, },
[] []
); );
@@ -973,6 +1090,14 @@ export function Categories() {
} }
}, [listError]); }, [listError]);
// Log when filter options are received
useEffect(() => {
if (filterOptions) {
console.log("Filter options loaded:", filterOptions);
console.log("Available types:", filterOptions.types);
}
}, [filterOptions]);
// --- Rendering --- // --- Rendering ---
return ( return (
@@ -1135,7 +1260,7 @@ export function Categories() {
<TableRow> <TableRow>
<TableHead <TableHead
onClick={() => handleSort("categoryName")} onClick={() => handleSort("categoryName")}
className="cursor-pointer w-[25%]" className="h-16 cursor-pointer w-[25%]"
> >
Name Name
<SortIndicator active={sortColumn === "categoryName"} /> <SortIndicator active={sortColumn === "categoryName"} />
@@ -1176,32 +1301,32 @@ export function Categories() {
<SortIndicator active={sortColumn === "currentStockCost"} /> <SortIndicator active={sortColumn === "currentStockCost"} />
</TableHead> </TableHead>
<TableHead <TableHead
onClick={() => handleSort("revenue_30d")} onClick={() => handleSort("revenue30d")}
className="cursor-pointer text-right w-[8%]" className="cursor-pointer text-right w-[8%]"
> >
Revenue (30d) Revenue (30d)
<SortIndicator active={sortColumn === "revenue_30d"} /> <SortIndicator active={sortColumn === "revenue30d"} />
</TableHead> </TableHead>
<TableHead <TableHead
onClick={() => handleSort("profit_30d")} onClick={() => handleSort("profit30d")}
className="cursor-pointer text-right w-[8%]" className="cursor-pointer text-right w-[8%]"
> >
Profit (30d) Profit (30d)
<SortIndicator active={sortColumn === "profit_30d"} /> <SortIndicator active={sortColumn === "profit30d"} />
</TableHead> </TableHead>
<TableHead <TableHead
onClick={() => handleSort("avg_margin_30d")} onClick={() => handleSort("avgMargin30d")}
className="cursor-pointer text-right w-[8%]" className="cursor-pointer text-right w-[8%]"
> >
Margin (30d) Margin (30d)
<SortIndicator active={sortColumn === "avg_margin_30d"} /> <SortIndicator active={sortColumn === "avgMargin30d"} />
</TableHead> </TableHead>
<TableHead <TableHead
onClick={() => handleSort("stock_turn_30d")} onClick={() => handleSort("stockTurn30d")}
className="cursor-pointer text-right w-[6%]" className="cursor-pointer text-right w-[6%]"
> >
Stock Turn (30d) Stock Turn (30d)
<SortIndicator active={sortColumn === "stock_turn_30d"} /> <SortIndicator active={sortColumn === "stockTurn30d"} />
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>

View File

@@ -55,17 +55,17 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'dateCreated', label: 'Created', group: 'Basic Info' }, { key: 'dateCreated', label: 'Created', group: 'Basic Info' },
// Current Status // Current Status
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (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?.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?.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?.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?.toString() ?? '-' }, { key: 'currentStock', label: 'Stock', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Stock', format: (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: 'Stock', format: (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: 'Stock', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'currentStockGross', label: 'Stock Gross', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'onOrderQty', label: 'On Order', group: 'Stock', format: (v) => v?.toString() ?? '-' }, { key: 'onOrderQty', label: 'On Order', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'onOrderCost', label: 'On Order Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'onOrderCost', label: 'On Order Cost', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'onOrderRetail', label: 'On Order Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'onOrderRetail', label: 'On Order Retail', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'earliestExpectedDate', label: 'Expected Date', group: 'Stock' }, { key: 'earliestExpectedDate', label: 'Expected Date', group: 'Stock' },
// Dates // Dates
@@ -73,41 +73,45 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
{ 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?.toString() ?? '-' }, { key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
// Product Status // Product Status
{ key: 'status', label: 'Status', group: 'Status' }, { key: 'status', label: 'Status', group: 'Status' },
// Rolling Metrics // Rolling Metrics
{ key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (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?.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?.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?.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?.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?.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?.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?.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?.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?.toFixed(2) ?? '-' }, { key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
// KPIs // KPIs
{ key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => 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 ? `${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?.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?.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 ? `${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?.toFixed(1) ?? '-' }, { key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Lead Time', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
// Replenishment // Replenishment
{ key: 'abcClass', label: 'ABC Class', group: 'Stock' }, { key: 'abcClass', label: 'ABC Class', group: 'Stock' },
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' }, { key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
{ key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' }, { key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
{ key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' }, { key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
{ key: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' }, { key: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'overstockedCost', label: 'Overstock Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'overstockedCost', label: 'Overstock Cost', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'overstockedRetail', label: 'Overstock Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'overstockedRetail', label: 'Overstock Retail', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'isOldStock', label: 'Old Stock', group: 'Stock' }, { key: 'isOldStock', label: 'Old Stock', group: 'Stock' },
{ key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v?.toString() ?? '-' }, { key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
// Config & Replenishment columns
{ key: 'configSafetyStock', label: 'Safety Stock', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'replenishmentUnits', label: 'Replenish Units', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
]; ];
// Define default columns for each view // Define default columns for each view
@@ -202,6 +206,10 @@ export function Products() {
const [filters, setFilters] = useState<Record<string, ActiveFilterValue>>({}); const [filters, setFilters] = useState<Record<string, ActiveFilterValue>>({});
const [sortColumn, setSortColumn] = useState<ProductMetricColumnKey>('title'); const [sortColumn, setSortColumn] = useState<ProductMetricColumnKey>('title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// Track last sort direction for each column
const [columnSortDirections, setColumnSortDirections] = useState<Record<string, 'asc' | 'desc'>>({
'title': 'asc' // Initialize with default sort column and direction
});
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [activeView, setActiveView] = useState(searchParams.get('view') || "all"); const [activeView, setActiveView] = useState(searchParams.get('view') || "all");
const [pageSize] = useState(50); const [pageSize] = useState(50);
@@ -283,9 +291,19 @@ export function Products() {
Object.entries(filters).forEach(([key, value]) => { Object.entries(filters).forEach(([key, value]) => {
if (typeof value === 'object' && 'operator' in value) { if (typeof value === 'object' && 'operator' in value) {
transformedFilters[key] = value.value; // Convert the operator format to match what the backend expects
transformedFilters[`${key}_operator`] = value.operator; // 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 { } else {
// Simple values are passed as-is
transformedFilters[key] = value; transformedFilters[key] = value;
} }
}); });
@@ -301,9 +319,10 @@ export function Products() {
params.append('limit', pageSize.toString()); params.append('limit', pageSize.toString());
if (sortColumn) { if (sortColumn) {
// Convert camelCase to snake_case for the API // Don't convert camelCase to snake_case - use the column name directly
const snakeCaseSort = sortColumn.replace(/([A-Z])/g, '_$1').toLowerCase(); // as defined in the backend's COLUMN_MAP
params.append('sort', snakeCaseSort); console.log(`Sorting: ${sortColumn} (${sortDirection})`);
params.append('sort', sortColumn);
params.append('order', sortDirection); params.append('order', sortDirection);
} }
@@ -315,21 +334,22 @@ export function Products() {
const transformedFilters = transformFilters(filters); const transformedFilters = transformFilters(filters);
Object.entries(transformedFilters).forEach(([key, value]) => { Object.entries(transformedFilters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') { if (value !== undefined && value !== null && value !== '') {
// Convert camelCase to snake_case for the API // Don't convert camelCase to snake_case - use the filter name directly
const snakeCaseKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
if (Array.isArray(value)) { if (Array.isArray(value)) {
params.append(snakeCaseKey, JSON.stringify(value)); params.append(key, JSON.stringify(value));
} else { } else {
params.append(snakeCaseKey, value.toString()); params.append(key, value.toString());
} }
} }
}); });
if (!showNonReplenishable) { if (!showNonReplenishable) {
params.append('show_non_replenishable', 'false'); params.append('showNonReplenishable', 'false');
} }
// Log the final query parameters for debugging
console.log('API Query:', params.toString());
const response = await fetch(`/api/metrics?${params.toString()}`); const response = await fetch(`/api/metrics?${params.toString()}`);
if (!response.ok) throw new Error('Failed to fetch products'); if (!response.ok) throw new Error('Failed to fetch products');
@@ -350,8 +370,8 @@ export function Products() {
// Then handle regular snake_case -> camelCase // Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase()); camelKey = camelKey.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase());
// Convert numeric strings to actual numbers // Convert numeric strings to actual numbers, but handle empty strings properly
if (typeof value === 'string' && !isNaN(Number(value)) && if (typeof value === 'string' && value !== '' && !isNaN(Number(value)) &&
!key.toLowerCase().includes('date') && key !== 'sku' && key !== 'title' && !key.toLowerCase().includes('date') && key !== 'sku' && key !== 'title' &&
key !== 'brand' && key !== 'vendor') { key !== 'brand' && key !== 'vendor') {
transformed[camelKey] = Number(value); transformed[camelKey] = Number(value);
@@ -434,13 +454,45 @@ export function Products() {
} }
}, [currentPage, data?.pagination.pages]); }, [currentPage, data?.pagination.pages]);
// Handle sort column change // Handle sort column change with improved column-specific direction memory
const handleSort = (column: ProductMetricColumnKey) => { const handleSort = (column: ProductMetricColumnKey) => {
setSortDirection(prev => { let nextDirection: 'asc' | 'desc';
if (sortColumn !== column) return 'asc';
return prev === 'asc' ? 'desc' : 'asc'; if (sortColumn === column) {
}); // If clicking the same column, toggle direction
nextDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
// If clicking a different column:
// 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' ||
['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); setSortColumn(column);
// Remember this column's sort direction for next time
setColumnSortDirections(prev => ({
...prev,
[column]: nextDirection
}));
}; };
// Handle filter changes // Handle filter changes
@@ -626,13 +678,11 @@ export function Products() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<ProductTable <ProductTable
products={data?.products?.map((product: ProductMetric) => { products={data?.products?.map((product: ProductMetric) => ({
// Before returning the product, ensure it has a status for display ...product,
if (!product.status) { // No need to calculate status anymore since it comes from the backend
product.status = getProductStatus(product); status: product.status || 'Healthy' // Fallback only if status is null
} })) || []}
return product;
}) || []}
onSort={handleSort} onSort={handleSort}
sortColumn={sortColumn} sortColumn={sortColumn}
sortDirection={sortDirection} sortDirection={sortDirection}