Add product lines page, tweak audit log
This commit is contained in:
381
inventory-server/src/routes/linesAggregate.js
Normal file
381
inventory-server/src/routes/linesAggregate.js
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { parseValue } = require('../utils/apiHelpers');
|
||||||
|
|
||||||
|
// --- Configuration & Helpers ---
|
||||||
|
const DEFAULT_PAGE_LIMIT = 50;
|
||||||
|
const MAX_PAGE_LIMIT = 200;
|
||||||
|
|
||||||
|
// Base aggregation query that produces line-level metrics from product_metrics
|
||||||
|
// Filters to lines with stock or sales in the past year
|
||||||
|
const LINE_AGGREGATE_SQL = `
|
||||||
|
SELECT
|
||||||
|
pm.brand,
|
||||||
|
pm.line,
|
||||||
|
COUNT(*) AS product_count,
|
||||||
|
COUNT(CASE WHEN pm.is_visible THEN 1 END) AS active_product_count,
|
||||||
|
COUNT(CASE WHEN pm.is_replenishable THEN 1 END) AS replenishable_product_count,
|
||||||
|
-- Stock
|
||||||
|
SUM(COALESCE(pm.current_stock, 0)) AS current_stock_units,
|
||||||
|
SUM(COALESCE(pm.current_stock_cost, 0)) AS current_stock_cost,
|
||||||
|
SUM(COALESCE(pm.current_stock_retail, 0)) AS current_stock_retail,
|
||||||
|
SUM(COALESCE(pm.on_order_qty, 0)) AS on_order_qty,
|
||||||
|
SUM(COALESCE(pm.on_order_cost, 0)) AS on_order_cost,
|
||||||
|
-- Sales periods
|
||||||
|
SUM(COALESCE(pm.sales_7d, 0)) AS sales_7d,
|
||||||
|
SUM(COALESCE(pm.revenue_7d, 0)) AS revenue_7d,
|
||||||
|
SUM(COALESCE(pm.sales_30d, 0)) AS sales_30d,
|
||||||
|
SUM(COALESCE(pm.revenue_30d, 0)) AS revenue_30d,
|
||||||
|
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||||
|
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||||
|
SUM(COALESCE(pm.sales_365d, 0)) AS sales_365d,
|
||||||
|
SUM(COALESCE(pm.revenue_365d, 0)) AS revenue_365d,
|
||||||
|
SUM(COALESCE(pm.lifetime_sales, 0)) AS lifetime_sales,
|
||||||
|
SUM(COALESCE(pm.lifetime_revenue, 0)) AS lifetime_revenue,
|
||||||
|
-- Weighted average margin (by revenue)
|
||||||
|
CASE WHEN SUM(pm.revenue_30d) > 0
|
||||||
|
THEN SUM(pm.profit_30d) * 100.0 / SUM(pm.revenue_30d)
|
||||||
|
ELSE NULL
|
||||||
|
END AS avg_margin_30d,
|
||||||
|
-- Velocity & coverage
|
||||||
|
SUM(COALESCE(pm.sales_velocity_daily, 0)) AS total_velocity_daily,
|
||||||
|
AVG(CASE WHEN pm.current_stock > 0 AND pm.sales_velocity_daily > 0
|
||||||
|
THEN pm.stock_cover_in_days ELSE NULL END) AS avg_stock_cover_days,
|
||||||
|
AVG(CASE WHEN pm.sells_out_in_days IS NOT NULL AND pm.current_stock > 0
|
||||||
|
THEN pm.sells_out_in_days ELSE NULL END) AS avg_sells_out_in_days,
|
||||||
|
-- Stockouts & replenishment
|
||||||
|
SUM(COALESCE(pm.stockout_days_30d, 0)) AS total_stockout_days_30d,
|
||||||
|
SUM(COALESCE(pm.replenishment_units, 0)) AS total_replenishment_units,
|
||||||
|
SUM(COALESCE(pm.replenishment_cost, 0)) AS total_replenishment_cost,
|
||||||
|
-- Lifecycle distribution (counts per phase)
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'launch' THEN 1 END) AS phase_launch,
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'mature' THEN 1 END) AS phase_mature,
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'slow_mover' THEN 1 END) AS phase_slow_mover,
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'decay' THEN 1 END) AS phase_decay,
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'dormant' THEN 1 END) AS phase_dormant,
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'preorder' THEN 1 END) AS phase_preorder,
|
||||||
|
-- Status distribution (exclude preorder-phase products — they get their own segment)
|
||||||
|
COUNT(CASE WHEN pm.status = 'Healthy' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_healthy,
|
||||||
|
COUNT(CASE WHEN pm.status = 'Reorder Soon' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_reorder,
|
||||||
|
COUNT(CASE WHEN pm.status = 'Critical' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_critical,
|
||||||
|
COUNT(CASE WHEN pm.status = 'Overstock' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_overstock,
|
||||||
|
COUNT(CASE WHEN pm.status = 'At Risk' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_at_risk,
|
||||||
|
COUNT(CASE WHEN pm.status = 'New' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_new,
|
||||||
|
-- ABC class distribution
|
||||||
|
COUNT(CASE WHEN pm.abc_class = 'A' THEN 1 END) AS abc_a_count,
|
||||||
|
COUNT(CASE WHEN pm.abc_class = 'B' THEN 1 END) AS abc_b_count,
|
||||||
|
COUNT(CASE WHEN pm.abc_class = 'C' THEN 1 END) AS abc_c_count,
|
||||||
|
-- Date range
|
||||||
|
MIN(pm.date_first_received) AS earliest_received,
|
||||||
|
MAX(pm.date_last_sold) AS latest_sale,
|
||||||
|
-- Growth (weighted by revenue)
|
||||||
|
CASE WHEN SUM(CASE WHEN pm.sales_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_30d ELSE 0 END) > 0
|
||||||
|
THEN SUM(CASE WHEN pm.sales_growth_30d_vs_prev IS NOT NULL THEN pm.sales_growth_30d_vs_prev * pm.revenue_30d ELSE 0 END)
|
||||||
|
/ SUM(CASE WHEN pm.sales_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_30d ELSE 0 END)
|
||||||
|
ELSE NULL
|
||||||
|
END AS sales_growth_30d_vs_prev,
|
||||||
|
CASE WHEN SUM(CASE WHEN pm.revenue_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_30d ELSE 0 END) > 0
|
||||||
|
THEN SUM(CASE WHEN pm.revenue_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_growth_30d_vs_prev * pm.revenue_30d ELSE 0 END)
|
||||||
|
/ SUM(CASE WHEN pm.revenue_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_30d ELSE 0 END)
|
||||||
|
ELSE NULL
|
||||||
|
END AS revenue_growth_30d_vs_prev,
|
||||||
|
-- Derived line status
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(CASE WHEN pm.lifecycle_phase = 'preorder' THEN 1 END) > COUNT(*) * 0.5 THEN 'preorder'
|
||||||
|
WHEN SUM(CASE WHEN pm.current_stock > 0 THEN 1 ELSE 0 END) = 0 THEN 'out_of_stock'
|
||||||
|
WHEN SUM(pm.sales_30d) > 0 THEN 'active'
|
||||||
|
WHEN SUM(pm.sales_365d) > 0 THEN 'slow'
|
||||||
|
ELSE 'dormant'
|
||||||
|
END AS line_status,
|
||||||
|
-- Dominant lifecycle phase
|
||||||
|
(ARRAY['launch','mature','slow_mover','decay','dormant','preorder'])[
|
||||||
|
(SELECT i FROM unnest(ARRAY[
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'launch' THEN 1 END),
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'mature' THEN 1 END),
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'slow_mover' THEN 1 END),
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'decay' THEN 1 END),
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'dormant' THEN 1 END),
|
||||||
|
COUNT(CASE WHEN pm.lifecycle_phase = 'preorder' THEN 1 END)
|
||||||
|
]) WITH ORDINALITY AS t(cnt, i)
|
||||||
|
ORDER BY cnt DESC LIMIT 1)
|
||||||
|
] AS dominant_lifecycle_phase
|
||||||
|
FROM product_metrics pm
|
||||||
|
WHERE pm.line IS NOT NULL AND pm.line != ''
|
||||||
|
AND (pm.current_stock > 0 OR pm.sales_365d > 0)
|
||||||
|
GROUP BY pm.brand, pm.line
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Column map for sorting/filtering on the aggregated result
|
||||||
|
const COLUMN_MAP = {
|
||||||
|
brand: { dbCol: 'agg.brand', type: 'string' },
|
||||||
|
line: { dbCol: 'agg.line', type: 'string' },
|
||||||
|
productCount: { dbCol: 'agg.product_count', type: 'number' },
|
||||||
|
activeProductCount: { dbCol: 'agg.active_product_count', type: 'number' },
|
||||||
|
currentStockUnits: { dbCol: 'agg.current_stock_units', type: 'number' },
|
||||||
|
currentStockCost: { dbCol: 'agg.current_stock_cost', type: 'number' },
|
||||||
|
currentStockRetail: { dbCol: 'agg.current_stock_retail', type: 'number' },
|
||||||
|
onOrderQty: { dbCol: 'agg.on_order_qty', type: 'number' },
|
||||||
|
sales7d: { dbCol: 'agg.sales_7d', type: 'number' },
|
||||||
|
revenue7d: { dbCol: 'agg.revenue_7d', type: 'number' },
|
||||||
|
sales30d: { dbCol: 'agg.sales_30d', type: 'number' },
|
||||||
|
revenue30d: { dbCol: 'agg.revenue_30d', type: 'number' },
|
||||||
|
profit30d: { dbCol: 'agg.profit_30d', type: 'number' },
|
||||||
|
sales365d: { dbCol: 'agg.sales_365d', type: 'number' },
|
||||||
|
revenue365d: { dbCol: 'agg.revenue_365d', type: 'number' },
|
||||||
|
lifetimeSales: { dbCol: 'agg.lifetime_sales', type: 'number' },
|
||||||
|
lifetimeRevenue: { dbCol: 'agg.lifetime_revenue', type: 'number' },
|
||||||
|
avgMargin30d: { dbCol: 'agg.avg_margin_30d', type: 'number' },
|
||||||
|
totalVelocityDaily: { dbCol: 'agg.total_velocity_daily', type: 'number' },
|
||||||
|
avgStockCoverDays: { dbCol: 'agg.avg_stock_cover_days', type: 'number' },
|
||||||
|
avgSellsOutInDays: { dbCol: 'agg.avg_sells_out_in_days', type: 'number' },
|
||||||
|
salesGrowth30dVsPrev: { dbCol: 'agg.sales_growth_30d_vs_prev', type: 'number' },
|
||||||
|
revenueGrowth30dVsPrev: { dbCol: 'agg.revenue_growth_30d_vs_prev', type: 'number' },
|
||||||
|
lineStatus: { dbCol: 'agg.line_status', type: 'string' },
|
||||||
|
dominantLifecyclePhase: { dbCol: 'agg.dominant_lifecycle_phase', type: 'string' },
|
||||||
|
name: { dbCol: 'agg.line', type: 'string' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSafeColumnInfo(queryParamKey) {
|
||||||
|
return COLUMN_MAP[queryParamKey] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Route Handlers ---
|
||||||
|
|
||||||
|
// GET /lines-aggregate/filter-options
|
||||||
|
router.get('/filter-options', async (req, res) => {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
try {
|
||||||
|
const { rows: brandRows } = await pool.query(`
|
||||||
|
SELECT DISTINCT pm.brand
|
||||||
|
FROM product_metrics pm
|
||||||
|
WHERE pm.line IS NOT NULL AND pm.line != ''
|
||||||
|
AND (pm.current_stock > 0 OR pm.sales_365d > 0)
|
||||||
|
AND pm.brand IS NOT NULL AND pm.brand != ''
|
||||||
|
ORDER BY pm.brand
|
||||||
|
`);
|
||||||
|
|
||||||
|
const statuses = ['active', 'preorder', 'slow', 'out_of_stock', 'dormant'];
|
||||||
|
const phases = ['launch', 'mature', 'slow_mover', 'decay', 'dormant', 'preorder'];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
brands: brandRows.map(r => r.brand),
|
||||||
|
statuses,
|
||||||
|
phases,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching line filter options:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch filter options' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /lines-aggregate/stats
|
||||||
|
router.get('/stats', async (req, res) => {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
try {
|
||||||
|
const { rows: [stats] } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_lines,
|
||||||
|
COUNT(DISTINCT brand) AS brand_count,
|
||||||
|
COUNT(CASE WHEN line_status = 'active' THEN 1 END) AS active_lines,
|
||||||
|
COUNT(CASE WHEN line_status = 'out_of_stock' THEN 1 END) AS oos_lines,
|
||||||
|
COUNT(CASE WHEN line_status = 'active' AND replenishable_product_count > 0 AND status_reorder > replenishable_product_count * 0.2 THEN 1 END) AS lines_needing_restock,
|
||||||
|
ROUND(AVG(product_count), 1) AS avg_products_per_line,
|
||||||
|
SUM(revenue_30d) AS total_revenue_30d,
|
||||||
|
CASE WHEN SUM(revenue_30d) > 0
|
||||||
|
THEN SUM(profit_30d) * 100.0 / SUM(revenue_30d)
|
||||||
|
ELSE 0
|
||||||
|
END AS overall_avg_margin
|
||||||
|
FROM (${LINE_AGGREGATE_SQL}) agg
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalLines: parseInt(stats?.total_lines || 0),
|
||||||
|
brandCount: parseInt(stats?.brand_count || 0),
|
||||||
|
activeLines: parseInt(stats?.active_lines || 0),
|
||||||
|
oosLines: parseInt(stats?.oos_lines || 0),
|
||||||
|
linesNeedingRestock: parseInt(stats?.lines_needing_restock || 0),
|
||||||
|
avgProductsPerLine: parseFloat(stats?.avg_products_per_line || 0),
|
||||||
|
totalRevenue30d: parseFloat(stats?.total_revenue_30d || 0),
|
||||||
|
avgMargin: parseFloat(stats?.overall_avg_margin || 0),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching line stats:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch line stats.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /lines-aggregate/ (List product lines)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
try {
|
||||||
|
// --- Pagination ---
|
||||||
|
let page = parseInt(req.query.page, 10) || 1;
|
||||||
|
let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT;
|
||||||
|
limit = Math.min(limit, MAX_PAGE_LIMIT);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// --- Sorting ---
|
||||||
|
// Sort presets for curated multi-column sorts
|
||||||
|
const SORT_PRESETS = {
|
||||||
|
'by-brand': 'ORDER BY agg.brand ASC, agg.revenue_30d DESC NULLS LAST',
|
||||||
|
'top-revenue': 'ORDER BY agg.revenue_30d DESC NULLS LAST',
|
||||||
|
'needs-restock': 'ORDER BY agg.total_replenishment_units DESC NULLS LAST, agg.avg_stock_cover_days ASC NULLS FIRST',
|
||||||
|
'fastest-growing': 'ORDER BY agg.sales_growth_30d_vs_prev DESC NULLS LAST',
|
||||||
|
'newest': 'ORDER BY agg.earliest_received DESC NULLS LAST',
|
||||||
|
'low-stock': 'ORDER BY CASE WHEN agg.sales_30d > 0 THEN 0 ELSE 1 END, agg.avg_stock_cover_days ASC NULLS LAST',
|
||||||
|
};
|
||||||
|
|
||||||
|
let sortClause;
|
||||||
|
const sortQueryKey = req.query.sort || 'by-brand';
|
||||||
|
if (SORT_PRESETS[sortQueryKey]) {
|
||||||
|
sortClause = SORT_PRESETS[sortQueryKey];
|
||||||
|
} else {
|
||||||
|
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
|
||||||
|
const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'agg.brand';
|
||||||
|
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||||
|
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST');
|
||||||
|
sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Filtering ---
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
let paramCounter = 1;
|
||||||
|
|
||||||
|
for (const key in req.query) {
|
||||||
|
if (['page', 'limit', 'sort', 'order', 'preset'].includes(key)) continue;
|
||||||
|
|
||||||
|
let filterKey = key;
|
||||||
|
let operator = '=';
|
||||||
|
const value = req.query[key];
|
||||||
|
|
||||||
|
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
|
||||||
|
if (operatorMatch) {
|
||||||
|
filterKey = operatorMatch[1];
|
||||||
|
operator = operatorMatch[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnInfo = getSafeColumnInfo(filterKey);
|
||||||
|
if (columnInfo) {
|
||||||
|
const dbColumn = columnInfo.dbCol;
|
||||||
|
const valueType = columnInfo.type;
|
||||||
|
try {
|
||||||
|
let conditionFragment = '';
|
||||||
|
let needsParam = true;
|
||||||
|
switch (operator.toLowerCase()) {
|
||||||
|
case 'eq': operator = '='; break;
|
||||||
|
case 'ne': operator = '<>'; break;
|
||||||
|
case 'gt': operator = '>'; break;
|
||||||
|
case 'gte': operator = '>='; break;
|
||||||
|
case 'lt': operator = '<'; break;
|
||||||
|
case 'lte': operator = '<='; break;
|
||||||
|
case 'like': operator = 'LIKE'; needsParam = false; params.push(`%${parseValue(value, valueType)}%`); break;
|
||||||
|
case 'ilike': operator = 'ILIKE'; needsParam = false; params.push(`%${parseValue(value, valueType)}%`); break;
|
||||||
|
case 'between':
|
||||||
|
const [val1, val2] = String(value).split(',');
|
||||||
|
if (val1 !== undefined && val2 !== undefined) {
|
||||||
|
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
|
||||||
|
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
|
||||||
|
needsParam = false;
|
||||||
|
} else 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)));
|
||||||
|
needsParam = false;
|
||||||
|
} else continue;
|
||||||
|
break;
|
||||||
|
default: operator = '='; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsParam) {
|
||||||
|
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
|
||||||
|
params.push(parseValue(value, valueType));
|
||||||
|
} else if (!conditionFragment) {
|
||||||
|
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditionFragment) {
|
||||||
|
conditions.push(`(${conditionFragment})`);
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Execute Queries ---
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const baseSql = `FROM (${LINE_AGGREGATE_SQL}) agg ${whereClause}`;
|
||||||
|
|
||||||
|
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
|
||||||
|
const dataSql = `SELECT agg.* ${baseSql} ${sortClause} LIMIT $${paramCounter} OFFSET $${paramCounter + 1}`;
|
||||||
|
const dataParams = [...params, limit, offset];
|
||||||
|
|
||||||
|
const [countResult, dataResult] = await Promise.all([
|
||||||
|
pool.query(countSql, params),
|
||||||
|
pool.query(dataSql, dataParams)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countResult.rows[0].total, 10);
|
||||||
|
const lines = dataResult.rows.map(row => {
|
||||||
|
const transformedRow = { ...row };
|
||||||
|
for (const key in row) {
|
||||||
|
if (row[key] === null || row[key] === undefined) continue;
|
||||||
|
// snake_case -> camelCase
|
||||||
|
let camelKey = key.replace(/_(\d+[a-z])/g, '$1');
|
||||||
|
camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
|
if (camelKey !== key) {
|
||||||
|
transformedRow[camelKey] = row[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return transformedRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
lines,
|
||||||
|
pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching line metrics list:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch line metrics.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /lines-aggregate/:brand/:line/products (All products in a line)
|
||||||
|
router.get('/:brand/:line/products', async (req, res) => {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
const { brand, line } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
pm.pid, pm.title, pm.sku,
|
||||||
|
pm.current_stock, pm.current_stock_retail,
|
||||||
|
pm.sales_30d, pm.revenue_30d, pm.profit_30d, pm.margin_30d,
|
||||||
|
pm.sales_365d, pm.revenue_365d,
|
||||||
|
pm.sales_velocity_daily, pm.stock_cover_in_days,
|
||||||
|
pm.lifecycle_phase, pm.status, pm.abc_class,
|
||||||
|
pm.on_order_qty, pm.replenishment_units,
|
||||||
|
pm.date_first_received, pm.date_last_sold
|
||||||
|
FROM product_metrics pm
|
||||||
|
WHERE pm.brand = $1 AND pm.line = $2
|
||||||
|
AND (pm.current_stock > 0 OR pm.sales_365d > 0)
|
||||||
|
ORDER BY pm.revenue_30d DESC NULLS LAST
|
||||||
|
`, [brand, line]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalProducts: rows.length,
|
||||||
|
products: rows,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching line products:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch line products.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -27,6 +27,7 @@ const importSessionsRouter = require('./routes/import-sessions');
|
|||||||
const importAuditLogRouter = require('./routes/import-audit-log');
|
const importAuditLogRouter = require('./routes/import-audit-log');
|
||||||
const productEditorAuditLogRouter = require('./routes/product-editor-audit-log');
|
const productEditorAuditLogRouter = require('./routes/product-editor-audit-log');
|
||||||
const newsletterRouter = require('./routes/newsletter');
|
const newsletterRouter = require('./routes/newsletter');
|
||||||
|
const linesAggregateRouter = require('./routes/linesAggregate');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
const envPath = '/var/www/html/inventory/.env';
|
const envPath = '/var/www/html/inventory/.env';
|
||||||
@@ -138,6 +139,7 @@ async function startServer() {
|
|||||||
app.use('/api/import-audit-log', importAuditLogRouter);
|
app.use('/api/import-audit-log', importAuditLogRouter);
|
||||||
app.use('/api/product-editor-audit-log', productEditorAuditLogRouter);
|
app.use('/api/product-editor-audit-log', productEditorAuditLogRouter);
|
||||||
app.use('/api/newsletter', newsletterRouter);
|
app.use('/api/newsletter', newsletterRouter);
|
||||||
|
app.use('/api/lines-aggregate', linesAggregateRouter);
|
||||||
|
|
||||||
// Basic health check route
|
// Basic health check route
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const HtsLookup = lazy(() => import('./pages/HtsLookup'));
|
|||||||
const Vendors = lazy(() => import('./pages/Vendors'));
|
const Vendors = lazy(() => import('./pages/Vendors'));
|
||||||
const Categories = lazy(() => import('./pages/Categories'));
|
const Categories = lazy(() => import('./pages/Categories'));
|
||||||
const Brands = lazy(() => import('./pages/Brands'));
|
const Brands = lazy(() => import('./pages/Brands'));
|
||||||
|
const ProductLines = lazy(() => import('./pages/ProductLines'));
|
||||||
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
||||||
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
||||||
const Newsletter = lazy(() => import('./pages/Newsletter'));
|
const Newsletter = lazy(() => import('./pages/Newsletter'));
|
||||||
@@ -149,6 +150,13 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/product-lines" element={
|
||||||
|
<Protected page="product_lines">
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
<ProductLines />
|
||||||
|
</Suspense>
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
<Route path="/purchase-orders" element={
|
<Route path="/purchase-orders" element={
|
||||||
<Protected page="purchase_orders">
|
<Protected page="purchase_orders">
|
||||||
<Suspense fallback={<PageLoading />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
FilePenLine,
|
FilePenLine,
|
||||||
PenLine,
|
PenLine,
|
||||||
Mail,
|
Mail,
|
||||||
|
Layers,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -78,6 +79,12 @@ const inventoryItems = [
|
|||||||
url: "/brands",
|
url: "/brands",
|
||||||
permission: "access:brands"
|
permission: "access:brands"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Product Lines",
|
||||||
|
icon: Layers,
|
||||||
|
url: "/product-lines",
|
||||||
|
permission: "access:product_lines"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Vendors",
|
title: "Vendors",
|
||||||
icon: Truck,
|
icon: Truck,
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
const STATUS_STYLES: Record<string, string> = {
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
'Critical': 'bg-red-600 text-white border-transparent',
|
'Critical': 'bg-red-600 text-white border-transparent',
|
||||||
'Reorder Soon': 'bg-yellow-500 text-black border-secondary',
|
'Reorder Soon': 'bg-yellow-400 text-black border-transparent',
|
||||||
'Healthy': 'bg-green-600 text-white border-transparent',
|
'Healthy': 'bg-green-600 text-white border-transparent',
|
||||||
'Overstock': 'bg-blue-600 text-white border-secondary',
|
'Overstock': 'bg-teal-700 text-white border-transparent',
|
||||||
'At Risk': 'border-orange-500 text-orange-600',
|
'At Risk': 'border-orange-500 text-orange-600',
|
||||||
'New': 'bg-purple-600 text-white border-transparent',
|
'New': 'bg-green-600 text-white border-transparent',
|
||||||
'Unknown': 'bg-muted text-muted-foreground border-transparent',
|
'Unknown': 'bg-muted text-muted-foreground border-transparent',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PayloadSection label="Request Payload" data={detail.request_payload} />
|
<RequestPayloadSection payload={detail.request_payload} />
|
||||||
|
|
||||||
{detail.response_payload != null && (
|
{detail.response_payload != null && (
|
||||||
<PayloadSection label="Response Payload" data={detail.response_payload} />
|
<PayloadSection label="Response Payload" data={detail.response_payload} />
|
||||||
@@ -493,6 +493,36 @@ function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keys in request_payload that were NOT sent to the API — stored for audit context only */
|
||||||
|
const CONTEXT_ONLY_KEYS = new Set(["previous_values", "previous_ids"]);
|
||||||
|
|
||||||
|
function RequestPayloadSection({ payload }: { payload: unknown }) {
|
||||||
|
const parsed = typeof payload === "string" ? (() => { try { return JSON.parse(payload); } catch { return payload; } })() : payload;
|
||||||
|
|
||||||
|
// Extract context-only keys from the payload
|
||||||
|
let apiPayload = parsed;
|
||||||
|
let contextData: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
const contextEntries = Object.entries(obj).filter(([k]) => CONTEXT_ONLY_KEYS.has(k));
|
||||||
|
if (contextEntries.length > 0) {
|
||||||
|
const remaining = Object.fromEntries(Object.entries(obj).filter(([k]) => !CONTEXT_ONLY_KEYS.has(k)));
|
||||||
|
apiPayload = remaining;
|
||||||
|
contextData = Object.fromEntries(contextEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PayloadSection label="Request Payload" data={apiPayload} />
|
||||||
|
{contextData && (
|
||||||
|
<PayloadSection label="Previous Values" data={contextData} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Formatted JSON viewer ---
|
// --- Formatted JSON viewer ---
|
||||||
|
|
||||||
/** Unwrap double-encoded JSON strings from JSONB columns */
|
/** Unwrap double-encoded JSON strings from JSONB columns */
|
||||||
@@ -689,6 +719,18 @@ function JsonArray({ items, depth }: { items: unknown[]; depth: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arrays of objects — render each item as a collapsible block
|
||||||
|
const hasObjects = items.some((v) => v !== null && typeof v === "object");
|
||||||
|
if (hasObjects) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<JsonArrayItem key={i} index={i} value={item} depth={depth} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
@@ -700,3 +742,34 @@ function JsonArray({ items, depth }: { items: unknown[]; depth: number }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function JsonArrayItem({ index, value, depth }: { index: number; value: unknown; depth: number }) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const isObject = value !== null && typeof value === "object";
|
||||||
|
|
||||||
|
if (!isObject) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground mr-1">[{index}]</span>
|
||||||
|
<JsonValue value={value} depth={depth + 1} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="h-3 w-3 text-muted-foreground" /> : <ChevronRightIcon className="h-3 w-3 text-muted-foreground" />}
|
||||||
|
<span className="text-muted-foreground font-medium">[{index}]</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="ml-4 border-l border-border pl-3 mt-1">
|
||||||
|
<JsonValue value={value} depth={depth + 1} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
863
inventory/src/pages/ProductLines.tsx
Normal file
863
inventory/src/pages/ProductLines.tsx
Normal file
@@ -0,0 +1,863 @@
|
|||||||
|
import { useState, useMemo, useCallback } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import config from "../config";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { PHASE_CONFIG } from "@/utils/lifecyclePhases";
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
interface LineMetric {
|
||||||
|
brand: string;
|
||||||
|
line: string;
|
||||||
|
product_count: number;
|
||||||
|
active_product_count: number;
|
||||||
|
replenishable_product_count: number;
|
||||||
|
current_stock_units: number;
|
||||||
|
current_stock_cost: number;
|
||||||
|
current_stock_retail: number;
|
||||||
|
on_order_qty: number;
|
||||||
|
on_order_cost: number;
|
||||||
|
sales_7d: number;
|
||||||
|
revenue_7d: number;
|
||||||
|
sales_30d: number;
|
||||||
|
revenue_30d: number;
|
||||||
|
profit_30d: number;
|
||||||
|
cogs_30d: number;
|
||||||
|
sales_365d: number;
|
||||||
|
revenue_365d: number;
|
||||||
|
lifetime_sales: number;
|
||||||
|
lifetime_revenue: number;
|
||||||
|
avg_margin_30d: number | null;
|
||||||
|
total_velocity_daily: number;
|
||||||
|
avg_stock_cover_days: number | null;
|
||||||
|
avg_sells_out_in_days: number | null;
|
||||||
|
total_stockout_days_30d: number;
|
||||||
|
total_replenishment_units: number;
|
||||||
|
total_replenishment_cost: number;
|
||||||
|
phase_launch: number;
|
||||||
|
phase_mature: number;
|
||||||
|
phase_slow_mover: number;
|
||||||
|
phase_decay: number;
|
||||||
|
phase_dormant: number;
|
||||||
|
phase_preorder: number;
|
||||||
|
status_healthy: number;
|
||||||
|
status_reorder: number;
|
||||||
|
status_critical: number;
|
||||||
|
status_overstock: number;
|
||||||
|
status_at_risk: number;
|
||||||
|
status_new: number;
|
||||||
|
abc_a_count: number;
|
||||||
|
abc_b_count: number;
|
||||||
|
abc_c_count: number;
|
||||||
|
earliest_received: string | null;
|
||||||
|
latest_sale: string | null;
|
||||||
|
sales_growth_30d_vs_prev: number | null;
|
||||||
|
revenue_growth_30d_vs_prev: number | null;
|
||||||
|
line_status: string;
|
||||||
|
dominant_lifecycle_phase: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineResponse {
|
||||||
|
lines: LineMetric[];
|
||||||
|
pagination: { total: number; pages: number; currentPage: number; limit: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineFilterOptions {
|
||||||
|
brands: string[];
|
||||||
|
statuses: string[];
|
||||||
|
phases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineStats {
|
||||||
|
totalLines: number;
|
||||||
|
brandCount: number;
|
||||||
|
activeLines: number;
|
||||||
|
oosLines: number;
|
||||||
|
linesNeedingRestock: number;
|
||||||
|
avgProductsPerLine: number;
|
||||||
|
totalRevenue30d: number;
|
||||||
|
avgMargin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineProduct {
|
||||||
|
pid: number;
|
||||||
|
title: string;
|
||||||
|
sku: string;
|
||||||
|
current_stock: number;
|
||||||
|
sales_30d: number;
|
||||||
|
revenue_30d: number;
|
||||||
|
profit_30d: number;
|
||||||
|
margin_30d: number | null;
|
||||||
|
sales_365d: number;
|
||||||
|
lifecycle_phase: string | null;
|
||||||
|
status: string | null;
|
||||||
|
abc_class: string | null;
|
||||||
|
on_order_qty: number;
|
||||||
|
replenishment_units: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineProductsResponse {
|
||||||
|
totalProducts: number;
|
||||||
|
products: LineProduct[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column-level sorts (for clicking table headers)
|
||||||
|
type SortableColumn =
|
||||||
|
| 'brand' | 'line' | 'productCount' | 'activeProductCount'
|
||||||
|
| 'currentStockUnits' | 'currentStockCost' | 'revenue30d' | 'profit30d'
|
||||||
|
| 'avgMargin30d' | 'sales30d' | 'sales365d' | 'revenue365d'
|
||||||
|
| 'totalVelocityDaily' | 'avgStockCoverDays'
|
||||||
|
| 'salesGrowth30dVsPrev' | 'revenueGrowth30dVsPrev'
|
||||||
|
| 'lineStatus' | 'dominantLifecyclePhase';
|
||||||
|
|
||||||
|
// Curated multi-column sort presets (backend handles the ORDER BY)
|
||||||
|
type SortPreset = 'by-brand' | 'top-revenue' | 'needs-restock' | 'fastest-growing' | 'newest' | 'low-stock';
|
||||||
|
|
||||||
|
const SORT_PRESETS: { value: SortPreset; label: string }[] = [
|
||||||
|
{ value: 'by-brand', label: 'By Brand' },
|
||||||
|
{ value: 'top-revenue', label: 'Top Revenue' },
|
||||||
|
{ value: 'needs-restock', label: 'Needs Restock' },
|
||||||
|
{ value: 'fastest-growing', label: 'Fastest Growing' },
|
||||||
|
{ value: 'newest', label: 'Newest Lines' },
|
||||||
|
{ value: 'low-stock', label: 'Low Stock Cover' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
search: string;
|
||||||
|
brand: string;
|
||||||
|
status: string;
|
||||||
|
phase: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Formatting helpers ---
|
||||||
|
|
||||||
|
const formatCurrency = (value: number | string | null | undefined, digits = 0): string => {
|
||||||
|
if (value == null) return 'N/A';
|
||||||
|
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||||
|
if (isNaN(num)) return 'N/A';
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency', currency: 'USD',
|
||||||
|
minimumFractionDigits: digits, maximumFractionDigits: digits
|
||||||
|
}).format(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number | string | null | undefined, digits = 0): string => {
|
||||||
|
if (value == null) return 'N/A';
|
||||||
|
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||||
|
if (isNaN(num)) return 'N/A';
|
||||||
|
return num.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercentage = (value: number | string | null | undefined, digits = 1): string => {
|
||||||
|
if (value == null) return 'N/A';
|
||||||
|
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||||
|
if (isNaN(num)) return 'N/A';
|
||||||
|
return `${num.toFixed(digits)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatGrowth = (value: number | string | null | undefined, digits = 1) => {
|
||||||
|
if (value == null) return <span className="text-muted-foreground">--</span>;
|
||||||
|
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||||
|
if (isNaN(num)) return <span className="text-muted-foreground">--</span>;
|
||||||
|
const formatted = `${num >= 0 ? '+' : ''}${num.toFixed(digits)}%`;
|
||||||
|
return <span className={num >= 0 ? 'text-green-600' : 'text-red-600'}>{formatted}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 50;
|
||||||
|
|
||||||
|
// --- Status & Phase badges ---
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "outline" | "destructive" }> = {
|
||||||
|
active: { label: 'Active', variant: 'default' },
|
||||||
|
preorder: { label: 'Pre-order', variant: 'secondary' },
|
||||||
|
slow: { label: 'Slow', variant: 'secondary' },
|
||||||
|
out_of_stock: { label: 'Out of Stock', variant: 'destructive' },
|
||||||
|
dormant: { label: 'Dormant', variant: 'outline' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPhaseLabel = (phase: string): string =>
|
||||||
|
PHASE_CONFIG[phase]?.label || phase;
|
||||||
|
|
||||||
|
const getPhaseColor = (phase: string): string =>
|
||||||
|
PHASE_CONFIG[phase]?.color || '#94A3B8';
|
||||||
|
|
||||||
|
// Mini bar showing lifecycle phase distribution using shared PHASE_CONFIG colors
|
||||||
|
function PhaseBar({ line }: { line: LineMetric }) {
|
||||||
|
const phases = [
|
||||||
|
{ key: 'preorder', count: Number(line.phase_preorder) },
|
||||||
|
{ key: 'launch', count: Number(line.phase_launch) },
|
||||||
|
{ key: 'decay', count: Number(line.phase_decay) },
|
||||||
|
{ key: 'mature', count: Number(line.phase_mature) },
|
||||||
|
{ key: 'slow_mover', count: Number(line.phase_slow_mover) },
|
||||||
|
{ key: 'dormant', count: Number(line.phase_dormant) },
|
||||||
|
];
|
||||||
|
const total = phases.reduce((s, p) => s + p.count, 0);
|
||||||
|
if (total === 0) return <span className="text-muted-foreground text-xs">--</span>;
|
||||||
|
|
||||||
|
const activePhases = phases.filter(p => p.count > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex flex-col items-start gap-1.5 cursor-help">
|
||||||
|
<div className="flex h-2 w-20 rounded-full overflow-hidden bg-muted">
|
||||||
|
{activePhases.map(p => (
|
||||||
|
<div
|
||||||
|
key={p.key}
|
||||||
|
className="h-full"
|
||||||
|
style={{ width: `${(p.count / total) * 100}%`, backgroundColor: getPhaseColor(p.key) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{line.dominant_lifecycle_phase ? getPhaseLabel(line.dominant_lifecycle_phase) : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{activePhases.map(p => (
|
||||||
|
<div key={p.key} className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: getPhaseColor(p.key) }} />
|
||||||
|
<span>{getPhaseLabel(p.key)}</span>
|
||||||
|
<span className="text-primary-foreground/70 ml-auto pl-2">{p.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stock health status bar — ordered by severity for visual scanning
|
||||||
|
const STATUS_BAR_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
critical: { label: 'Critical', color: '#ef4444' },
|
||||||
|
reorder: { label: 'Reorder Soon', color: '#fbbf24' },
|
||||||
|
healthy: { label: 'Healthy', color: '#22c55e' },
|
||||||
|
at_risk: { label: 'At Risk', color: '#f97316' },
|
||||||
|
overstock: { label: 'Overstock', color: '#0f766e' },
|
||||||
|
preorder: { label: 'Pre-order', color: '#3b82f6' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determines display order — array position = render order in bar
|
||||||
|
const STATUS_ORDER: string[] = ['critical', 'reorder', 'healthy', 'at_risk', 'overstock', 'preorder'];
|
||||||
|
|
||||||
|
// Map DB status strings to STATUS_BAR_CONFIG keys
|
||||||
|
const STATUS_KEY_MAP: Record<string, string> = {
|
||||||
|
'Critical': 'critical',
|
||||||
|
'Reorder Soon': 'reorder',
|
||||||
|
'Healthy': 'healthy',
|
||||||
|
'Overstock': 'overstock',
|
||||||
|
'At Risk': 'at_risk',
|
||||||
|
'New': 'healthy',
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBar({ line }: { line: LineMetric }) {
|
||||||
|
const statusCounts: Record<string, number> = {
|
||||||
|
critical: Number(line.status_critical),
|
||||||
|
reorder: Number(line.status_reorder),
|
||||||
|
healthy: Number(line.status_healthy) + Number(line.status_new),
|
||||||
|
at_risk: Number(line.status_at_risk),
|
||||||
|
overstock: Number(line.status_overstock),
|
||||||
|
preorder: Number(line.phase_preorder),
|
||||||
|
};
|
||||||
|
|
||||||
|
const statuses = STATUS_ORDER.map(key => ({ key, count: statusCounts[key] }));
|
||||||
|
const total = statuses.reduce((s, st) => s + st.count, 0);
|
||||||
|
if (total === 0) return <span className="text-muted-foreground text-xs">--</span>;
|
||||||
|
|
||||||
|
const active = statuses.filter(s => s.count > 0);
|
||||||
|
const stockCover = line.avg_stock_cover_days != null ? Number(line.avg_stock_cover_days) : null;
|
||||||
|
const onOrder = Number(line.on_order_qty);
|
||||||
|
|
||||||
|
// Summary label: pick the most notable non-healthy/non-preorder status, or "Good"
|
||||||
|
const neutralCount = statusCounts.healthy + statusCounts.preorder;
|
||||||
|
const healthyPct = neutralCount / total;
|
||||||
|
let summaryLabel = 'Good';
|
||||||
|
if (Number(line.current_stock_units) === 0 && onOrder === 0) {
|
||||||
|
summaryLabel = 'No Stock';
|
||||||
|
} else if (stockCover !== null && stockCover < 14) {
|
||||||
|
summaryLabel = `${Math.round(stockCover)}d cover`;
|
||||||
|
} else if (healthyPct < 0.8 && active.length > 1) {
|
||||||
|
// Find the largest problem segment
|
||||||
|
const biggest = active
|
||||||
|
.filter(s => s.key !== 'healthy' && s.key !== 'preorder')
|
||||||
|
.sort((a, b) => b.count - a.count)[0];
|
||||||
|
if (biggest) summaryLabel = `${biggest.count} ${STATUS_BAR_CONFIG[biggest.key].label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex flex-col items-end gap-1.5 cursor-help">
|
||||||
|
<div className="flex h-2 w-20 rounded-full overflow-hidden bg-muted">
|
||||||
|
{active.map(s => (
|
||||||
|
<div
|
||||||
|
key={s.key}
|
||||||
|
className="h-full"
|
||||||
|
style={{ width: `${(s.count / total) * 100}%`, backgroundColor: STATUS_BAR_CONFIG[s.key].color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">{summaryLabel}</span>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{active.map(s => (
|
||||||
|
<div key={s.key} className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: STATUS_BAR_CONFIG[s.key].color }} />
|
||||||
|
<span>{STATUS_BAR_CONFIG[s.key].label}</span>
|
||||||
|
<span className="text-primary-foreground/70 ml-auto pl-2">{s.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stockCover !== null && (
|
||||||
|
<div className="text-xs text-primary-foreground/70 pt-1 border-t border-primary-foreground/20 mt-1">
|
||||||
|
{Math.round(stockCover)}d avg cover
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onOrder > 0 && (
|
||||||
|
<div className="text-xs text-primary-foreground/70">
|
||||||
|
{formatNumber(onOrder)} on order
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expandable product detail row
|
||||||
|
function LineProductDetail({ brand, line }: { brand: string; line: string }) {
|
||||||
|
const { data, isLoading } = useQuery<LineProductsResponse>({
|
||||||
|
queryKey: ['lineProducts', brand, line],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${config.apiUrl}/lines-aggregate/${encodeURIComponent(brand)}/${encodeURIComponent(line)}/products`,
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={13} className="bg-muted/30 p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.products.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={13} className="bg-muted/30 p-4">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">{data.totalProducts} products in this line — sorted by revenue</p>
|
||||||
|
<div className="max-h-[400px] overflow-y-auto rounded border bg-background">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow className="text-xs">
|
||||||
|
<TableHead className="py-1">Product</TableHead>
|
||||||
|
<TableHead className="py-1 text-right">Stock</TableHead>
|
||||||
|
<TableHead className="py-1 text-right">Sales (30d)</TableHead>
|
||||||
|
<TableHead className="py-1 text-right">Revenue (30d)</TableHead>
|
||||||
|
<TableHead className="py-1 text-right">Margin</TableHead>
|
||||||
|
<TableHead className="py-1 text-right">Phase</TableHead>
|
||||||
|
<TableHead className="py-1 text-right">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.products.map((p) => (
|
||||||
|
<TableRow key={p.pid} className="text-xs">
|
||||||
|
<TableCell className="py-1.5 max-w-[300px]">
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="block truncate">{p.title}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
<p>{p.title}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-right">{formatNumber(p.current_stock)}</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-right">{formatNumber(p.sales_30d)}</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-right">{formatCurrency(p.revenue_30d)}</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-right">{formatPercentage(p.margin_30d)}</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-right">
|
||||||
|
{p.lifecycle_phase ? (
|
||||||
|
<span
|
||||||
|
className="inline-block px-1.5 py-0.5 rounded text-[10px] text-white"
|
||||||
|
style={{ backgroundColor: getPhaseColor(p.lifecycle_phase) }}
|
||||||
|
>
|
||||||
|
{getPhaseLabel(p.lifecycle_phase)}
|
||||||
|
</span>
|
||||||
|
) : '--'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-1.5 text-right">
|
||||||
|
{p.status ? (
|
||||||
|
<span
|
||||||
|
className="inline-block px-1.5 py-0.5 rounded text-[10px] text-white"
|
||||||
|
style={{ backgroundColor: STATUS_BAR_CONFIG[STATUS_KEY_MAP[p.status] || 'healthy']?.color }}
|
||||||
|
>
|
||||||
|
{p.status}
|
||||||
|
</span>
|
||||||
|
) : '--'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Page Component ---
|
||||||
|
|
||||||
|
export function ProductLines() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [limit] = useState(ITEMS_PER_PAGE);
|
||||||
|
// Sort can be either a preset (compound sort) or a single column
|
||||||
|
const [sortPreset, setSortPreset] = useState<SortPreset | null>('newest');
|
||||||
|
const [sortColumn, setSortColumn] = useState<SortableColumn>("brand");
|
||||||
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
|
const [expandedLine, setExpandedLine] = useState<string | null>(null);
|
||||||
|
const [filters, setFilters] = useState<Filters>({
|
||||||
|
search: "",
|
||||||
|
brand: "all",
|
||||||
|
status: "all",
|
||||||
|
phase: "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The active sort key sent to the backend
|
||||||
|
const activeSort = sortPreset || sortColumn;
|
||||||
|
|
||||||
|
// --- Data Fetching ---
|
||||||
|
|
||||||
|
const queryParams = useMemo(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', page.toString());
|
||||||
|
params.set('limit', limit.toString());
|
||||||
|
params.set('sort', activeSort);
|
||||||
|
if (!sortPreset) {
|
||||||
|
params.set('order', sortDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
params.set('line_ilike', filters.search);
|
||||||
|
}
|
||||||
|
if (filters.brand !== 'all') {
|
||||||
|
params.set('brand', filters.brand);
|
||||||
|
}
|
||||||
|
if (filters.status !== 'all') {
|
||||||
|
params.set('lineStatus', filters.status);
|
||||||
|
}
|
||||||
|
if (filters.phase !== 'all') {
|
||||||
|
params.set('dominantLifecyclePhase', filters.phase);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}, [page, limit, activeSort, sortPreset, sortDirection, filters]);
|
||||||
|
|
||||||
|
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<LineResponse, Error>({
|
||||||
|
queryKey: ['productLines', queryParams.toString()],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`${config.apiUrl}/lines-aggregate?${queryParams.toString()}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Network response was not ok (${res.status})`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: statsData, isLoading: isLoadingStats } = useQuery<LineStats, Error>({
|
||||||
|
queryKey: ['productLineStats'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`${config.apiUrl}/lines-aggregate/stats`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch stats");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: filterOptions } = useQuery<LineFilterOptions, Error>({
|
||||||
|
queryKey: ['productLineFilterOptions'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`${config.apiUrl}/lines-aggregate/filter-options`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch filter options");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
|
||||||
|
const handleSort = useCallback((column: SortableColumn) => {
|
||||||
|
setSortPreset(null); // Clear preset when manually sorting by column
|
||||||
|
setSortDirection(prev => (sortColumn === column && prev === "desc" ? "asc" : "desc"));
|
||||||
|
setSortColumn(column);
|
||||||
|
setPage(1);
|
||||||
|
}, [sortColumn]);
|
||||||
|
|
||||||
|
const handlePresetChange = useCallback((preset: string) => {
|
||||||
|
if (preset === 'custom') {
|
||||||
|
// Switching to the "custom" placeholder does nothing; user clicks column headers
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSortPreset(preset as SortPreset);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((name: keyof Filters, value: string) => {
|
||||||
|
setFilters(prev => ({ ...prev, [name]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
if (newPage >= 1 && newPage <= (listData?.pagination.pages ?? 1)) {
|
||||||
|
setPage(newPage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (brand: string, line: string) => {
|
||||||
|
const key = `${brand}|||${line}`;
|
||||||
|
setExpandedLine(prev => prev === key ? null : key);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Derived ---
|
||||||
|
const lines = listData?.lines ?? [];
|
||||||
|
const pagination = listData?.pagination;
|
||||||
|
const totalPages = pagination?.pages ?? 0;
|
||||||
|
|
||||||
|
const sortIndicator = (col: SortableColumn) => {
|
||||||
|
if (sortPreset || sortColumn !== col) return null;
|
||||||
|
return <span className="ml-1">{sortDirection === 'asc' ? '\u2191' : '\u2193'}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
|
||||||
|
className="container mx-auto py-6 space-y-4"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Product Lines</h1>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<motion.div layout="preserve-aspect" transition={{ duration: 0.15 }} className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Product Lines</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : (
|
||||||
|
<div className="text-2xl font-bold">{formatNumber(statsData?.totalLines)}</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
|
||||||
|
`${formatNumber(statsData?.activeLines)} active across ${formatNumber(statsData?.brandCount)} brands`}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Avg Products / Line</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : (
|
||||||
|
<div className="text-2xl font-bold">{formatNumber(statsData?.avgProductsPerLine, 1)}</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">Average line depth</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Avg Margin (30d)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : (
|
||||||
|
<div className="text-2xl font-bold">{formatPercentage(statsData?.avgMargin)}</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
|
||||||
|
`${formatCurrency(statsData?.totalRevenue30d)} total revenue`}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
className={`cursor-pointer transition-colors hover:border-primary/40 ${sortPreset === 'needs-restock' ? 'border-primary' : ''}`}
|
||||||
|
onClick={() => { setSortPreset('needs-restock'); setPage(1); }}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Lines Needing Restock</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : (
|
||||||
|
<div className="text-2xl font-bold">{formatNumber(statsData?.linesNeedingRestock)}</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
|
||||||
|
`${formatNumber(statsData?.oosLines)} fully out of stock`}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Filters & Sort */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search lines..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||||
|
className="w-full sm:w-[250px]"
|
||||||
|
/>
|
||||||
|
<Select value={filters.brand} onValueChange={(v) => handleFilterChange('brand', v)}>
|
||||||
|
<SelectTrigger className="w-full sm:w-[200px]">
|
||||||
|
<SelectValue placeholder="Brand" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Brands</SelectItem>
|
||||||
|
{filterOptions?.brands?.map((b) => (
|
||||||
|
<SelectItem key={b} value={b}>{b}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filters.status} onValueChange={(v) => handleFilterChange('status', v)}>
|
||||||
|
<SelectTrigger className="w-full sm:w-[160px]">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Statuses</SelectItem>
|
||||||
|
{filterOptions?.statuses?.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{statusConfig[s]?.label || s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filters.phase} onValueChange={(v) => handleFilterChange('phase', v)}>
|
||||||
|
<SelectTrigger className="w-full sm:w-[180px]">
|
||||||
|
<SelectValue placeholder="Lifecycle" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Phases</SelectItem>
|
||||||
|
{filterOptions?.phases?.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{getPhaseLabel(p)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<Select value={sortPreset || 'custom'} onValueChange={handlePresetChange}>
|
||||||
|
<SelectTrigger className="w-full sm:w-[180px]">
|
||||||
|
<SelectValue placeholder="Sort by..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SORT_PRESETS.map((p) => (
|
||||||
|
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
{!sortPreset && (
|
||||||
|
<SelectItem value="custom" disabled>Custom sort</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-8"></TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("brand")} className="cursor-pointer">
|
||||||
|
Brand{sortIndicator("brand")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("line")} className="cursor-pointer">
|
||||||
|
Line{sortIndicator("line")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("productCount")} className="cursor-pointer text-right">
|
||||||
|
Products{sortIndicator("productCount")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("currentStockUnits")} className="cursor-pointer text-right">
|
||||||
|
Stock{sortIndicator("currentStockUnits")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("revenue30d")} className="cursor-pointer text-right">
|
||||||
|
Revenue (30d){sortIndicator("revenue30d")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("profit30d")} className="cursor-pointer text-right">
|
||||||
|
Profit (30d){sortIndicator("profit30d")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("avgMargin30d")} className="cursor-pointer text-right">
|
||||||
|
Margin{sortIndicator("avgMargin30d")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("totalVelocityDaily")} className="cursor-pointer text-right whitespace-nowrap">
|
||||||
|
Velocity/d{sortIndicator("totalVelocityDaily")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("salesGrowth30dVsPrev")} className="cursor-pointer text-right">
|
||||||
|
Growth{sortIndicator("salesGrowth30dVsPrev")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">Lifecycle</TableHead>
|
||||||
|
<TableHead className="text-right">Product Status</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("lineStatus")} className="cursor-pointer text-right">
|
||||||
|
Status{sortIndicator("lineStatus")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoadingList && !listData ? (
|
||||||
|
Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<TableRow key={`skel-${i}`}>
|
||||||
|
<TableCell><Skeleton className="h-4 w-4" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-28" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-36" /></TableCell>
|
||||||
|
<TableCell className="text-right"><Skeleton className="h-5 w-10 ml-auto" /></TableCell>
|
||||||
|
<TableCell className="text-right"><Skeleton className="h-5 w-14 ml-auto" /></TableCell>
|
||||||
|
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
|
||||||
|
<TableCell className="text-right"><Skeleton className="h-5 w-18 ml-auto" /></TableCell>
|
||||||
|
<TableCell className="text-right"><Skeleton className="h-5 w-14 ml-auto" /></TableCell>
|
||||||
|
<TableCell className="text-right"><Skeleton className="h-5 w-12 ml-auto" /></TableCell>
|
||||||
|
<TableCell className="text-right"><Skeleton className="h-5 w-14 ml-auto" /></TableCell>
|
||||||
|
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
|
||||||
|
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||||
|
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : listError ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={13} className="text-center py-8 text-destructive">
|
||||||
|
Error loading product lines: {listError.message}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : lines.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={13} className="text-center py-8 text-muted-foreground">
|
||||||
|
No product lines found matching your criteria.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
lines.map((line) => {
|
||||||
|
const key = `${line.brand}|||${line.line}`;
|
||||||
|
const isExpanded = expandedLine === key;
|
||||||
|
const sc = statusConfig[line.line_status] || statusConfig.dormant;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence key={key}>
|
||||||
|
<TableRow
|
||||||
|
className={`cursor-pointer hover:bg-muted/50 ${line.line_status === 'out_of_stock' || line.line_status === 'dormant' ? 'opacity-60' : ''}`}
|
||||||
|
onClick={() => toggleExpand(line.brand, line.line)}
|
||||||
|
>
|
||||||
|
<TableCell className="w-8 px-2">
|
||||||
|
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium text-sm whitespace-nowrap max-w-[200px] overflow-hidden text-ellipsis">{line.brand}</TableCell>
|
||||||
|
<TableCell className="text-sm">{line.line}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatNumber(line.product_count)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatNumber(line.current_stock_units)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(line.revenue_30d)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(line.profit_30d)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatPercentage(line.avg_margin_30d)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatNumber(line.total_velocity_daily, 1)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatGrowth(line.sales_growth_30d_vs_prev)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<PhaseBar line={line} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<StatusBar line={line} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Badge variant={sc.variant} className="whitespace-nowrap">{sc.label}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isExpanded && (
|
||||||
|
<LineProductDetail brand={line.brand} line={line.line} />
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && pagination && (
|
||||||
|
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex justify-center">
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage - 1); }}
|
||||||
|
aria-disabled={pagination.currentPage === 1}
|
||||||
|
className={pagination.currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
{[...Array(Math.min(totalPages, 7))].map((_, i) => {
|
||||||
|
// Show pages around current page
|
||||||
|
let pageNum: number;
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (pagination.currentPage <= 4) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (pagination.currentPage >= totalPages - 3) {
|
||||||
|
pageNum = totalPages - 6 + i;
|
||||||
|
} else {
|
||||||
|
pageNum = pagination.currentPage - 3 + i;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PaginationItem key={pageNum}>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => { e.preventDefault(); handlePageChange(pageNum); }}
|
||||||
|
isActive={pagination.currentPage === pageNum}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage + 1); }}
|
||||||
|
aria-disabled={pagination.currentPage >= totalPages}
|
||||||
|
className={pagination.currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductLines;
|
||||||
@@ -269,7 +269,7 @@ export function Settings() {
|
|||||||
|
|
||||||
<TabsContent value="audit-log" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
<TabsContent value="audit-log" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
<Protected
|
<Protected
|
||||||
adminOnly
|
permission="settings:audit_log"
|
||||||
fallback={
|
fallback={
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const PHASE_CONFIG: Record<string, { label: string; color: string }> = {
|
export const PHASE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
preorder: { label: "Pre-order", color: "#3B82F6" },
|
preorder: { label: "Pre-order", color: "#3B82F6" },
|
||||||
launch: { label: "Launch", color: "#22C55E" },
|
launch: { label: "Launch", color: "#84cc16" },
|
||||||
decay: { label: "Active", color: "#F59E0B" },
|
decay: { label: "Active", color: "#F59E0B" },
|
||||||
mature: { label: "Evergreen", color: "#8B5CF6" },
|
mature: { label: "Evergreen", color: "#8B5CF6" },
|
||||||
slow_mover: { label: "Slow Mover", color: "#14B8A6" },
|
slow_mover: { label: "Slow Mover", color: "#06B6D4" },
|
||||||
dormant: { label: "Dormant", color: "#6B7280" },
|
dormant: { label: "Dormant", color: "#6B7280" },
|
||||||
unknown: { label: "Unclassified", color: "#94A3B8" },
|
unknown: { label: "Unclassified", color: "#94A3B8" },
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user