diff --git a/inventory-server/db/metrics-schema-new.sql b/inventory-server/db/metrics-schema-new.sql index 3c7516a..7c5aa33 100644 --- a/inventory-server/db/metrics-schema-new.sql +++ b/inventory-server/db/metrics-schema-new.sql @@ -151,6 +151,9 @@ CREATE TABLE public.product_metrics ( -- Yesterday's Metrics (Refreshed Hourly from daily_product_snapshots) 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 ); @@ -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_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_status ON public.product_metrics(status); -- Index for status filtering -- Add new vendor, category, and brand metrics tables -- Drop tables in reverse order if they exist diff --git a/inventory-server/scripts/calculate-metrics-new.js b/inventory-server/scripts/calculate-metrics-new.js index b53ae99..e2e3bcb 100644 --- a/inventory-server/scripts/calculate-metrics-new.js +++ b/inventory-server/scripts/calculate-metrics-new.js @@ -5,11 +5,11 @@ const { Pool } = require('pg'); // Assuming you use 'pg' // --- Configuration --- // Toggle these constants to enable/disable specific steps for testing -const RUN_DAILY_SNAPSHOTS = false; -const RUN_PRODUCT_METRICS = false; -const RUN_PERIODIC_METRICS = false; -const RUN_BRAND_METRICS = false; -const RUN_VENDOR_METRICS = false; +const RUN_DAILY_SNAPSHOTS = true; +const RUN_PRODUCT_METRICS = true; +const RUN_PERIODIC_METRICS = true; +const RUN_BRAND_METRICS = true; +const RUN_VENDOR_METRICS = true; const RUN_CATEGORY_METRICS = true; // Maximum execution time for the entire sequence (e.g., 90 minutes) diff --git a/inventory-server/scripts/metrics-new/update_product_metrics.sql b/inventory-server/scripts/metrics-new/update_product_metrics.sql index 4d82a0f..4dff4d4 100644 --- a/inventory-server/scripts/metrics-new/update_product_metrics.sql +++ b/inventory-server/scripts/metrics-new/update_product_metrics.sql @@ -209,7 +209,8 @@ BEGIN to_order_units, forecast_lost_sales_units, forecast_lost_revenue, stock_cover_in_days, po_cover_in_days, sells_out_in_days, replenish_date, overstocked_units, overstocked_cost, overstocked_retail, is_old_stock, - yesterday_sales + yesterday_sales, + status -- Add status field for calculated status ) SELECT 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 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 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, 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, - 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 diff --git a/inventory-server/src/routes/categoriesAggregate.js b/inventory-server/src/routes/categoriesAggregate.js index b9527c8..455f950 100644 --- a/inventory-server/src/routes/categoriesAggregate.js +++ b/inventory-server/src/routes/categoriesAggregate.js @@ -178,6 +178,8 @@ router.get('/', async (req, res) => { const params = []; let paramCounter = 1; + console.log("Starting to process filters from query:", req.query); + // Add filters based on req.query using COLUMN_MAP and parseValue for (const key in req.query) { if (['page', 'limit', 'sort', 'order'].includes(key)) continue; @@ -185,11 +187,14 @@ router.get('/', async (req, res) => { let filterKey = key; let operator = '='; // Default operator 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)$/); if (operatorMatch) { filterKey = operatorMatch[1]; operator = operatorMatch[2]; + console.log(`Parsed filter key: "${filterKey}" with operator: "${operator}"`); } // Special case for parentName requires join @@ -197,6 +202,7 @@ router.get('/', async (req, res) => { const columnInfo = getSafeColumnInfo(filterKey); if (columnInfo) { + console.log(`Column info for "${filterKey}":`, columnInfo); const dbColumn = columnInfo.dbCol; const valueType = columnInfo.type; try { @@ -232,22 +238,46 @@ router.get('/', async (req, res) => { } if (needsParam) { - conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; - params.push(parseValue(value, valueType)); + 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++}`; + 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 conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; // paramCounter was already incremented in push } if (conditionFragment) { + console.log(`Adding condition: ${conditionFragment}`); conditions.push(`(${conditionFragment})`); } } 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 } } else { - console.warn(`Invalid filter key ignored: ${key}`); + console.warn(`Invalid filter key ignored: "${key}", not found in COLUMN_MAP`); } } diff --git a/inventory-server/src/routes/metrics.js b/inventory-server/src/routes/metrics.js index 599c2d2..b02aac7 100644 --- a/inventory-server/src/routes/metrics.js +++ b/inventory-server/src/routes/metrics.js @@ -7,90 +7,210 @@ const { Pool } = require('pg'); // Assuming pg driver const DEFAULT_PAGE_LIMIT = 50; const MAX_PAGE_LIMIT = 200; // Prevent excessive data requests -/** - * Maps user-friendly query parameter keys (camelCase) to database column names. - * Also validates if the column is safe for sorting or filtering. - * Add ALL columns from product_metrics that should be filterable/sortable. - */ +// Define direct mapping from frontend column names to database columns +// This simplifies the code by eliminating conversion logic const COLUMN_MAP = { // Product Info - pid: { dbCol: 'pm.pid', type: 'number' }, - sku: { dbCol: 'pm.sku', type: 'string' }, - title: { dbCol: 'pm.title', type: 'string' }, - brand: { dbCol: 'pm.brand', type: 'string' }, - vendor: { dbCol: 'pm.vendor', type: 'string' }, - imageUrl: { dbCol: 'pm.image_url', type: 'string' }, - isVisible: { dbCol: 'pm.is_visible', type: 'boolean' }, - isReplenishable: { dbCol: 'pm.is_replenishable', type: 'boolean' }, + pid: 'pm.pid', + sku: 'pm.sku', + title: 'pm.title', + brand: 'pm.brand', + vendor: 'pm.vendor', + imageUrl: 'pm.image_url', + isVisible: 'pm.is_visible', + isReplenishable: 'pm.is_replenishable', // Current Status - currentPrice: { dbCol: 'pm.current_price', type: 'number' }, - currentRegularPrice: { dbCol: 'pm.current_regular_price', type: 'number' }, - currentCostPrice: { dbCol: 'pm.current_cost_price', type: 'number' }, - currentLandingCostPrice: { dbCol: 'pm.current_landing_cost_price', type: 'number' }, - currentStock: { dbCol: 'pm.current_stock', type: 'number' }, - currentStockCost: { dbCol: 'pm.current_stock_cost', type: 'number' }, - currentStockRetail: { dbCol: 'pm.current_stock_retail', type: 'number' }, - currentStockGross: { dbCol: 'pm.current_stock_gross', type: 'number' }, - onOrderQty: { dbCol: 'pm.on_order_qty', type: 'number' }, - onOrderCost: { dbCol: 'pm.on_order_cost', type: 'number' }, - onOrderRetail: { dbCol: 'pm.on_order_retail', type: 'number' }, - earliestExpectedDate: { dbCol: 'pm.earliest_expected_date', type: 'date' }, + currentPrice: 'pm.current_price', + currentRegularPrice: 'pm.current_regular_price', + currentCostPrice: 'pm.current_cost_price', + currentLandingCostPrice: 'pm.current_landing_cost_price', + currentStock: 'pm.current_stock', + currentStockCost: 'pm.current_stock_cost', + currentStockRetail: 'pm.current_stock_retail', + currentStockGross: 'pm.current_stock_gross', + onOrderQty: 'pm.on_order_qty', + onOrderCost: 'pm.on_order_cost', + onOrderRetail: 'pm.on_order_retail', + earliestExpectedDate: 'pm.earliest_expected_date', // Historical Dates - dateCreated: { dbCol: 'pm.date_created', type: 'date' }, - dateFirstReceived: { dbCol: 'pm.date_first_received', type: 'date' }, - dateLastReceived: { dbCol: 'pm.date_last_received', type: 'date' }, - dateFirstSold: { dbCol: 'pm.date_first_sold', type: 'date' }, - dateLastSold: { dbCol: 'pm.date_last_sold', type: 'date' }, - ageDays: { dbCol: 'pm.age_days', type: 'number' }, + dateCreated: 'pm.date_created', + dateFirstReceived: 'pm.date_first_received', + dateLastReceived: 'pm.date_last_received', + dateFirstSold: 'pm.date_first_sold', + dateLastSold: 'pm.date_last_sold', + ageDays: 'pm.age_days', // Rolling Period Metrics - sales7d: { dbCol: 'pm.sales_7d', type: 'number' }, revenue7d: { dbCol: 'pm.revenue_7d', type: 'number' }, - sales14d: { dbCol: 'pm.sales_14d', type: 'number' }, revenue14d: { dbCol: 'pm.revenue_14d', type: 'number' }, - sales30d: { dbCol: 'pm.sales_30d', type: 'number' }, revenue30d: { dbCol: 'pm.revenue_30d', type: 'number' }, - cogs30d: { dbCol: 'pm.cogs_30d', type: 'number' }, profit30d: { dbCol: 'pm.profit_30d', type: 'number' }, - returnsUnits30d: { dbCol: 'pm.returns_units_30d', type: 'number' }, returnsRevenue30d: { dbCol: 'pm.returns_revenue_30d', type: 'number' }, - discounts30d: { dbCol: 'pm.discounts_30d', type: 'number' }, grossRevenue30d: { dbCol: 'pm.gross_revenue_30d', type: 'number' }, - grossRegularRevenue30d: { dbCol: 'pm.gross_regular_revenue_30d', type: 'number' }, - stockoutDays30d: { dbCol: 'pm.stockout_days_30d', type: 'number' }, - sales365d: { dbCol: 'pm.sales_365d', type: 'number' }, revenue365d: { dbCol: 'pm.revenue_365d', type: 'number' }, - avgStockUnits30d: { dbCol: 'pm.avg_stock_units_30d', type: 'number' }, avgStockCost30d: { dbCol: 'pm.avg_stock_cost_30d', type: 'number' }, - avgStockRetail30d: { dbCol: 'pm.avg_stock_retail_30d', type: 'number' }, avgStockGross30d: { dbCol: 'pm.avg_stock_gross_30d', type: 'number' }, - receivedQty30d: { dbCol: 'pm.received_qty_30d', type: 'number' }, receivedCost30d: { dbCol: 'pm.received_cost_30d', type: 'number' }, + sales7d: 'pm.sales_7d', + revenue7d: 'pm.revenue_7d', + sales14d: 'pm.sales_14d', + revenue14d: 'pm.revenue_14d', + sales30d: 'pm.sales_30d', + revenue30d: 'pm.revenue_30d', + cogs30d: 'pm.cogs_30d', + profit30d: 'pm.profit_30d', + returnsUnits30d: 'pm.returns_units_30d', + returnsRevenue30d: 'pm.returns_revenue_30d', + discounts30d: 'pm.discounts_30d', + 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 - 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 - first7DaysSales: { dbCol: 'pm.first_7_days_sales', type: 'number' }, first7DaysRevenue: { dbCol: 'pm.first_7_days_revenue', type: 'number' }, - first30DaysSales: { dbCol: 'pm.first_30_days_sales', type: 'number' }, first30DaysRevenue: { dbCol: 'pm.first_30_days_revenue', type: 'number' }, - first60DaysSales: { dbCol: 'pm.first_60_days_sales', type: 'number' }, first60DaysRevenue: { dbCol: 'pm.first_60_days_revenue', type: 'number' }, - first90DaysSales: { dbCol: 'pm.first_90_days_sales', type: 'number' }, first90DaysRevenue: { dbCol: 'pm.first_90_days_revenue', type: 'number' }, + first7DaysSales: 'pm.first_7_days_sales', + first7DaysRevenue: 'pm.first_7_days_revenue', + first30DaysSales: 'pm.first_30_days_sales', + 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 - asp30d: { dbCol: 'pm.asp_30d', type: 'number' }, acp30d: { dbCol: 'pm.acp_30d', type: 'number' }, avgRos30d: { dbCol: 'pm.avg_ros_30d', type: 'number' }, - avgSalesPerDay30d: { dbCol: 'pm.avg_sales_per_day_30d', type: 'number' }, avgSalesPerMonth30d: { dbCol: 'pm.avg_sales_per_month_30d', type: 'number' }, - margin30d: { dbCol: 'pm.margin_30d', type: 'number' }, markup30d: { dbCol: 'pm.markup_30d', type: 'number' }, gmroi30d: { dbCol: 'pm.gmroi_30d', type: 'number' }, - stockturn30d: { dbCol: 'pm.stockturn_30d', type: 'number' }, returnRate30d: { dbCol: 'pm.return_rate_30d', type: 'number' }, - discountRate30d: { dbCol: 'pm.discount_rate_30d', type: 'number' }, stockoutRate30d: { dbCol: 'pm.stockout_rate_30d', type: 'number' }, - markdown30d: { dbCol: 'pm.markdown_30d', type: 'number' }, markdownRate30d: { dbCol: 'pm.markdown_rate_30d', type: 'number' }, - sellThrough30d: { dbCol: 'pm.sell_through_30d', type: 'number' }, avgLeadTimeDays: { dbCol: 'pm.avg_lead_time_days', type: 'number' }, + asp30d: 'pm.asp_30d', + acp30d: 'pm.acp_30d', + avgRos30d: 'pm.avg_ros_30d', + avgSalesPerDay30d: 'pm.avg_sales_per_day_30d', + avgSalesPerMonth30d: 'pm.avg_sales_per_month_30d', + margin30d: 'pm.margin_30d', + 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 - abcClass: { dbCol: 'pm.abc_class', type: 'string' }, salesVelocityDaily: { dbCol: 'pm.sales_velocity_daily', type: 'number' }, - configLeadTime: { dbCol: 'pm.config_lead_time', type: 'number' }, configDaysOfStock: { dbCol: 'pm.config_days_of_stock', type: 'number' }, - configSafetyStock: { dbCol: 'pm.config_safety_stock', type: 'number' }, planningPeriodDays: { dbCol: 'pm.planning_period_days', type: 'number' }, - leadTimeForecastUnits: { dbCol: 'pm.lead_time_forecast_units', type: 'number' }, daysOfStockForecastUnits: { dbCol: 'pm.days_of_stock_forecast_units', type: 'number' }, - planningPeriodForecastUnits: { dbCol: 'pm.planning_period_forecast_units', type: 'number' }, leadTimeClosingStock: { dbCol: 'pm.lead_time_closing_stock', type: 'number' }, - daysOfStockClosingStock: { dbCol: 'pm.days_of_stock_closing_stock', type: 'number' }, replenishmentNeededRaw: { dbCol: 'pm.replenishment_needed_raw', type: 'number' }, - replenishmentUnits: { dbCol: 'pm.replenishment_units', type: 'number' }, replenishmentCost: { dbCol: 'pm.replenishment_cost', type: 'number' }, - replenishmentRetail: { dbCol: 'pm.replenishment_retail', type: 'number' }, replenishmentProfit: { dbCol: 'pm.replenishment_profit', type: 'number' }, - toOrderUnits: { dbCol: 'pm.to_order_units', type: 'number' }, forecastLostSalesUnits: { dbCol: 'pm.forecast_lost_sales_units', type: 'number' }, - forecastLostRevenue: { dbCol: 'pm.forecast_lost_revenue', type: 'number' }, stockCoverInDays: { dbCol: 'pm.stock_cover_in_days', type: 'number' }, - poCoverInDays: { dbCol: 'pm.po_cover_in_days', type: 'number' }, sellsOutInDays: { dbCol: 'pm.sells_out_in_days', type: 'number' }, - replenishDate: { dbCol: 'pm.replenish_date', type: 'date' }, overstockedUnits: { dbCol: 'pm.overstocked_units', type: 'number' }, - overstockedCost: { dbCol: 'pm.overstocked_cost', type: 'number' }, overstockedRetail: { dbCol: 'pm.overstocked_retail', type: 'number' }, - isOldStock: { dbCol: 'pm.is_old_stock', type: 'boolean' }, + abcClass: 'pm.abc_class', + salesVelocityDaily: 'pm.sales_velocity_daily', + configLeadTime: 'pm.config_lead_time', + configDaysOfStock: 'pm.config_days_of_stock', + configSafetyStock: 'pm.config_safety_stock', + planningPeriodDays: 'pm.planning_period_days', + leadTimeForecastUnits: 'pm.lead_time_forecast_units', + daysOfStockForecastUnits: 'pm.days_of_stock_forecast_units', + planningPeriodForecastUnits: 'pm.planning_period_forecast_units', + leadTimeClosingStock: 'pm.lead_time_closing_stock', + daysOfStockClosingStock: 'pm.days_of_stock_closing_stock', + replenishmentNeededRaw: 'pm.replenishment_needed_raw', + replenishmentUnits: 'pm.replenishment_units', + 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 - 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) { - return COLUMN_MAP[queryParamKey] || null; +// Map of column types for proper sorting +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 --- @@ -121,7 +241,7 @@ router.get('/filter-options', async (req, res) => { // GET /metrics/ - List all product metrics with filtering, sorting, pagination 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); try { @@ -135,10 +255,45 @@ router.get('/', async (req, res) => { // --- Sorting --- const sortQueryKey = req.query.sort || 'title'; // Default sort field key - const sortColumnInfo = getSafeColumnInfo(sortQueryKey); - const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'pm.title'; // Default DB column + const dbColumn = getDbColumn(sortQueryKey); + const columnType = getColumnType(sortQueryKey); + + console.log(`Sorting request: ${sortQueryKey} -> ${dbColumn} (${columnType})`); + 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 --- 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.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 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 operator = '='; // Default operator @@ -164,15 +334,15 @@ router.get('/', async (req, res) => { operator = operatorMatch[2]; // e.g., "gt" } - const columnInfo = getSafeColumnInfo(filterKey); - if (!columnInfo) { + // Get the database column for this filter key + const dbColumn = getDbColumn(filterKey); + const valueType = getColumnType(filterKey); + + if (!dbColumn) { console.warn(`Invalid filter key ignored: ${key}`); continue; // Skip if the key doesn't map to a known column } - const dbColumn = columnInfo.dbCol; - const valueType = columnInfo.type; - // --- Build WHERE clause fragment --- try { let conditionFragment = ''; @@ -234,6 +404,10 @@ router.get('/', async (req, res) => { // --- Construct and Execute Queries --- 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 const countSql = `SELECT COUNT(*) AS total FROM public.product_metrics pm ${whereClause}`; console.log('Executing Count Query:', countSql, params); @@ -244,11 +418,20 @@ router.get('/', async (req, res) => { SELECT pm.* FROM public.product_metrics pm ${whereClause} - ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder} + ORDER BY ${orderByClause} LIMIT $${paramCounter} OFFSET $${paramCounter + 1} `; 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); // Execute queries in parallel diff --git a/inventory-server/src/utils/apiHelpers.js b/inventory-server/src/utils/apiHelpers.js index 7ebdd4a..559fa29 100644 --- a/inventory-server/src/utils/apiHelpers.js +++ b/inventory-server/src/utils/apiHelpers.js @@ -5,18 +5,28 @@ function parseValue(value, type) { if (value === null || value === undefined || value === '') return null; + console.log(`Parsing value: "${value}" as type: "${type}"`); + switch (type) { case 'number': 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; case 'integer': // Specific type for integer IDs etc. 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; case 'boolean': if (String(value).toLowerCase() === 'true') return true; if (String(value).toLowerCase() === 'false') return false; + console.error(`Invalid boolean format: "${value}"`); throw new Error(`Invalid boolean format: "${value}"`); case 'date': // Basic ISO date format validation (YYYY-MM-DD) diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index 6d0d634..e8f4186 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -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" /> -
+
A Cherry On Bottom
diff --git a/inventory/src/pages/Categories.tsx b/inventory/src/pages/Categories.tsx index 8a2ceb6..9475327 100644 --- a/inventory/src/pages/Categories.tsx +++ b/inventory/src/pages/Categories.tsx @@ -53,12 +53,14 @@ type CategorySortableColumns = | "activeProductCount" | "currentStockUnits" | "currentStockCost" - | "revenue_7d" - | "revenue_30d" - | "profit_30d" - | "sales_30d" - | "avg_margin_30d" - | "stock_turn_30d"; + | "currentStockRetail" + | "revenue7d" + | "revenue30d" + | "profit30d" + | "sales30d" + | "avgMargin30d" + | "stockTurn30d" + | "status"; interface CategoryMetric { // Assuming category_id is unique primary identifier in category_metrics @@ -375,17 +377,26 @@ export function Categories() { if (filters.search) { params.set("categoryName_ilike", filters.search); } + 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); } + if (filters.status !== "all") { params.set("status", filters.status); } + // Only filter by active products if explicitly requested if (!filters.showInactive) { params.set("activeProductCount_gt", "0"); } + console.log("Filters:", filters); + console.log("Query params:", params.toString()); + return params; }, [sortColumn, sortDirection, filters]); @@ -397,21 +408,34 @@ export function Categories() { } = useQuery({ queryKey: ["categories-all", queryParams.toString()], queryFn: async () => { - const response = await fetch( - `${config.apiUrl}/categories-aggregate?${queryParams.toString()}`, - { - credentials: "include", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "Cache-Control": "no-cache", - }, - } - ); + const url = `${config.apiUrl}/categories-aggregate?${queryParams.toString()}`; + console.log("Fetching categories from URL:", url); + + const response = await fetch(url, { + credentials: "include", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "Cache-Control": "no-cache", + }, + }); + if (!response.ok) { 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, }); @@ -447,7 +471,9 @@ export function Categories() { ); if (!response.ok) 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 const hierarchicalCategories = useMemo(() => { + console.log(`Building hierarchical structure from ${categories.length} categories`); if (!categories || categories.length === 0) return []; // 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 const allDescendantsMap = new Map>(); @@ -544,6 +576,60 @@ export function Categories() { 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 const rootCategories: CategoryWithChildren[] = []; @@ -618,9 +704,18 @@ export function Categories() { // Apply hierarchy levels and use our pre-calculated totals const result = computeHierarchyAndLevels(rootCategories); + console.log(`Returning ${result.length} hierarchical categories`); 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 const renderCategoryRow = ( @@ -882,45 +977,51 @@ export function Categories() { // where it has access to all required variables const renderGroupedCategories = () => { if (isLoadingAll) { - return Array.from({ length: 5 }).map((_, i) => ( + return Array.from({ length: 10 }).map((_, i) => ( - - + +
+ + +
- - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - - - + +
)); } + console.log("Rendering categories:", { + hierarchicalCategories: hierarchicalCategories?.length || 0, + categories: categories?.length || 0, + filters + }); + if (!hierarchicalCategories || hierarchicalCategories.length === 0) { // Check hierarchicalCategories directly return ( @@ -929,18 +1030,28 @@ export function Categories() { colSpan={11} className="h-16 text-center py-8 text-muted-foreground" > - {filters.search || filters.type !== "all" || !filters.showInactive - ? "No categories found matching your criteria. Try adjusting filters." - : "No categories available."} + {categories && categories.length > 0 ? ( + <> +

We found {categories.length} matching categories but encountered an issue displaying them.

+

Try adjusting your filter criteria or refreshing the page.

+ + ) : ( + filters.search || filters.type !== "all" || !filters.showInactive + ? "No categories found matching your criteria. Try adjusting filters." + : "No categories available." + )} ); } // Directly render the hierarchical tree roots - return hierarchicalCategories + const rows = hierarchicalCategories .map((category) => renderCategoryRow(category)) .flat(); + + console.log(`Rendering ${rows.length} total rows`); + return rows; }; // --- Event Handlers --- @@ -948,8 +1059,8 @@ export function Categories() { const handleSort = useCallback( (column: CategorySortableColumns) => { setSortDirection((prev) => { - if (sortColumn !== column) return "asc"; - return prev === "asc" ? "desc" : "asc"; + if (sortColumn !== column) return "desc"; + return prev === "asc" ? "asc" : "desc"; }); setSortColumn(column); @@ -961,7 +1072,13 @@ export function Categories() { const handleFilterChange = useCallback( (filterName: keyof CategoryFilters, value: string | boolean) => { + console.log(`Filter change: ${filterName} = ${value} (${typeof 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]); + // Log when filter options are received + useEffect(() => { + if (filterOptions) { + console.log("Filter options loaded:", filterOptions); + console.log("Available types:", filterOptions.types); + } + }, [filterOptions]); + // --- Rendering --- return ( @@ -1135,7 +1260,7 @@ export function Categories() { handleSort("categoryName")} - className="cursor-pointer w-[25%]" + className="h-16 cursor-pointer w-[25%]" > Name @@ -1176,32 +1301,32 @@ export function Categories() { handleSort("revenue_30d")} + onClick={() => handleSort("revenue30d")} className="cursor-pointer text-right w-[8%]" > Revenue (30d) - + handleSort("profit_30d")} + onClick={() => handleSort("profit30d")} className="cursor-pointer text-right w-[8%]" > Profit (30d) - + handleSort("avg_margin_30d")} + onClick={() => handleSort("avgMargin30d")} className="cursor-pointer text-right w-[8%]" > Margin (30d) - + handleSort("stock_turn_30d")} + onClick={() => handleSort("stockTurn30d")} className="cursor-pointer text-right w-[6%]" > Stock Turn (30d) - + @@ -1212,4 +1337,4 @@ export function Categories() { ); } -export default Categories; \ No newline at end of file +export default Categories; diff --git a/inventory/src/pages/Products.tsx b/inventory/src/pages/Products.tsx index a12ad47..cfd5571 100644 --- a/inventory/src/pages/Products.tsx +++ b/inventory/src/pages/Products.tsx @@ -55,17 +55,17 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [ { key: 'dateCreated', label: 'Created', group: 'Basic Info' }, // Current Status - { key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'currentStock', label: 'Stock', group: 'Stock', format: (v) => v?.toString() ?? '-' }, - { key: 'currentStockCost', label: 'Stock Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'currentStockRetail', label: 'Stock Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'currentStockGross', label: 'Stock Gross', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'onOrderQty', label: 'On Order', group: 'Stock', format: (v) => v?.toString() ?? '-' }, - { key: 'onOrderCost', label: 'On Order Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'onOrderRetail', label: 'On Order Retail', group: 'Stock', 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 === 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: 'currentStock', label: 'Stock', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, + { 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 === 0 ? '0' : 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 === 0 ? '0' : v ? v.toString() : '-' }, + { 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 === 0 ? '0' : v ? v.toFixed(2) : '-' }, { key: 'earliestExpectedDate', label: 'Expected Date', group: 'Stock' }, // Dates @@ -73,41 +73,45 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [ { key: 'dateLastReceived', label: 'Last Received', group: 'Dates' }, { key: 'dateFirstSold', label: 'First Sold', group: 'Dates' }, { key: 'dateLastSold', label: 'Last Sold', group: 'Dates' }, - { key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v?.toString() ?? '-' }, + { key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, // Product Status { key: 'status', label: 'Status', group: 'Status' }, // Rolling Metrics - { key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (v) => v?.toString() ?? '-' }, - { key: 'revenue7d', label: 'Revenue (7d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v?.toString() ?? '-' }, - { key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v) => v?.toString() ?? '-' }, - { key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'cogs30d', label: 'COGS (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'profit30d', label: 'Profit (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v?.toString() ?? '-' }, - { key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' }, + { key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, + { key: 'revenue7d', label: 'Revenue (7d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, + { key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, + { key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, + { key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, + { key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, + { key: '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: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, // KPIs - { key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' }, - { key: 'markup30d', label: 'Markup %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' }, - { key: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' }, - { key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Lead Time', format: (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: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, + { key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, + { key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' }, + { key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Lead Time', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' }, // Replenishment { key: 'abcClass', label: 'ABC Class', group: 'Stock' }, - { key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' }, - { key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' }, - { key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' }, - { key: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' }, - { key: 'overstockedCost', label: 'Overstock Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' }, - { key: 'overstockedRetail', label: 'Overstock Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' }, + { 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 === 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: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, + { 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 === 0 ? '0' : v ? v.toFixed(2) : '-' }, { 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 @@ -202,6 +206,10 @@ export function Products() { const [filters, setFilters] = useState>({}); const [sortColumn, setSortColumn] = useState('title'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + // Track last sort direction for each column + const [columnSortDirections, setColumnSortDirections] = useState>({ + 'title': 'asc' // Initialize with default sort column and direction + }); const [currentPage, setCurrentPage] = useState(1); const [activeView, setActiveView] = useState(searchParams.get('view') || "all"); const [pageSize] = useState(50); @@ -283,9 +291,19 @@ export function Products() { Object.entries(filters).forEach(([key, value]) => { if (typeof value === 'object' && 'operator' in value) { - transformedFilters[key] = value.value; - transformedFilters[`${key}_operator`] = value.operator; + // Convert the operator format to match what the backend expects + // Backend expects keys like "sales30d_gt" instead of separate operator parameters + const operatorSuffix = value.operator === '=' ? 'eq' : + value.operator === '>' ? 'gt' : + value.operator === '>=' ? 'gte' : + value.operator === '<' ? 'lt' : + value.operator === '<=' ? 'lte' : + value.operator === 'between' ? 'between' : 'eq'; + + // Create a key with the correct suffix format: key_operator + transformedFilters[`${key}_${operatorSuffix}`] = value.value; } else { + // Simple values are passed as-is transformedFilters[key] = value; } }); @@ -301,9 +319,10 @@ export function Products() { params.append('limit', pageSize.toString()); if (sortColumn) { - // Convert camelCase to snake_case for the API - const snakeCaseSort = sortColumn.replace(/([A-Z])/g, '_$1').toLowerCase(); - params.append('sort', snakeCaseSort); + // Don't convert camelCase to snake_case - use the column name directly + // as defined in the backend's COLUMN_MAP + console.log(`Sorting: ${sortColumn} (${sortDirection})`); + params.append('sort', sortColumn); params.append('order', sortDirection); } @@ -315,21 +334,22 @@ export function Products() { const transformedFilters = transformFilters(filters); Object.entries(transformedFilters).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { - // Convert camelCase to snake_case for the API - const snakeCaseKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - + // Don't convert camelCase to snake_case - use the filter name directly if (Array.isArray(value)) { - params.append(snakeCaseKey, JSON.stringify(value)); + params.append(key, JSON.stringify(value)); } else { - params.append(snakeCaseKey, value.toString()); + params.append(key, value.toString()); } } }); 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()}`); if (!response.ok) throw new Error('Failed to fetch products'); @@ -350,8 +370,8 @@ export function Products() { // Then handle regular snake_case -> camelCase camelKey = camelKey.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase()); - // Convert numeric strings to actual numbers - if (typeof value === 'string' && !isNaN(Number(value)) && + // Convert numeric strings to actual numbers, but handle empty strings properly + if (typeof value === 'string' && value !== '' && !isNaN(Number(value)) && !key.toLowerCase().includes('date') && key !== 'sku' && key !== 'title' && key !== 'brand' && key !== 'vendor') { transformed[camelKey] = Number(value); @@ -434,13 +454,45 @@ export function Products() { } }, [currentPage, data?.pagination.pages]); - // Handle sort column change + // Handle sort column change with improved column-specific direction memory const handleSort = (column: ProductMetricColumnKey) => { - setSortDirection(prev => { - if (sortColumn !== column) return 'asc'; - return prev === 'asc' ? 'desc' : 'asc'; - }); + let nextDirection: 'asc' | 'desc'; + + 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); + + // Remember this column's sort direction for next time + setColumnSortDirections(prev => ({ + ...prev, + [column]: nextDirection + })); }; // Handle filter changes @@ -626,13 +678,11 @@ export function Products() { ) : (
{ - // Before returning the product, ensure it has a status for display - if (!product.status) { - product.status = getProductStatus(product); - } - return product; - }) || []} + products={data?.products?.map((product: ProductMetric) => ({ + ...product, + // No need to calculate status anymore since it comes from the backend + status: product.status || 'Healthy' // Fallback only if status is null + })) || []} onSort={handleSort} sortColumn={sortColumn} sortDirection={sortDirection}