Move product status calculation to database, fix up products table, more categories tweaks
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user