Updates and fixes for products page

This commit is contained in:
2026-02-07 09:30:22 -05:00
parent b5469440bf
commit 8044771301
18 changed files with 1424 additions and 1274 deletions

View File

@@ -1,6 +1,5 @@
const express = require('express');
const router = express.Router();
const { Pool } = require('pg'); // Assuming pg driver
// --- Configuration & Helpers ---
@@ -255,32 +254,98 @@ const SPECIAL_SORT_COLUMNS = {
};
// Status priority for sorting (lower number = higher priority)
// Values must match what's stored in the DB status column
const STATUS_PRIORITY = {
'Critical': 1,
'At Risk': 2,
'Reorder': 3,
'Overstocked': 4,
'Reorder Soon': 3,
'Overstock': 4,
'Healthy': 5,
'New': 6
// Any other status will be sorted alphabetically after these
};
// Get database column name from frontend column name
// Returns null for unknown keys so callers can skip them
function getDbColumn(frontendColumn) {
return COLUMN_MAP[frontendColumn] || 'pm.title'; // Default to title if not found
return COLUMN_MAP[frontendColumn] || null;
}
// Get column type for proper sorting
// Get column type by searching through the COLUMN_TYPES arrays
function getColumnType(frontendColumn) {
return COLUMN_TYPES[frontendColumn] || 'string';
if (COLUMN_TYPES.numeric.includes(frontendColumn)) return 'numeric';
if (COLUMN_TYPES.date.includes(frontendColumn)) return 'date';
if (COLUMN_TYPES.boolean.includes(frontendColumn)) return 'boolean';
return 'string';
}
// --- Route Handlers ---
// GET /metrics/summary - Aggregate KPI summary for the current view
router.get('/summary', async (req, res) => {
const pool = req.app.locals.pool;
try {
// Build WHERE clause from same filters as main list endpoint
const conditions = ['pm.is_visible = true', 'pm.is_replenishable = true'];
const params = [];
let paramCounter = 1;
// Handle showNonReplenishable
if (req.query.showNonReplenishable === 'true') {
// Remove the is_replenishable condition
conditions.pop();
}
// Handle showInvisible
if (req.query.showInvisible === 'true') {
conditions.shift(); // Remove is_visible condition
}
// Handle stock_status filter
if (req.query.stock_status) {
conditions.push(`pm.status = $${paramCounter++}`);
params.push(req.query.stock_status);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const sql = `
SELECT
COUNT(*)::int AS total_products,
COALESCE(SUM(pm.current_stock_cost), 0)::numeric(15,2) AS total_stock_value,
COALESCE(SUM(pm.current_stock_retail), 0)::numeric(15,2) AS total_stock_retail,
COUNT(*) FILTER (WHERE pm.status IN ('Critical', 'Reorder Soon'))::int AS needs_reorder_count,
COALESCE(SUM(pm.replenishment_cost) FILTER (WHERE pm.replenishment_units > 0), 0)::numeric(15,2) AS total_replenishment_cost,
COALESCE(SUM(pm.replenishment_units) FILTER (WHERE pm.replenishment_units > 0), 0)::int AS total_replenishment_units,
COALESCE(SUM(pm.overstocked_cost) FILTER (WHERE pm.overstocked_units > 0), 0)::numeric(15,2) AS total_overstock_value,
COALESCE(SUM(pm.overstocked_units) FILTER (WHERE pm.overstocked_units > 0), 0)::int AS total_overstock_units,
COALESCE(SUM(pm.on_order_qty), 0)::int AS total_on_order_units,
COALESCE(SUM(pm.on_order_cost), 0)::numeric(15,2) AS total_on_order_cost,
COALESCE(AVG(pm.stock_cover_in_days) FILTER (WHERE pm.stock_cover_in_days IS NOT NULL AND pm.current_stock > 0), 0)::numeric(10,1) AS avg_stock_cover_days,
COUNT(*) FILTER (WHERE pm.current_stock = 0)::int AS out_of_stock_count,
COALESCE(SUM(pm.forecast_lost_revenue) FILTER (WHERE pm.forecast_lost_revenue > 0), 0)::numeric(15,2) AS total_lost_revenue,
COALESCE(SUM(pm.forecast_lost_sales_units) FILTER (WHERE pm.forecast_lost_sales_units > 0), 0)::int AS total_lost_sales_units,
COUNT(*) FILTER (WHERE pm.status = 'Critical')::int AS critical_count,
COUNT(*) FILTER (WHERE pm.status = 'Reorder Soon')::int AS reorder_count,
COUNT(*) FILTER (WHERE pm.status = 'At Risk')::int AS at_risk_count,
COUNT(*) FILTER (WHERE pm.status = 'Overstock')::int AS overstock_count,
COUNT(*) FILTER (WHERE pm.status = 'Healthy')::int AS healthy_count,
COUNT(*) FILTER (WHERE pm.status = 'New')::int AS new_count
FROM public.product_metrics pm
${whereClause}
`;
const { rows } = await pool.query(sql, params);
res.json(rows[0]);
} catch (error) {
console.error('Error fetching metrics summary:', error);
res.status(500).json({ error: 'Failed to fetch metrics summary.' });
}
});
// GET /metrics/filter-options - Provide distinct values for filter dropdowns
router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /metrics/filter-options');
try {
const [vendorRes, brandRes, abcClassRes] = await Promise.all([
pool.query(`SELECT DISTINCT vendor FROM public.product_metrics WHERE vendor IS NOT NULL AND vendor <> '' ORDER BY vendor`),
@@ -304,7 +369,6 @@ 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;
console.log('GET /metrics received query:', req.query);
try {
// --- Pagination ---
@@ -317,11 +381,9 @@ router.get('/', async (req, res) => {
// --- Sorting ---
const sortQueryKey = req.query.sort || 'title'; // Default sort field key
const dbColumn = getDbColumn(sortQueryKey);
const sortDbColumn = getDbColumn(sortQueryKey) || 'pm.title';
const columnType = getColumnType(sortQueryKey);
console.log(`Sorting request: ${sortQueryKey} -> ${dbColumn} (${columnType})`);
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
// Always put nulls last regardless of sort direction or column type
@@ -332,29 +394,29 @@ router.get('/', async (req, res) => {
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) {
orderByClause = `ABS(${sortDbColumn}::numeric) ${sortDirection} ${nullsOrder}`;
} else if (columnType === 'numeric' || SPECIAL_SORT_COLUMNS[sortQueryKey] === true) {
// For numeric columns, cast to numeric to ensure proper sorting
orderByClause = `${dbColumn}::numeric ${sortDirection} ${nullsOrder}`;
orderByClause = `${sortDbColumn}::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') {
orderByClause = `CASE WHEN ${sortDbColumn} IS NULL THEN 1 ELSE 0 END, ${sortDbColumn}::timestamp ${sortDirection}`;
} else if (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
CASE WHEN ${sortDbColumn} IS NULL THEN 999
WHEN ${sortDbColumn} = 'Critical' THEN 1
WHEN ${sortDbColumn} = 'At Risk' THEN 2
WHEN ${sortDbColumn} = 'Reorder Soon' THEN 3
WHEN ${sortDbColumn} = 'Overstock' THEN 4
WHEN ${sortDbColumn} = 'Healthy' THEN 5
WHEN ${sortDbColumn} = 'New' THEN 6
ELSE 100
END ${sortDirection} ${nullsOrder},
${dbColumn} ${sortDirection}`;
${sortDbColumn} ${sortDirection}`;
} else {
// For string and boolean columns, no special casting needed
orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn} ${sortDirection}`;
orderByClause = `CASE WHEN ${sortDbColumn} IS NULL THEN 1 ELSE 0 END, ${sortDbColumn} ${sortDirection}`;
}
// --- Filtering ---
@@ -389,26 +451,26 @@ router.get('/', async (req, res) => {
let operator = '='; // Default operator
let value = req.query[key];
// Check for operator suffixes (e.g., sales30d_gt, title_like)
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
// Check for operator suffixes (e.g., sales30d_gt, title_ilike, isVisible_is_true)
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|starts_with|ends_with|not_contains|between|in|is_empty|is_not_empty|is_true|is_false)$/);
if (operatorMatch) {
filterKey = operatorMatch[1]; // e.g., "sales30d"
operator = operatorMatch[2]; // e.g., "gt"
}
// Get the database column for this filter key
const dbColumn = getDbColumn(filterKey);
const filterDbColumn = getDbColumn(filterKey);
const valueType = getColumnType(filterKey);
if (!dbColumn) {
if (!filterDbColumn) {
console.warn(`Invalid filter key ignored: ${key}`);
continue; // Skip if the key doesn't map to a known column
}
// --- Build WHERE clause fragment ---
let needsParam = true; // Declared outside try so catch can access it
try {
let conditionFragment = '';
let needsParam = true; // Most operators need a parameter
switch (operator.toLowerCase()) {
case 'eq': operator = '='; break;
@@ -417,48 +479,65 @@ router.get('/', async (req, res) => {
case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; value = `%${value}%`; break; // Add wildcards for LIKE
case 'ilike': operator = 'ILIKE'; value = `%${value}%`; break; // Add wildcards for ILIKE
case 'like': operator = 'ILIKE'; value = `%${value}%`; break;
case 'ilike': operator = 'ILIKE'; value = `%${value}%`; break;
case 'starts_with': operator = 'ILIKE'; value = `${value}%`; break;
case 'ends_with': operator = 'ILIKE'; value = `%${value}`; break;
case 'not_contains': operator = 'NOT ILIKE'; value = `%${value}%`; break;
case 'is_empty':
conditionFragment = `(${filterDbColumn} IS NULL OR ${filterDbColumn}::text = '')`;
needsParam = false;
break;
case 'is_not_empty':
conditionFragment = `(${filterDbColumn} IS NOT NULL AND ${filterDbColumn}::text <> '')`;
needsParam = false;
break;
case 'is_true':
conditionFragment = `${filterDbColumn} = true`;
needsParam = false;
break;
case 'is_false':
conditionFragment = `${filterDbColumn} = false`;
needsParam = false;
break;
case 'between':
const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) {
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
conditionFragment = `${filterDbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false; // Params added manually
needsParam = false;
} else {
console.warn(`Invalid 'between' value for ${key}: ${value}`);
continue; // Skip this filter
continue;
}
break;
case 'in':
const inValues = String(value).split(',');
if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType))); // Add all parsed values
needsParam = false; // Params added manually
conditionFragment = `${filterDbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false;
} else {
console.warn(`Invalid 'in' value for ${key}: ${value}`);
continue; // Skip this filter
continue;
}
break;
// Add other operators as needed (IS NULL, IS NOT NULL, etc.)
case '=': // Keep default '='
default: operator = '='; break; // Ensure default is handled
case '=':
default: operator = '='; break;
}
if (needsParam) {
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
conditionFragment = `${filterDbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType));
}
if (conditionFragment) {
conditions.push(`(${conditionFragment})`); // Wrap condition in parentheses
conditions.push(`(${conditionFragment})`);
}
} catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
// Decrement counter if param wasn't actually used due to error
if (needsParam) paramCounter--;
}
}
@@ -466,13 +545,8 @@ 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);
const countPromise = pool.query(countSql, params);
// Data Query (Select all columns from metrics table for now)
@@ -484,16 +558,6 @@ router.get('/', async (req, res) => {
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`;
const dataParams = [...params, limit, offset];
// 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
@@ -501,7 +565,6 @@ router.get('/', async (req, res) => {
const total = parseInt(countResult.rows[0].total, 10);
const metrics = dataResult.rows;
console.log(`Total: ${total}, Fetched: ${metrics.length} for page ${page}`);
// --- Respond ---
res.json({
@@ -535,7 +598,6 @@ router.get('/:pid', async (req, res) => {
return res.status(400).json({ error: 'Invalid Product ID.' });
}
console.log(`GET /metrics/${pid}`);
try {
const { rows } = await pool.query(
`SELECT * FROM public.product_metrics WHERE pid = $1`,
@@ -543,11 +605,8 @@ router.get('/:pid', async (req, res) => {
);
if (rows.length === 0) {
console.log(`Metrics not found for PID: ${pid}`);
return res.status(404).json({ error: 'Metrics not found for this product.' });
}
console.log(`Metrics found for PID: ${pid}`);
// Data is pre-calculated, return the first (only) row
res.json(rows[0]);
@@ -566,7 +625,7 @@ function parseValue(value, type) {
if (value === null || value === undefined || value === '') return null; // Allow empty strings? Or handle differently?
switch (type) {
case 'number':
case 'numeric':
const num = parseFloat(value);
if (isNaN(num)) throw new Error(`Invalid number format: "${value}"`);
return num;

View File

@@ -731,32 +731,33 @@ router.get('/:id/time-series', async (req, res) => {
LIMIT 10
`, [id]);
// Get recent purchase orders with detailed status
// Get recent purchase orders with received quantities from the receivings table
const { rows: recentPurchases } = await pool.query(`
SELECT
TO_CHAR(date, 'YYYY-MM-DD') as date,
TO_CHAR(expected_date, 'YYYY-MM-DD') as expected_date,
TO_CHAR(received_date, 'YYYY-MM-DD') as received_date,
po_id,
ordered,
received,
status,
receiving_status,
cost_price,
notes,
CASE
WHEN received_date IS NOT NULL THEN
(received_date - date)
WHEN expected_date < CURRENT_DATE AND status < $2 THEN
(CURRENT_DATE - expected_date)
ELSE NULL
SELECT
TO_CHAR(po.date, 'YYYY-MM-DD') as date,
TO_CHAR(po.expected_date, 'YYYY-MM-DD') as expected_date,
TO_CHAR(MAX(r.received_date), 'YYYY-MM-DD') as received_date,
po.po_id,
po.ordered,
COALESCE(SUM(r.qty_each), 0)::integer as received,
po.status,
po.po_cost_price as cost_price,
po.notes,
CASE
WHEN MAX(r.received_date) IS NOT NULL THEN
EXTRACT(DAY FROM MAX(r.received_date) - po.date)::integer
WHEN po.expected_date < CURRENT_DATE AND po.status NOT IN ('done', 'canceled') THEN
(CURRENT_DATE - po.expected_date)
ELSE NULL
END as lead_time_days
FROM purchase_orders
WHERE pid = $1
AND status != $3
ORDER BY date DESC
FROM purchase_orders po
LEFT JOIN receivings r ON r.receiving_id = po.po_id AND r.pid = po.pid AND r.status != 'canceled'
WHERE po.pid = $1
AND po.status != 'canceled'
GROUP BY po.id, po.po_id, po.date, po.expected_date, po.ordered, po.status, po.po_cost_price, po.notes
ORDER BY po.date DESC
LIMIT 10
`, [id, PurchaseOrderStatus.ReceivingStarted, PurchaseOrderStatus.Canceled]);
`, [id]);
res.json({
monthly_sales: formattedMonthlySales,
@@ -772,8 +773,7 @@ router.get('/:id/time-series', async (req, res) => {
...po,
ordered: parseInt(po.ordered),
received: parseInt(po.received),
status: parseInt(po.status),
receiving_status: parseInt(po.receiving_status),
status: po.status, // Text-based status (e.g., 'done', 'ordered', 'receiving_started')
cost_price: parseFloat(po.cost_price),
lead_time_days: po.lead_time_days ? parseInt(po.lead_time_days) : null
}))