Updates and fixes for products page
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead.
|
||||
* If you use the task tool to have an agent investigate something, make sure to let it know to avoid using glob
|
||||
* Prefer solving tasks in a single session. Only spawn subagents for genuinely independent workstreams.
|
||||
* Prefer solving tasks in a single session. Only spawn subagents for genuinely independent workstreams.
|
||||
* The postgres/query tool is not working and not connected to the current version of the database. If you need to query the database for any reason you can use "ssh netcup" and use psql on the server with inventory_readonly 6D3GUkxuFgi2UghwgnUd
|
||||
@@ -435,7 +435,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
||||
LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id
|
||||
LEFT JOIN product_last_sold pls ON p.pid = pls.pid
|
||||
WHERE ${incrementalUpdate ? `
|
||||
p.date_created > ? OR
|
||||
p.date_created >= DATE(?) OR
|
||||
p.stamp > ? OR
|
||||
ci.stamp > ? OR
|
||||
pcp.date_deactive > ? OR
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -381,8 +381,8 @@ export function RecommendationTable({ category }: RecommendationTableProps) {
|
||||
<span className={`text-sm ${p.current_stock <= 0 ? "text-red-500" : p.is_low_stock ? "text-yellow-600" : ""}`}>
|
||||
{p.current_stock ?? 0}
|
||||
</span>
|
||||
{p.on_order_qty > 0 && (
|
||||
<span className="text-xs text-blue-500 ml-1">(+{p.on_order_qty})</span>
|
||||
{p.preorder_count > 0 && (
|
||||
<span className="text-xs text-blue-500 ml-1">(+{p.preorder_count})</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">{p.sales_7d ?? 0}</TableCell>
|
||||
|
||||
@@ -6,19 +6,20 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { ProductMetric, ProductStatus } from "@/types/products";
|
||||
import {
|
||||
getStatusBadge,
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
formatPercentage,
|
||||
formatDays,
|
||||
formatDate,
|
||||
import { ProductMetric } from "@/types/products";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
formatPercentage,
|
||||
formatDays,
|
||||
formatDate,
|
||||
formatBoolean
|
||||
} from "@/utils/productUtils";
|
||||
import { StatusBadge } from "@/components/products/StatusBadge";
|
||||
import { transformMetricsRow } from "@/utils/transformUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import config from "@/config";
|
||||
import { ResponsiveContainer, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from "recharts";
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
|
||||
@@ -30,8 +31,7 @@ interface ProductPurchaseOrder {
|
||||
receivedDate: string | null;
|
||||
ordered: number;
|
||||
received: number;
|
||||
status: number;
|
||||
receivingStatus: number;
|
||||
status: string;
|
||||
costPrice: number;
|
||||
notes: string | null;
|
||||
leadTimeDays: number | null;
|
||||
@@ -42,7 +42,6 @@ interface ProductTimeSeries {
|
||||
month: string;
|
||||
sales: number;
|
||||
revenue: number;
|
||||
profit: number;
|
||||
}[];
|
||||
recentPurchases: ProductPurchaseOrder[];
|
||||
}
|
||||
@@ -63,36 +62,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
throw new Error(`Failed to fetch product details (${response.status}): ${errorData.error || 'Server error'}`);
|
||||
}
|
||||
const rawData = await response.json();
|
||||
|
||||
// Transform snake_case to camelCase and convert string numbers to actual numbers
|
||||
const transformed: any = {};
|
||||
Object.entries(rawData).forEach(([key, value]) => {
|
||||
// Better handling of snake_case to camelCase conversion
|
||||
let camelKey = key;
|
||||
|
||||
// First handle cases like sales_7d -> sales7d
|
||||
camelKey = camelKey.replace(/_(\d+[a-z])/g, '$1');
|
||||
|
||||
// Then handle regular snake_case -> camelCase
|
||||
camelKey = camelKey.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase());
|
||||
|
||||
// Convert numeric strings to actual numbers
|
||||
if (typeof value === 'string' && !isNaN(Number(value)) &&
|
||||
!key.toLowerCase().includes('date') && key !== 'sku' && key !== 'title' &&
|
||||
key !== 'brand' && key !== 'vendor') {
|
||||
transformed[camelKey] = Number(value);
|
||||
} else {
|
||||
transformed[camelKey] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure pid is a number
|
||||
transformed.pid = typeof transformed.pid === 'string' ?
|
||||
parseInt(transformed.pid, 10) : transformed.pid;
|
||||
|
||||
console.log("Transformed product data:", transformed);
|
||||
|
||||
return transformed;
|
||||
return transformMetricsRow(rawData) as ProductMetric;
|
||||
},
|
||||
enabled: !!productId, // Only run query when productId is truthy
|
||||
});
|
||||
@@ -108,48 +78,58 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
throw new Error(`Failed to fetch time series data (${response.status}): ${errorData.error || 'Server error'}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Ensure the monthly_sales data is properly formatted for charts
|
||||
const formattedMonthlySales = data.monthly_sales.map((item: any) => ({
|
||||
|
||||
// Map backend field names (units_sold) to frontend chart keys (sales)
|
||||
// Reverse from DESC to ASC so the chart shows oldest-to-newest (left-to-right)
|
||||
const formattedMonthlySales = [...(data.monthly_sales || [])].reverse().map((item: any) => ({
|
||||
month: item.month,
|
||||
sales: Number(item.sales),
|
||||
revenue: Number(item.revenue),
|
||||
profit: Number(item.profit || 0)
|
||||
sales: Number(item.units_sold || 0),
|
||||
revenue: Number(item.revenue || 0),
|
||||
}));
|
||||
|
||||
|
||||
// Transform snake_case PO fields to camelCase expected by ProductPurchaseOrder
|
||||
const formattedPurchases: ProductPurchaseOrder[] = (data.recent_purchases || []).map((po: any) => ({
|
||||
poId: po.po_id,
|
||||
date: po.date,
|
||||
expectedDate: po.expected_date,
|
||||
receivedDate: po.received_date,
|
||||
ordered: po.ordered,
|
||||
received: po.received,
|
||||
status: po.status,
|
||||
costPrice: po.cost_price,
|
||||
notes: po.notes,
|
||||
leadTimeDays: po.lead_time_days,
|
||||
}));
|
||||
|
||||
return {
|
||||
monthlySales: formattedMonthlySales,
|
||||
recentPurchases: data.recent_purchases || []
|
||||
recentPurchases: formattedPurchases,
|
||||
};
|
||||
},
|
||||
enabled: !!productId, // Only run query when productId is truthy
|
||||
});
|
||||
|
||||
// Get PO status names
|
||||
const getPOStatusName = (status: number): string => {
|
||||
const statusMap: {[key: number]: string} = {
|
||||
0: 'Canceled',
|
||||
1: 'Created',
|
||||
10: 'Ready to Send',
|
||||
11: 'Ordered',
|
||||
12: 'Preordered',
|
||||
13: 'Electronically Sent',
|
||||
15: 'Receiving Started',
|
||||
50: 'Completed'
|
||||
// Get PO status display names (DB stores text statuses)
|
||||
const getPOStatusName = (status: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
canceled: 'Canceled',
|
||||
created: 'Created',
|
||||
ordered: 'Ordered',
|
||||
electronically_sent: 'Electronically Sent',
|
||||
receiving_started: 'Receiving Started',
|
||||
done: 'Completed',
|
||||
};
|
||||
return statusMap[status] || 'Unknown';
|
||||
};
|
||||
|
||||
// Get status badge color class
|
||||
const getStatusBadgeClass = (status: number): string => {
|
||||
if (status === 0) return "bg-destructive text-destructive-foreground"; // Canceled
|
||||
if (status === 50) return "bg-green-600 text-white"; // Completed
|
||||
if (status >= 15) return "bg-amber-500 text-black"; // In progress
|
||||
return "bg-blue-600 text-white"; // Other statuses
|
||||
const getStatusBadgeClass = (status: string): string => {
|
||||
if (status === 'canceled') return "bg-destructive text-destructive-foreground";
|
||||
if (status === 'done') return "bg-green-600 text-white";
|
||||
if (status === 'receiving_started') return "bg-amber-500 text-black";
|
||||
return "bg-blue-600 text-white"; // created, ordered, electronically_sent
|
||||
};
|
||||
|
||||
const isLoading = isLoadingProduct || isLoadingTimeSeries;
|
||||
|
||||
if (!productId) return null; // Don't render anything if no ID
|
||||
|
||||
return (
|
||||
@@ -160,7 +140,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-4 border-b sticky top-0 bg-background z-10">
|
||||
<div className="flex items-center gap-4 overflow-hidden">
|
||||
{isLoading ? (
|
||||
{isLoadingProduct ? (
|
||||
<Skeleton className="h-16 w-16 rounded-lg" />
|
||||
) : product?.imageUrl ? (
|
||||
<div className="h-16 w-16 rounded-lg border bg-white p-1 flex-shrink-0">
|
||||
@@ -171,14 +151,23 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<VaulDrawer.Title className="text-lg font-semibold truncate">
|
||||
{isLoading ? <Skeleton className="h-6 w-3/4" /> : product?.title || 'Product Detail'}
|
||||
{isLoadingProduct ? <Skeleton className="h-6 w-3/4" /> : product?.title || 'Product Detail'}
|
||||
</VaulDrawer.Title>
|
||||
<VaulDrawer.Description className="text-sm text-muted-foreground">
|
||||
{isLoading ? <Skeleton className="h-4 w-1/2 mt-1" /> : product?.sku || ''}
|
||||
{isLoadingProduct ? <Skeleton className="h-4 w-1/2 mt-1" /> : (
|
||||
<>
|
||||
{product?.sku || ''}
|
||||
{product?.lastCalculated && (
|
||||
<span className="ml-2 text-xs text-muted-foreground/70">
|
||||
· Updated {formatDate(product.lastCalculated)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</VaulDrawer.Description>
|
||||
{/* Show Status Badge */}
|
||||
{!isLoading && product && (
|
||||
<div className="mt-1" dangerouslySetInnerHTML={{ __html: getStatusBadge(product.status as ProductStatus) }} />
|
||||
{!isLoadingProduct && product && (
|
||||
<div className="mt-1"><StatusBadge status={product.status as string} /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,7 +178,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
{isLoadingProduct ? (
|
||||
<div className="p-4 space-y-4">
|
||||
<Skeleton className="h-8 w-1/2" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -234,6 +223,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<InfoItem label="Landing Cost" value={formatCurrency(product.currentLandingCostPrice)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Customer Engagement</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Rating" value={product.rating != null ? `${product.rating.toFixed(1)} / 5` : 'N/A'} />
|
||||
<InfoItem label="Reviews" value={formatNumber(product.reviews)} />
|
||||
<InfoItem label="Basket Adds" value={formatNumber(product.baskets)} />
|
||||
<InfoItem label="Stock Alerts" value={formatNumber(product.notifies)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="inventory" className="space-y-4">
|
||||
@@ -248,7 +246,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Stock Position</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Status" value={<div className="scale-90 origin-left" dangerouslySetInnerHTML={{ __html: getStatusBadge(product.status as ProductStatus) }} />} />
|
||||
<InfoItem label="Status" value={<div className="scale-90 origin-left"><StatusBadge status={product.status as string} /></div>} />
|
||||
<InfoItem label="Stock Cover" value={formatDays(product.stockCoverInDays)} />
|
||||
<InfoItem label="Sells Out In" value={formatDays(product.sellsOutInDays)} />
|
||||
<InfoItem label="Overstock Units" value={formatNumber(product.overstockedUnits)} />
|
||||
@@ -264,6 +262,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<InfoItem label="Earliest Arrival" value={formatDate(product.earliestExpectedDate)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Service Level (30 Days)</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Fill Rate" value={formatPercentage(product.fillRate30d)} />
|
||||
<InfoItem label="Service Level" value={formatPercentage(product.serviceLevel30d)} />
|
||||
<InfoItem label="Stockout Incidents" value={formatNumber(product.stockoutIncidents30d)} />
|
||||
<InfoItem label="Lost Sales Incidents" value={formatNumber(product.lostSalesIncidents30d)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="performance" className="space-y-4">
|
||||
@@ -288,36 +295,29 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis yAxisId="left" />
|
||||
<YAxis yAxisId="right" orientation="right" />
|
||||
<Tooltip
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'revenue' || name === 'profit') {
|
||||
if (name === 'Revenue') {
|
||||
return [formatCurrency(value), name];
|
||||
}
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="sales"
|
||||
name="Units Sold"
|
||||
stroke="#8884d8"
|
||||
activeDot={{ r: 8 }}
|
||||
type="monotone"
|
||||
dataKey="sales"
|
||||
name="Units Sold"
|
||||
stroke="#8884d8"
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
<Line
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#82ca9d"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="profit"
|
||||
name="Profit"
|
||||
stroke="#ffc658"
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#82ca9d"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -342,59 +342,16 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Inventory KPIs Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Key Inventory Metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[250px]">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[200px] w-full" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={[
|
||||
{
|
||||
name: 'Stock Turn',
|
||||
value: product.stockturn30d || 0,
|
||||
fill: '#8884d8'
|
||||
},
|
||||
{
|
||||
name: 'GMROI',
|
||||
value: product.gmroi30d || 0,
|
||||
fill: '#82ca9d'
|
||||
},
|
||||
{
|
||||
name: 'Sell Through %',
|
||||
value: product.sellThrough30d ? product.sellThrough30d * 100 : 0,
|
||||
fill: '#ffc658'
|
||||
},
|
||||
{
|
||||
name: 'Margin %',
|
||||
value: product.margin30d ? product.margin30d * 100 : 0,
|
||||
fill: '#ff8042'
|
||||
}
|
||||
]}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 50 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" height={60} />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Sell Through %' || name === 'Margin %') {
|
||||
return [`${value.toFixed(1)}%`, name];
|
||||
}
|
||||
return [value.toFixed(2), name];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
<CardHeader><CardTitle className="text-base">Growth Analysis</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Sales Growth (30d vs Prev)" value={formatPercentage(product.salesGrowth30dVsPrev)} />
|
||||
<InfoItem label="Revenue Growth (30d vs Prev)" value={formatPercentage(product.revenueGrowth30dVsPrev)} />
|
||||
<InfoItem label="Sales Growth YoY" value={formatPercentage(product.salesGrowthYoy)} />
|
||||
<InfoItem label="Revenue Growth YoY" value={formatPercentage(product.revenueGrowthYoy)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Inventory Performance (30 Days)</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
@@ -503,16 +460,18 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
) : 'N/A'
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Fulfillment Rate"
|
||||
<InfoItem
|
||||
label="Fulfillment Rate"
|
||||
value={
|
||||
timeSeriesData?.recentPurchases ?
|
||||
timeSeriesData?.recentPurchases ?
|
||||
(() => {
|
||||
const totalOrdered = timeSeriesData.recentPurchases.reduce((acc, po) => acc + po.ordered, 0);
|
||||
const totalReceived = timeSeriesData.recentPurchases.reduce((acc, po) => acc + po.received, 0);
|
||||
return totalOrdered > 0 ? formatPercentage(totalReceived / totalOrdered) : 'N/A';
|
||||
// Only include POs where receiving has started or completed for an accurate rate
|
||||
const receivingPOs = timeSeriesData.recentPurchases.filter(po => ['receiving_started', 'done'].includes(po.status));
|
||||
const totalOrdered = receivingPOs.reduce((acc, po) => acc + po.ordered, 0);
|
||||
const totalReceived = receivingPOs.reduce((acc, po) => acc + po.received, 0);
|
||||
return totalOrdered > 0 ? formatPercentage((totalReceived / totalOrdered) * 100) : 'N/A';
|
||||
})() : 'N/A'
|
||||
}
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Total PO Cost"
|
||||
@@ -544,7 +503,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
timeSeriesData?.recentPurchases ?
|
||||
formatNumber(
|
||||
timeSeriesData.recentPurchases.filter(
|
||||
po => po.status < 50 && po.receivingStatus < 40
|
||||
po => !['done', 'canceled'].includes(po.status)
|
||||
).length
|
||||
) : 'N/A'
|
||||
}
|
||||
@@ -584,6 +543,29 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<InfoItem label="Forecast Lost Revenue" value={formatCurrency(product.forecastLostRevenue)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Demand & Seasonality</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Demand Pattern" value={product.demandPattern || 'N/A'} />
|
||||
<InfoItem label="Sales CV %" value={formatPercentage(product.salesCv30d)} />
|
||||
<InfoItem label="Sales Std Dev" value={formatNumber(product.salesStdDev30d, 2)} />
|
||||
<InfoItem label="Seasonality Index" value={formatNumber(product.seasonalityIndex, 2)} />
|
||||
<InfoItem label="Seasonal Pattern" value={product.seasonalPattern || 'N/A'} />
|
||||
<InfoItem label="Peak Season" value={product.peakSeason || 'N/A'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Lifetime & First Period</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Lifetime Sales" value={formatNumber(product.lifetimeSales)} />
|
||||
<InfoItem label="Lifetime Revenue" value={formatCurrency(product.lifetimeRevenue)} />
|
||||
<InfoItem label="Revenue Quality" value={product.lifetimeRevenueQuality || 'N/A'} />
|
||||
<InfoItem label="First 7d Sales" value={formatNumber(product.first7DaysSales)} />
|
||||
<InfoItem label="First 30d Sales" value={formatNumber(product.first30DaysSales)} />
|
||||
<InfoItem label="First 60d Sales" value={formatNumber(product.first60DaysSales)} />
|
||||
<InfoItem label="First 90d Sales" value={formatNumber(product.first90DaysSales)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
@@ -19,14 +19,21 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ProductFilterOptions, ProductMetricColumnKey } from "@/types/products";
|
||||
import {
|
||||
ProductFilterOptions,
|
||||
ProductMetricColumnKey,
|
||||
ComparisonOperator,
|
||||
ActiveFilterValue,
|
||||
FilterValue,
|
||||
} from "@/types/products";
|
||||
|
||||
// Define operators for different filter types
|
||||
const STRING_OPERATORS: ComparisonOperator[] = ["contains", "equals", "starts_with", "ends_with", "not_contains", "is_empty", "is_not_empty"];
|
||||
const NUMBER_OPERATORS: ComparisonOperator[] = ["=", ">", ">=", "<", "<=", "between", "is_empty", "is_not_empty"];
|
||||
const BOOLEAN_OPERATORS: ComparisonOperator[] = ["is_true", "is_false"];
|
||||
const DATE_OPERATORS: ComparisonOperator[] = ["=", ">", ">=", "<", "<=", "between", "is_empty", "is_not_empty"];
|
||||
const SELECT_OPERATORS: ComparisonOperator[] = ["=", "!=", "in", "not_in", "is_empty", "is_not_empty"];
|
||||
// Select filters use direct value selection (no operator UI), so only equality is applied
|
||||
const SELECT_OPERATORS: ComparisonOperator[] = ["="];
|
||||
|
||||
interface FilterOption {
|
||||
id: ProductMetricColumnKey | 'search';
|
||||
@@ -37,21 +44,6 @@ interface FilterOption {
|
||||
operators?: ComparisonOperator[];
|
||||
}
|
||||
|
||||
type FilterValue = string | number | boolean;
|
||||
|
||||
export type ComparisonOperator =
|
||||
| "=" | "!=" | ">" | ">=" | "<" | "<=" | "between"
|
||||
| "contains" | "equals" | "starts_with" | "ends_with" | "not_contains"
|
||||
| "in" | "not_in" | "is_empty" | "is_not_empty" | "is_true" | "is_false";
|
||||
|
||||
// Support both simple values and complex ones with operators
|
||||
export type ActiveFilterValue = FilterValue | FilterValueWithOperator;
|
||||
|
||||
interface FilterValueWithOperator {
|
||||
value: FilterValue | string[] | number[];
|
||||
operator: ComparisonOperator;
|
||||
}
|
||||
|
||||
interface ActiveFilterDisplay {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -76,8 +68,8 @@ const BASE_FILTER_OPTIONS: FilterOption[] = [
|
||||
{ id: 'status', label: 'Status', type: 'select', group: 'Basic Info', operators: SELECT_OPERATORS, options: [
|
||||
{ value: 'Critical', label: 'Critical' },
|
||||
{ value: 'At Risk', label: 'At Risk' },
|
||||
{ value: 'Reorder', label: 'Reorder' },
|
||||
{ value: 'Overstocked', label: 'Overstocked' },
|
||||
{ value: 'Reorder Soon', label: 'Reorder Soon' },
|
||||
{ value: 'Overstock', label: 'Overstock' },
|
||||
{ value: 'Healthy', label: 'Healthy' },
|
||||
{ value: 'New', label: 'New' },
|
||||
]},
|
||||
@@ -347,7 +339,7 @@ export function ProductFilters({
|
||||
if (!selectedFilter) return;
|
||||
|
||||
let valueToApply: FilterValue | [string, string]; // Use string for dates
|
||||
let requiresOperator = selectedFilter.type === 'number' || selectedFilter.type === 'date';
|
||||
let requiresOperator = selectedFilter.type === 'number' || selectedFilter.type === 'date' || selectedFilter.type === 'text';
|
||||
|
||||
if (selectedOperator === 'between') {
|
||||
if (!inputValue || !inputValue2) return; // Need both values
|
||||
@@ -357,9 +349,9 @@ export function ProductFilters({
|
||||
const numVal = parseFloat(inputValue);
|
||||
if (isNaN(numVal)) return; // Invalid number
|
||||
valueToApply = numVal;
|
||||
} else if (selectedFilter.type === 'boolean' || selectedFilter.type === 'select') {
|
||||
} else if (selectedFilter.type === 'select') {
|
||||
valueToApply = inputValue; // Value set directly via CommandItem select
|
||||
requiresOperator = false; // Usually simple equality for selects/booleans
|
||||
requiresOperator = false; // Usually simple equality for selects
|
||||
} else { // Text or Date (not between)
|
||||
if (!inputValue.trim()) return;
|
||||
valueToApply = inputValue.trim();
|
||||
@@ -404,9 +396,15 @@ export function ProductFilters({
|
||||
if (!option) return String(value); // Fallback
|
||||
|
||||
if (typeof value === 'object' && value !== null && 'operator' in value) {
|
||||
// Boolean operators get friendly display
|
||||
if (value.operator === 'is_true') return `${option.label}: Yes`;
|
||||
if (value.operator === 'is_false') return `${option.label}: No`;
|
||||
if (value.operator === 'is_empty') return `${option.label}: is empty`;
|
||||
if (value.operator === 'is_not_empty') return `${option.label}: is not empty`;
|
||||
|
||||
const opLabel = value.operator === '=' ? '' : `${value.operator} `;
|
||||
if (value.operator === 'between' && Array.isArray(value.value)) {
|
||||
return `${option.label}: ${opLabel} ${value.value[0]} and ${value.value[1]}`;
|
||||
return `${option.label}: ${value.value[0]} to ${value.value[1]}`;
|
||||
}
|
||||
return `${option.label}: ${opLabel}${value.value}`;
|
||||
}
|
||||
@@ -529,8 +527,8 @@ export function ProductFilters({
|
||||
← {selectedFilter.label}
|
||||
</Button>
|
||||
|
||||
{/* Render Operator Select ONLY if type is number or date */}
|
||||
{(selectedFilter.type === 'number' || selectedFilter.type === 'date') && renderOperatorSelect()}
|
||||
{/* Render Operator Select for number, date, and text types */}
|
||||
{(selectedFilter.type === 'number' || selectedFilter.type === 'date' || selectedFilter.type === 'text') && renderOperatorSelect()}
|
||||
|
||||
{/* Render Input based on type */}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -606,8 +604,42 @@ export function ProductFilters({
|
||||
{/* Select and Boolean types are handled via CommandList below */}
|
||||
</div>
|
||||
|
||||
{/* CommandList for Select and Boolean */}
|
||||
{(selectedFilter.type === 'select' || selectedFilter.type === 'boolean') && (
|
||||
{/* Boolean type: show Yes/No buttons */}
|
||||
{selectedFilter.type === 'boolean' && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8"
|
||||
onClick={() => {
|
||||
onFilterChange({
|
||||
...activeFilters,
|
||||
[selectedFilter.id]: { value: 'true', operator: 'is_true' as ComparisonOperator },
|
||||
});
|
||||
handlePopoverClose();
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8"
|
||||
onClick={() => {
|
||||
onFilterChange({
|
||||
...activeFilters,
|
||||
[selectedFilter.id]: { value: 'false', operator: 'is_false' as ComparisonOperator },
|
||||
});
|
||||
handlePopoverClose();
|
||||
}}
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CommandList for Select type */}
|
||||
{selectedFilter.type === 'select' && (
|
||||
<Command className="mt-2 border rounded-md">
|
||||
<CommandInput
|
||||
ref={selectInputRef}
|
||||
|
||||
323
inventory/src/components/products/ProductSummaryCards.tsx
Normal file
323
inventory/src/components/products/ProductSummaryCards.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
DollarSign,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
PackageX,
|
||||
PackageMinus,
|
||||
Truck,
|
||||
ShoppingCart,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SummaryData {
|
||||
total_products: number;
|
||||
total_stock_value: string;
|
||||
total_stock_retail: string;
|
||||
needs_reorder_count: number;
|
||||
total_replenishment_cost: string;
|
||||
total_replenishment_units: number;
|
||||
total_overstock_value: string;
|
||||
total_overstock_units: number;
|
||||
total_on_order_units: number;
|
||||
total_on_order_cost: string;
|
||||
avg_stock_cover_days: string;
|
||||
out_of_stock_count: number;
|
||||
total_lost_revenue: string;
|
||||
total_lost_sales_units: number;
|
||||
critical_count: number;
|
||||
reorder_count: number;
|
||||
at_risk_count: number;
|
||||
overstock_count: number;
|
||||
healthy_count: number;
|
||||
new_count: number;
|
||||
}
|
||||
|
||||
interface ProductSummaryCardsProps {
|
||||
activeView: string;
|
||||
showNonReplenishable: boolean;
|
||||
showInvisible: boolean;
|
||||
}
|
||||
|
||||
function formatCurrency(value: string | number): string {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return '$0';
|
||||
if (num >= 1_000_000) return `$${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `$${(num / 1_000).toFixed(1)}K`;
|
||||
return `$${num.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function formatDays(value: string | number): string {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num) || num === 0) return '0 days';
|
||||
return `${num.toFixed(0)} days`;
|
||||
}
|
||||
|
||||
interface CardConfig {
|
||||
label: string;
|
||||
value: string;
|
||||
subValue?: string;
|
||||
icon: any;
|
||||
iconClassName: string;
|
||||
}
|
||||
|
||||
function getCardsForView(data: SummaryData, activeView: string): CardConfig[] {
|
||||
const allCards: Record<string, CardConfig[]> = {
|
||||
all: [
|
||||
{
|
||||
label: 'Stock Value',
|
||||
value: formatCurrency(data.total_stock_value),
|
||||
subValue: `${formatCurrency(data.total_stock_retail)} retail`,
|
||||
icon: DollarSign,
|
||||
iconClassName: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'Needs Reorder',
|
||||
value: formatNumber(data.needs_reorder_count),
|
||||
subValue: `${formatCurrency(data.total_replenishment_cost)} to replenish`,
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-amber-500',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
{
|
||||
label: 'Overstock Value',
|
||||
value: formatCurrency(data.total_overstock_value),
|
||||
subValue: `${formatNumber(data.total_overstock_units)} excess units`,
|
||||
icon: PackageX,
|
||||
iconClassName: 'text-orange-400',
|
||||
},
|
||||
],
|
||||
critical: [
|
||||
{
|
||||
label: 'Out of Stock',
|
||||
value: formatNumber(data.out_of_stock_count),
|
||||
subValue: `of ${formatNumber(data.total_products)} products`,
|
||||
icon: PackageMinus,
|
||||
iconClassName: 'text-red-500',
|
||||
},
|
||||
{
|
||||
label: 'Replenishment Needed',
|
||||
value: formatNumber(data.total_replenishment_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_replenishment_cost)} cost`,
|
||||
icon: ShoppingCart,
|
||||
iconClassName: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
{
|
||||
label: 'Forecast Lost Sales',
|
||||
value: formatNumber(data.total_lost_sales_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_lost_revenue)} revenue`,
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-red-400',
|
||||
},
|
||||
],
|
||||
reorder: [
|
||||
{
|
||||
label: 'Replenishment Needed',
|
||||
value: formatNumber(data.total_replenishment_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_replenishment_cost)} cost`,
|
||||
icon: ShoppingCart,
|
||||
iconClassName: 'text-amber-500',
|
||||
},
|
||||
{
|
||||
label: 'Also Critical',
|
||||
value: formatNumber(data.critical_count),
|
||||
subValue: 'need ordering too',
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-red-500',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
{
|
||||
label: 'Forecast Lost Sales',
|
||||
value: formatNumber(data.total_lost_sales_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_lost_revenue)} revenue`,
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-red-400',
|
||||
},
|
||||
],
|
||||
healthy: [
|
||||
{
|
||||
label: 'Healthy Products',
|
||||
value: formatNumber(data.healthy_count),
|
||||
subValue: `of ${formatNumber(data.total_products)} total`,
|
||||
icon: Package,
|
||||
iconClassName: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: 'Avg Stock Cover',
|
||||
value: formatDays(data.avg_stock_cover_days),
|
||||
icon: Clock,
|
||||
iconClassName: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: 'Stock Value',
|
||||
value: formatCurrency(data.total_stock_value),
|
||||
subValue: `${formatCurrency(data.total_stock_retail)} retail`,
|
||||
icon: DollarSign,
|
||||
iconClassName: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
],
|
||||
'at-risk': [
|
||||
{
|
||||
label: 'At Risk Products',
|
||||
value: formatNumber(data.at_risk_count),
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-orange-500',
|
||||
},
|
||||
{
|
||||
label: 'Avg Stock Cover',
|
||||
value: formatDays(data.avg_stock_cover_days),
|
||||
icon: Clock,
|
||||
iconClassName: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
label: 'Forecast Lost Sales',
|
||||
value: formatNumber(data.total_lost_sales_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_lost_revenue)} revenue`,
|
||||
icon: AlertTriangle,
|
||||
iconClassName: 'text-red-400',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
],
|
||||
overstocked: [
|
||||
{
|
||||
label: 'Overstocked Products',
|
||||
value: formatNumber(data.overstock_count),
|
||||
icon: PackageX,
|
||||
iconClassName: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
label: 'Excess Units',
|
||||
value: formatNumber(data.total_overstock_units),
|
||||
icon: Package,
|
||||
iconClassName: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
label: 'Overstock Value',
|
||||
value: formatCurrency(data.total_overstock_value),
|
||||
subValue: 'at cost',
|
||||
icon: DollarSign,
|
||||
iconClassName: 'text-orange-500',
|
||||
},
|
||||
{
|
||||
label: 'Avg Stock Cover',
|
||||
value: formatDays(data.avg_stock_cover_days),
|
||||
icon: Clock,
|
||||
iconClassName: 'text-blue-400',
|
||||
},
|
||||
],
|
||||
new: [
|
||||
{
|
||||
label: 'New Products',
|
||||
value: formatNumber(data.new_count),
|
||||
icon: Package,
|
||||
iconClassName: 'text-purple-500',
|
||||
},
|
||||
{
|
||||
label: 'Stock Value',
|
||||
value: formatCurrency(data.total_stock_value),
|
||||
subValue: `${formatCurrency(data.total_stock_retail)} retail`,
|
||||
icon: DollarSign,
|
||||
iconClassName: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'On Order',
|
||||
value: formatNumber(data.total_on_order_units) + ' units',
|
||||
subValue: `${formatCurrency(data.total_on_order_cost)} cost`,
|
||||
icon: Truck,
|
||||
iconClassName: 'text-indigo-500',
|
||||
},
|
||||
{
|
||||
label: 'Out of Stock',
|
||||
value: formatNumber(data.out_of_stock_count),
|
||||
subValue: `of ${formatNumber(data.total_products)} products`,
|
||||
icon: PackageMinus,
|
||||
iconClassName: 'text-red-400',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return allCards[activeView] || allCards.all;
|
||||
}
|
||||
|
||||
export function ProductSummaryCards({ activeView, showNonReplenishable, showInvisible }: ProductSummaryCardsProps) {
|
||||
const { data, isLoading } = useQuery<SummaryData>({
|
||||
queryKey: ['productsSummary', showNonReplenishable, showInvisible],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (showNonReplenishable) params.append('showNonReplenishable', 'true');
|
||||
if (showInvisible) params.append('showInvisible', 'true');
|
||||
const response = await fetch(`/api/metrics/summary?${params.toString()}`, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Failed to fetch summary');
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 60 * 1000, // Cache for 1 minute
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card p-4 animate-pulse">
|
||||
<div className="h-4 w-24 bg-muted rounded mb-2" />
|
||||
<div className="h-7 w-20 bg-muted rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = getCardsForView(data, activeView);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{cards.map((card) => (
|
||||
<div key={card.label} className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{card.label}</p>
|
||||
<card.icon className={`h-4 w-4 ${card.iconClassName}`} />
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{card.value}</p>
|
||||
{card.subValue && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{card.subValue}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,19 +25,10 @@ import {
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ProductMetric, ProductMetricColumnKey, ProductStatus } from "@/types/products";
|
||||
import { ProductMetric, ProductMetricColumnKey } from "@/types/products";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getStatusBadge } from "@/utils/productUtils";
|
||||
|
||||
// Column definition
|
||||
interface ColumnDef {
|
||||
key: ProductMetricColumnKey;
|
||||
label: string;
|
||||
group: string;
|
||||
noLabel?: boolean;
|
||||
width?: string;
|
||||
format?: (value: any, product?: ProductMetric) => React.ReactNode;
|
||||
}
|
||||
import { StatusBadge } from "@/components/products/StatusBadge";
|
||||
import { ColumnDef } from "@/components/products/columnDefinitions";
|
||||
|
||||
interface ProductTableProps {
|
||||
products: ProductMetric[];
|
||||
@@ -145,10 +136,6 @@ export function ProductTable({
|
||||
return columnOrder.filter(col => visibleColumns.has(col));
|
||||
}, [columnOrder, visibleColumns]);
|
||||
|
||||
const handleDragStart = () => {
|
||||
// No need to set activeId as it's not used in the new implementation
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
@@ -163,18 +150,23 @@ export function ProductTable({
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-compute a Map for O(1) column def lookups instead of O(n) find() per cell
|
||||
const columnDefMap = React.useMemo(() => {
|
||||
return new Map(columnDefs.map(col => [col.key, col]));
|
||||
}, [columnDefs]);
|
||||
|
||||
const formatColumnValue = (product: ProductMetric, columnKey: ProductMetricColumnKey): React.ReactNode => {
|
||||
const value = product[columnKey as keyof ProductMetric];
|
||||
const columnDef = columnDefs.find(col => col.key === columnKey);
|
||||
|
||||
const columnDef = columnDefMap.get(columnKey);
|
||||
|
||||
// Use the format function from column definition if available
|
||||
if (columnDef?.format) {
|
||||
return columnDef.format(value, product);
|
||||
}
|
||||
|
||||
// Special handling for status
|
||||
|
||||
// Special handling for status - proper React component instead of dangerouslySetInnerHTML
|
||||
if (columnKey === 'status') {
|
||||
return <div dangerouslySetInnerHTML={{ __html: getStatusBadge(value as ProductStatus) }} />;
|
||||
return <StatusBadge status={value as string} />;
|
||||
}
|
||||
|
||||
// Special handling for boolean values
|
||||
@@ -204,9 +196,7 @@ export function ProductTable({
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => {}}
|
||||
>
|
||||
<div className="border rounded-md relative">
|
||||
{isLoading && (
|
||||
@@ -226,7 +216,7 @@ export function ProductTable({
|
||||
<SortableHeader
|
||||
key={columnKey}
|
||||
column={columnKey}
|
||||
columnDef={columnDefs.find(def => def.key === columnKey)}
|
||||
columnDef={columnDefMap.get(columnKey)}
|
||||
onSort={onSort}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
@@ -254,7 +244,7 @@ export function ProductTable({
|
||||
data-state={isLoading ? 'loading' : undefined}
|
||||
>
|
||||
{orderedVisibleColumns.map((columnKey) => {
|
||||
const colDef = columnDefs.find(c => c.key === columnKey);
|
||||
const colDef = columnDefMap.get(columnKey);
|
||||
return (
|
||||
<TableCell
|
||||
key={`${product.pid}-${columnKey}`}
|
||||
@@ -289,7 +279,7 @@ export function ProductTable({
|
||||
{isLoading && products.length === 0 && Array.from({length: 10}).map((_, i) => (
|
||||
<TableRow key={`skel-${i}`}>
|
||||
{orderedVisibleColumns.map(key => {
|
||||
const colDef = columnDefs.find(c => c.key === key);
|
||||
const colDef = columnDefMap.get(key);
|
||||
return (
|
||||
<TableCell
|
||||
key={`skel-${i}-${key}`}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
PackageSearch,
|
||||
CheckCircle2,
|
||||
PackageSearch,
|
||||
Sparkles,
|
||||
Timer,
|
||||
PackagePlus,
|
||||
@@ -61,25 +61,57 @@ export const PRODUCT_VIEWS: ProductView[] = [
|
||||
}
|
||||
]
|
||||
|
||||
interface ViewCounts {
|
||||
critical_count: number;
|
||||
reorder_count: number;
|
||||
at_risk_count: number;
|
||||
overstock_count: number;
|
||||
healthy_count: number;
|
||||
new_count: number;
|
||||
total_products: number;
|
||||
}
|
||||
|
||||
interface ProductViewsProps {
|
||||
activeView: string
|
||||
onViewChange: (view: string) => void
|
||||
viewCounts?: ViewCounts | null
|
||||
}
|
||||
|
||||
export function ProductViews({ activeView, onViewChange }: ProductViewsProps) {
|
||||
function getCountForView(viewId: string, counts: ViewCounts): number | null {
|
||||
switch (viewId) {
|
||||
case 'all': return counts.total_products;
|
||||
case 'critical': return counts.critical_count;
|
||||
case 'reorder': return counts.reorder_count;
|
||||
case 'healthy': return counts.healthy_count;
|
||||
case 'at-risk': return counts.at_risk_count;
|
||||
case 'overstocked': return counts.overstock_count;
|
||||
case 'new': return counts.new_count;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProductViews({ activeView, onViewChange, viewCounts }: ProductViewsProps) {
|
||||
return (
|
||||
<Tabs value={activeView} onValueChange={onViewChange} className="w-full">
|
||||
<TabsList className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground w-fit">
|
||||
{PRODUCT_VIEWS.map((view) => (
|
||||
<TabsTrigger
|
||||
key={view.id}
|
||||
value={view.id}
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow"
|
||||
>
|
||||
<view.icon className={`h-4 w-4 ${view.iconClassName} mr-2`} />
|
||||
{view.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{PRODUCT_VIEWS.map((view) => {
|
||||
const count = viewCounts ? getCountForView(view.id, viewCounts) : null;
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={view.id}
|
||||
value={view.id}
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow"
|
||||
>
|
||||
<view.icon className={`h-4 w-4 ${view.iconClassName} mr-2`} />
|
||||
{view.label}
|
||||
{count !== null && (
|
||||
<span className="ml-1.5 text-[10px] rounded-full bg-background/50 px-1.5 py-0 text-muted-foreground tabular-nums">
|
||||
{count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ProductFilterOptions, ProductMetric, ProductMetricColumnKey } from "@/types/products";
|
||||
import { ProductTable } from "./ProductTable";
|
||||
import { ProductFilters } from "./ProductFilters";
|
||||
import { ProductDetail } from "./ProductDetail";
|
||||
import config from "@/config";
|
||||
import { getProductStatus } from "@/utils/productUtils";
|
||||
|
||||
export function Products() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selectedProductId, setSelectedProductId] = React.useState<number | null>(null);
|
||||
|
||||
// Get current filter values from URL params
|
||||
const currentPage = Number(searchParams.get("page") || "1");
|
||||
const pageSize = Number(searchParams.get("pageSize") || "25");
|
||||
const sortBy = searchParams.get("sortBy") || "title";
|
||||
const sortDirection = searchParams.get("sortDirection") || "asc";
|
||||
const filterType = searchParams.get("filterType") || "";
|
||||
const filterValue = searchParams.get("filterValue") || "";
|
||||
const searchQuery = searchParams.get("search") || "";
|
||||
const statusFilter = searchParams.get("status") || "";
|
||||
|
||||
// Fetch filter options
|
||||
const {
|
||||
data: filterOptions,
|
||||
isLoading: isLoadingOptions
|
||||
} = useQuery<ProductFilterOptions>({
|
||||
queryKey: ["productFilterOptions"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/metrics/filter-options`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { vendors: [], brands: [], abcClasses: [] };
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
initialData: { vendors: [], brands: [], abcClasses: [] }, // Provide initial data to prevent undefined
|
||||
});
|
||||
|
||||
// Fetch products with metrics data
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error
|
||||
} = useQuery<{ products: ProductMetric[], total: number }>({
|
||||
queryKey: ["products", currentPage, pageSize, sortBy, sortDirection, filterType, filterValue, searchQuery, statusFilter],
|
||||
queryFn: async () => {
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams();
|
||||
params.append("page", currentPage.toString());
|
||||
params.append("limit", pageSize.toString());
|
||||
|
||||
if (sortBy) params.append("sortBy", sortBy);
|
||||
if (sortDirection) params.append("sortDirection", sortDirection);
|
||||
if (filterType && filterValue) {
|
||||
params.append("filterType", filterType);
|
||||
params.append("filterValue", filterValue);
|
||||
}
|
||||
if (searchQuery) params.append("search", searchQuery);
|
||||
if (statusFilter) params.append("status", statusFilter);
|
||||
|
||||
const response = await fetch(`${config.apiUrl}/metrics?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Failed to fetch products (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Calculate status for each product
|
||||
const productsWithStatus = data.products.map((product: ProductMetric) => ({
|
||||
...product,
|
||||
status: getProductStatus(product)
|
||||
}));
|
||||
|
||||
return {
|
||||
products: productsWithStatus,
|
||||
total: data.total
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const handleSortChange = (field: string, direction: "asc" | "desc") => {
|
||||
searchParams.set("sortBy", field);
|
||||
searchParams.set("sortDirection", direction);
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handleSort = (column: ProductMetricColumnKey) => {
|
||||
// Toggle sort direction if same column, otherwise default to asc
|
||||
const newDirection = sortBy === column && sortDirection === "asc" ? "desc" : "asc";
|
||||
handleSortChange(column, newDirection as "asc" | "desc");
|
||||
};
|
||||
|
||||
const handleViewProduct = (id: number) => {
|
||||
setSelectedProductId(id);
|
||||
};
|
||||
|
||||
const handleCloseProductDetail = () => {
|
||||
setSelectedProductId(null);
|
||||
};
|
||||
|
||||
// Create a wrapper function to handle all filter changes
|
||||
const handleFiltersChange = (filters: Record<string, any>) => {
|
||||
// Reset to first page when applying filters
|
||||
searchParams.set("page", "1");
|
||||
|
||||
// Update searchParams with all filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
searchParams.set(key, String(value));
|
||||
} else {
|
||||
searchParams.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
// Clear all filters
|
||||
const handleClearFilters = () => {
|
||||
// Keep only pagination and sorting params
|
||||
const newParams = new URLSearchParams();
|
||||
newParams.set("page", "1");
|
||||
newParams.set("pageSize", pageSize.toString());
|
||||
newParams.set("sortBy", sortBy);
|
||||
newParams.set("sortDirection", sortDirection);
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
// Current active filters
|
||||
const activeFilters = React.useMemo(() => {
|
||||
const filters: Record<string, any> = {};
|
||||
|
||||
if (filterType && filterValue) {
|
||||
filters[filterType] = filterValue;
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
filters.search = searchQuery;
|
||||
}
|
||||
|
||||
if (statusFilter) {
|
||||
filters.status = statusFilter;
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [filterType, filterValue, searchQuery, statusFilter]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Products</h2>
|
||||
</div>
|
||||
|
||||
<ProductFilters
|
||||
filterOptions={filterOptions || { vendors: [], brands: [], abcClasses: [] }}
|
||||
isLoadingOptions={isLoadingOptions}
|
||||
onFilterChange={handleFiltersChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
activeFilters={activeFilters}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center min-h-[300px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span>Loading products...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-destructive/10 p-4 rounded-lg text-center text-destructive border border-destructive">
|
||||
Error loading products: {(error as Error).message}
|
||||
</div>
|
||||
) : (
|
||||
<ProductTable
|
||||
products={data?.products || []}
|
||||
onViewProduct={handleViewProduct}
|
||||
isLoading={isLoading}
|
||||
onSort={handleSort}
|
||||
sortColumn={sortBy as ProductMetricColumnKey}
|
||||
sortDirection={sortDirection as "asc" | "desc"}
|
||||
columnDefs={[
|
||||
{ key: 'title', label: 'Name', group: 'Product' },
|
||||
{ key: 'brand', label: 'Brand', group: 'Product' },
|
||||
{ key: 'sku', label: 'SKU', group: 'Product' },
|
||||
{ key: 'currentStock', label: 'Stock', group: 'Inventory' },
|
||||
{ key: 'currentPrice', label: 'Price', group: 'Pricing' },
|
||||
{ key: 'status', label: 'Status', group: 'Product' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProductDetail
|
||||
productId={selectedProductId}
|
||||
onClose={handleCloseProductDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
inventory/src/components/products/StatusBadge.tsx
Normal file
33
inventory/src/components/products/StatusBadge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
'Critical': 'bg-red-600 text-white border-transparent',
|
||||
'Reorder Soon': 'bg-yellow-500 text-black border-secondary',
|
||||
'Healthy': 'bg-green-600 text-white border-transparent',
|
||||
'Overstock': 'bg-blue-600 text-white border-secondary',
|
||||
'At Risk': 'border-orange-500 text-orange-600',
|
||||
'New': 'bg-purple-600 text-white border-transparent',
|
||||
'Unknown': 'bg-muted text-muted-foreground border-transparent',
|
||||
};
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string | null | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
||||
const displayStatus = status || 'Unknown';
|
||||
const styles = STATUS_STYLES[displayStatus] || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
styles,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{displayStatus}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
288
inventory/src/components/products/columnDefinitions.ts
Normal file
288
inventory/src/components/products/columnDefinitions.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createElement } from 'react';
|
||||
import type { ProductMetric, ProductMetricColumnKey } from "@/types/products";
|
||||
|
||||
export interface ColumnDef {
|
||||
key: ProductMetricColumnKey;
|
||||
label: string;
|
||||
group: string;
|
||||
noLabel?: boolean;
|
||||
width?: string;
|
||||
format?: (value: any, product?: ProductMetric) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an inline trend indicator element showing value + colored arrow.
|
||||
* Used in sales/growth columns to provide at-a-glance trend information.
|
||||
*/
|
||||
function trendIndicator(value: any, _product?: ProductMetric): ReactNode {
|
||||
if (value === null || value === undefined) return '-';
|
||||
const num = typeof value === 'number' ? value : parseFloat(value);
|
||||
if (isNaN(num)) return '-';
|
||||
if (num === 0) return createElement('span', { className: 'text-muted-foreground' }, '0%');
|
||||
const isPositive = num > 0;
|
||||
const color = isPositive ? 'text-green-600' : 'text-red-600';
|
||||
const arrow = isPositive ? '\u2191' : '\u2193'; // ↑ or ↓
|
||||
return createElement('span', { className: `font-medium ${color}` }, `${arrow} ${Math.abs(num).toFixed(1)}%`);
|
||||
}
|
||||
|
||||
// Define available columns with their groups
|
||||
export const AVAILABLE_COLUMNS: ColumnDef[] = [
|
||||
// Identity & Basic Info
|
||||
{ key: 'imageUrl', label: 'Image', group: 'Product Identity', noLabel: true, width: 'w-[60px]' },
|
||||
{ key: 'title', label: 'Name', group: 'Product Identity'},
|
||||
{ key: 'sku', label: 'Item Number', group: 'Product Identity' },
|
||||
{ key: 'barcode', label: 'UPC', group: 'Product Identity' },
|
||||
{ key: 'brand', label: 'Company', group: 'Product Identity' },
|
||||
{ key: 'line', label: 'Line', group: 'Product Identity' },
|
||||
{ key: 'subline', label: 'Subline', group: 'Product Identity' },
|
||||
{ key: 'artist', label: 'Artist', group: 'Product Identity' },
|
||||
{ key: 'isVisible', label: 'Visible', group: 'Product Identity' },
|
||||
{ key: 'isReplenishable', label: 'Replenishable', group: 'Product Identity' },
|
||||
{ key: 'abcClass', label: 'ABC Class', group: 'Product Identity' },
|
||||
{ key: 'status', label: 'Status', group: 'Product Identity' },
|
||||
{ key: 'dateCreated', label: 'Created', group: 'Dates' },
|
||||
|
||||
// Supply Chain
|
||||
{ key: 'vendor', label: 'Supplier', group: 'Supply Chain' },
|
||||
{ key: 'vendorReference', label: 'Supplier #', group: 'Supply Chain' },
|
||||
{ key: 'notionsReference', label: 'Notions #', group: 'Supply Chain' },
|
||||
{ key: 'harmonizedTariffCode', label: 'Tariff Code', group: 'Supply Chain' },
|
||||
{ key: 'countryOfOrigin', label: 'Country', group: 'Supply Chain' },
|
||||
{ key: 'location', label: 'Location', group: 'Supply Chain' },
|
||||
{ key: 'moq', label: 'MOQ', group: 'Supply Chain', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
|
||||
// Physical Properties
|
||||
{ key: 'weight', label: 'Weight', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'length', label: 'Length', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'width', label: 'Width', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'height', label: 'Height', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'dimensions', label: 'Dimensions', group: 'Physical', format: (_, product) => {
|
||||
const length = product?.length;
|
||||
const width = product?.width;
|
||||
const height = product?.height;
|
||||
if (length && width && height) {
|
||||
return `${length}\u00d7${width}\u00d7${height}`;
|
||||
}
|
||||
return '-';
|
||||
}},
|
||||
|
||||
// Customer Engagement
|
||||
{ key: 'rating', label: 'Rating', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'reviews', label: 'Reviews', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'baskets', label: 'Basket Adds', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'notifies', label: 'Stock Alerts', group: 'Customer', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
|
||||
// Inventory & Stock
|
||||
{ key: 'currentStock', label: 'Current Stock', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'preorderCount', label: 'Preorders', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'notionsInvCount', label: 'Notions Inv.', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'configSafetyStock', label: 'Safety Stock', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'replenishmentUnits', label: 'Replenish Units', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'onOrderQty', label: 'On Order', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'earliestExpectedDate', label: 'Expected Date', group: 'Inventory' },
|
||||
{ key: 'isOldStock', label: 'Old Stock', group: 'Inventory' },
|
||||
{ key: 'overstockedUnits', label: 'Overstock Qty', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'stockoutDays30d', label: 'Stockout Days (30d)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'stockoutRate30d', label: 'Stockout Rate %', group: 'Inventory', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'avgStockUnits30d', label: 'Avg Stock Units (30d)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'receivedQty30d', label: 'Received Qty (30d)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'poCoverInDays', label: 'PO Cover (Days)', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
|
||||
// Pricing & Costs
|
||||
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentStockRetail', label: 'Stock Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'currentStockGross', label: 'Stock Gross', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'onOrderCost', label: 'On Order Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'onOrderRetail', label: 'On Order Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'overstockedCost', label: 'Overstock Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'overstockedRetail', label: 'Overstock Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'avgStockCost30d', label: 'Avg Stock Cost (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'avgStockRetail30d', label: 'Avg Stock Retail (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'avgStockGross30d', label: 'Avg Stock Gross (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'receivedCost30d', label: 'Received Cost (30d)', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'replenishmentCost', label: 'Replenishment Cost', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'replenishmentRetail', label: 'Replenishment Retail', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'replenishmentProfit', label: 'Replenishment Profit', group: 'Valuation', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
|
||||
// Dates & Timing
|
||||
{ key: 'dateFirstReceived', label: 'First Received', group: 'Dates' },
|
||||
{ key: 'dateLastReceived', label: 'Last Received', group: 'Dates' },
|
||||
{ key: 'dateFirstSold', label: 'First Sold', group: 'Dates' },
|
||||
{ key: 'dateLastSold', label: 'Last Sold', group: 'Dates' },
|
||||
{ key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'replenishDate', label: 'Replenish Date', group: 'Dates' },
|
||||
{ key: 'planningPeriodDays', label: 'Planning Period (Days)', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
|
||||
// Sales & Revenue
|
||||
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v === 0 ? '0' : 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 === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v, product) => {
|
||||
if (v === null || v === undefined) return '-';
|
||||
const display = v === 0 ? '0' : v.toString();
|
||||
const growth = product?.salesGrowth30dVsPrev;
|
||||
if (growth === null || growth === undefined || growth === 0) return display;
|
||||
const isUp = growth > 0;
|
||||
const arrow = isUp ? '\u2191' : '\u2193';
|
||||
const color = isUp ? 'text-green-600' : 'text-red-600';
|
||||
return createElement('span', { className: 'inline-flex items-center gap-1.5' },
|
||||
createElement('span', null, display),
|
||||
createElement('span', { className: `text-[10px] ${color}` }, `${arrow}${Math.abs(growth).toFixed(0)}%`),
|
||||
);
|
||||
}},
|
||||
{ key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v, product) => {
|
||||
if (v === null || v === undefined) return '-';
|
||||
const display = v === 0 ? '0' : v.toFixed(2);
|
||||
const growth = product?.revenueGrowth30dVsPrev;
|
||||
if (growth === null || growth === undefined || growth === 0) return display;
|
||||
const isUp = growth > 0;
|
||||
const arrow = isUp ? '\u2191' : '\u2193';
|
||||
const color = isUp ? 'text-green-600' : 'text-red-600';
|
||||
return createElement('span', { className: 'inline-flex items-center gap-1.5' },
|
||||
createElement('span', null, display),
|
||||
createElement('span', { className: `text-[10px] ${color}` }, `${arrow}${Math.abs(growth).toFixed(0)}%`),
|
||||
);
|
||||
}},
|
||||
{ key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'avgSalesPerDay30d', label: 'Avg Sales/Day (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'avgSalesPerMonth30d', label: 'Avg Sales/Month (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'asp30d', label: 'ASP (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'acp30d', label: 'ACP (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'avgRos30d', label: 'Avg ROS (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'returnsUnits30d', label: 'Returns Units (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'returnsRevenue30d', label: 'Returns Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'discounts30d', label: 'Discounts (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'grossRevenue30d', label: 'Gross Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'grossRegularRevenue30d', label: 'Gross Regular Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'lifetimeSales', label: 'Lifetime Sales', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'lifetimeRevenue', label: 'Lifetime Revenue', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
|
||||
// Financial Performance
|
||||
{ key: 'cogs30d', label: 'COGS (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'profit30d', label: 'Profit (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'markup30d', label: 'Markup %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'returnRate30d', label: 'Return Rate %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'discountRate30d', label: 'Discount Rate %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'markdown30d', label: 'Markdown (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'markdownRate30d', label: 'Markdown Rate %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
|
||||
// Forecasting
|
||||
{ key: 'leadTimeForecastUnits', label: 'Lead Time Forecast Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'daysOfStockForecastUnits', label: 'Days of Stock Forecast Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'planningPeriodForecastUnits', label: 'Planning Period Forecast Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'leadTimeClosingStock', label: 'Lead Time Closing Stock', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'daysOfStockClosingStock', label: 'Days of Stock Closing Stock', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'replenishmentNeededRaw', label: 'Replenishment Needed Raw', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'forecastLostSalesUnits', label: 'Forecast Lost Sales Units', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
|
||||
{ key: 'forecastLostRevenue', label: 'Forecast Lost Revenue', group: 'Forecasting', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
|
||||
// First Period Performance
|
||||
{ key: 'first7DaysSales', label: 'First 7 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'first7DaysRevenue', label: 'First 7 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'first30DaysSales', label: 'First 30 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'first30DaysRevenue', label: 'First 30 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'first60DaysSales', label: 'First 60 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'first60DaysRevenue', label: 'First 60 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'first90DaysSales', label: 'First 90 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'first90DaysRevenue', label: 'First 90 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
|
||||
// Growth Metrics — uses trendIndicator for colored arrows
|
||||
{ key: 'salesGrowth30dVsPrev', label: 'Sales Growth (30d)', group: 'Growth Analysis', format: trendIndicator },
|
||||
{ key: 'revenueGrowth30dVsPrev', label: 'Rev Growth (30d)', group: 'Growth Analysis', format: trendIndicator },
|
||||
{ key: 'salesGrowthYoy', label: 'Sales Growth YoY', group: 'Growth Analysis', format: trendIndicator },
|
||||
{ key: 'revenueGrowthYoy', label: 'Rev Growth YoY', group: 'Growth Analysis', format: trendIndicator },
|
||||
|
||||
// Demand Variability Metrics
|
||||
{ key: 'salesVariance30d', label: 'Sales Variance (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'salesStdDev30d', label: 'Sales Std Dev (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'salesCv30d', label: 'Sales CV % (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'demandPattern', label: 'Demand Pattern', group: 'Demand Variability' },
|
||||
|
||||
// Service Level Metrics
|
||||
{ key: 'fillRate30d', label: 'Fill Rate % (30d)', group: 'Service Level', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'stockoutIncidents30d', label: 'Stockout Incidents (30d)', group: 'Service Level', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
{ key: 'serviceLevel30d', label: 'Service Level % (30d)', group: 'Service Level', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
|
||||
{ key: 'lostSalesIncidents30d', label: 'Lost Sales Incidents (30d)', group: 'Service Level', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
|
||||
|
||||
// Seasonality Metrics
|
||||
{ key: 'seasonalityIndex', label: 'Seasonality Index', group: 'Seasonality', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
|
||||
{ key: 'seasonalPattern', label: 'Seasonal Pattern', group: 'Seasonality' },
|
||||
{ key: 'peakSeason', label: 'Peak Season', group: 'Seasonality' },
|
||||
|
||||
// Quality Indicators
|
||||
{ key: 'lifetimeRevenueQuality', label: 'Lifetime Revenue Quality', group: 'Data Quality' },
|
||||
];
|
||||
|
||||
// Define default columns for each view
|
||||
export const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||
// General overview: identification + headline KPIs + trend
|
||||
all: [
|
||||
'imageUrl', 'title', 'sku', 'brand', 'status', 'abcClass', 'currentStock', 'currentPrice',
|
||||
'salesVelocityDaily', 'sales30d', 'revenue30d', 'profit30d', 'margin30d', 'stockCoverInDays',
|
||||
'salesGrowth30dVsPrev'
|
||||
],
|
||||
// Urgency-focused: what's out, how fast, what's coming, cost of inaction
|
||||
critical: [
|
||||
'status', 'imageUrl', 'title', 'vendor', 'currentStock', 'configSafetyStock', 'sellsOutInDays',
|
||||
'salesVelocityDaily', 'sales30d', 'replenishmentUnits', 'onOrderQty', 'earliestExpectedDate',
|
||||
'avgLeadTimeDays', 'stockoutDays30d', 'forecastLostRevenue'
|
||||
],
|
||||
// Purchase planning: what to order, how much, cost, supplier, timeline
|
||||
reorder: [
|
||||
'status', 'imageUrl', 'title', 'vendor', 'currentStock', 'sellsOutInDays', 'stockCoverInDays',
|
||||
'salesVelocityDaily', 'sales30d', 'replenishmentUnits', 'replenishmentCost', 'replenishDate',
|
||||
'currentCostPrice', 'moq', 'avgLeadTimeDays', 'onOrderQty'
|
||||
],
|
||||
// Excess inventory: how much, what it costs, is it moving, is it old
|
||||
overstocked: [
|
||||
'status', 'imageUrl', 'title', 'currentStock', 'overstockedUnits', 'overstockedCost',
|
||||
'currentStockCost', 'salesVelocityDaily', 'sales30d', 'stockCoverInDays', 'stockturn30d',
|
||||
'dateLastSold', 'isOldStock', 'salesGrowth30dVsPrev', 'margin30d'
|
||||
],
|
||||
// Early warnings: stock trajectory, is help on the way, what to do
|
||||
'at-risk': [
|
||||
'status', 'imageUrl', 'title', 'vendor', 'currentStock', 'stockCoverInDays', 'sellsOutInDays',
|
||||
'salesVelocityDaily', 'sales30d', 'salesGrowth30dVsPrev', 'onOrderQty', 'earliestExpectedDate',
|
||||
'replenishmentUnits', 'avgLeadTimeDays', 'fillRate30d'
|
||||
],
|
||||
// New product monitoring: early performance, benchmarks, engagement
|
||||
new: [
|
||||
'status', 'imageUrl', 'title', 'brand', 'currentStock', 'currentPrice',
|
||||
'salesVelocityDaily', 'sales30d', 'revenue30d', 'margin30d',
|
||||
'dateFirstReceived', 'ageDays', 'first7DaysSales', 'first30DaysSales', 'reviews'
|
||||
],
|
||||
// Performance monitoring: profitability, efficiency, trends
|
||||
healthy: [
|
||||
'status', 'imageUrl', 'title', 'abcClass', 'currentStock', 'stockCoverInDays',
|
||||
'salesVelocityDaily', 'sales30d', 'revenue30d', 'profit30d', 'margin30d',
|
||||
'gmroi30d', 'stockturn30d', 'sellThrough30d', 'salesGrowth30dVsPrev'
|
||||
],
|
||||
};
|
||||
|
||||
// Pre-computed column lookup map for O(1) access by key
|
||||
export const COLUMN_DEF_MAP = new Map<ProductMetricColumnKey, ColumnDef>(
|
||||
AVAILABLE_COLUMNS.map(col => [col.key, col])
|
||||
);
|
||||
|
||||
// Pre-computed columns grouped by their group property
|
||||
export const COLUMNS_BY_GROUP = AVAILABLE_COLUMNS.reduce((acc, col) => {
|
||||
if (!acc[col.group]) acc[col.group] = [];
|
||||
acc[col.group].push(col);
|
||||
return acc;
|
||||
}, {} as Record<string, ColumnDef[]>);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,7 @@ export interface ProductMetric {
|
||||
imageUrl: string | null;
|
||||
isVisible: boolean;
|
||||
isReplenishable: boolean;
|
||||
lastCalculated: string | null;
|
||||
|
||||
// Additional Product Fields
|
||||
barcode: string | null;
|
||||
@@ -334,11 +335,6 @@ export type ProductMetricColumnKey =
|
||||
| 'configDaysOfStock'
|
||||
| 'poCoverInDays'
|
||||
| 'toOrderUnits'
|
||||
| 'costPrice'
|
||||
| 'valueAtCost'
|
||||
| 'profit'
|
||||
| 'margin'
|
||||
| 'targetPrice'
|
||||
| 'replenishmentCost'
|
||||
| 'replenishmentRetail'
|
||||
| 'replenishmentProfit'
|
||||
@@ -347,20 +343,13 @@ export type ProductMetricColumnKey =
|
||||
| 'sales14d'
|
||||
| 'revenue14d'
|
||||
| 'sales30d'
|
||||
| 'units30d'
|
||||
| 'revenue30d'
|
||||
| 'sales365d'
|
||||
| 'revenue365d'
|
||||
| 'avgSalePrice30d'
|
||||
| 'avgDailySales30d'
|
||||
| 'avgDailyRevenue30d'
|
||||
| 'stockturnRate30d'
|
||||
| 'margin30d'
|
||||
| 'markup30d'
|
||||
| 'cogs30d'
|
||||
| 'profit30d'
|
||||
| 'roas30d'
|
||||
| 'adSpend30d'
|
||||
| 'gmroi30d'
|
||||
| 'stockturn30d'
|
||||
| 'sellThrough30d'
|
||||
@@ -421,100 +410,14 @@ export type ProductMetricColumnKey =
|
||||
| 'peakSeason'
|
||||
| 'imageUrl';
|
||||
|
||||
// Mapping frontend keys to backend query param keys
|
||||
export const FRONTEND_TO_BACKEND_KEY_MAP: Record<string, string> = {
|
||||
pid: 'pid',
|
||||
sku: 'sku',
|
||||
title: 'title',
|
||||
brand: 'brand',
|
||||
vendor: 'vendor',
|
||||
imageUrl: 'imageUrl',
|
||||
isVisible: 'isVisible',
|
||||
isReplenishable: 'isReplenishable',
|
||||
currentPrice: 'currentPrice',
|
||||
currentRegularPrice: 'currentRegularPrice',
|
||||
currentCostPrice: 'currentCostPrice',
|
||||
currentLandingCostPrice: 'currentLandingCostPrice',
|
||||
currentStock: 'currentStock',
|
||||
currentStockCost: 'currentStockCost',
|
||||
currentStockRetail: 'currentStockRetail',
|
||||
currentStockGross: 'currentStockGross',
|
||||
onOrderQty: 'onOrderQty',
|
||||
onOrderCost: 'onOrderCost',
|
||||
onOrderRetail: 'onOrderRetail',
|
||||
earliestExpectedDate: 'earliestExpectedDate',
|
||||
dateCreated: 'dateCreated',
|
||||
dateFirstReceived: 'dateFirstReceived',
|
||||
dateLastReceived: 'dateLastReceived',
|
||||
dateFirstSold: 'dateFirstSold',
|
||||
dateLastSold: 'dateLastSold',
|
||||
ageDays: 'ageDays',
|
||||
sales7d: 'sales7d',
|
||||
revenue7d: 'revenue7d',
|
||||
sales14d: 'sales14d',
|
||||
revenue14d: 'revenue14d',
|
||||
sales30d: 'sales30d',
|
||||
revenue30d: 'revenue30d',
|
||||
cogs30d: 'cogs30d',
|
||||
profit30d: 'profit30d',
|
||||
stockoutDays30d: 'stockoutDays30d',
|
||||
sales365d: 'sales365d',
|
||||
revenue365d: 'revenue365d',
|
||||
avgStockUnits30d: 'avgStockUnits30d',
|
||||
avgStockCost30d: 'avgStockCost30d',
|
||||
receivedQty30d: 'receivedQty30d',
|
||||
receivedCost30d: 'receivedCost30d',
|
||||
asp30d: 'asp30d',
|
||||
acp30d: 'acp30d',
|
||||
margin30d: 'margin30d',
|
||||
gmroi30d: 'gmroi30d',
|
||||
stockturn30d: 'stockturn30d',
|
||||
sellThrough30d: 'sellThrough30d',
|
||||
avgLeadTimeDays: 'avgLeadTimeDays',
|
||||
abcClass: 'abcClass',
|
||||
salesVelocityDaily: 'salesVelocityDaily',
|
||||
configLeadTime: 'configLeadTime',
|
||||
configDaysOfStock: 'configDaysOfStock',
|
||||
stockCoverInDays: 'stockCoverInDays',
|
||||
sellsOutInDays: 'sellsOutInDays',
|
||||
replenishDate: 'replenishDate',
|
||||
overstockedUnits: 'overstockedUnits',
|
||||
overstockedCost: 'overstockedCost',
|
||||
isOldStock: 'isOldStock',
|
||||
yesterdaySales: 'yesterdaySales',
|
||||
status: 'status', // Frontend-only field
|
||||
// New metrics from P3-P5 implementation
|
||||
salesGrowth30dVsPrev: 'salesGrowth30dVsPrev',
|
||||
revenueGrowth30dVsPrev: 'revenueGrowth30dVsPrev',
|
||||
salesGrowthYoy: 'salesGrowthYoy',
|
||||
revenueGrowthYoy: 'revenueGrowthYoy',
|
||||
salesVariance30d: 'salesVariance30d',
|
||||
salesStdDev30d: 'salesStdDev30d',
|
||||
salesCv30d: 'salesCv30d',
|
||||
demandPattern: 'demandPattern',
|
||||
fillRate30d: 'fillRate30d',
|
||||
stockoutIncidents30d: 'stockoutIncidents30d',
|
||||
serviceLevel30d: 'serviceLevel30d',
|
||||
lostSalesIncidents30d: 'lostSalesIncidents30d',
|
||||
seasonalityIndex: 'seasonalityIndex',
|
||||
seasonalPattern: 'seasonalPattern',
|
||||
peakSeason: 'peakSeason',
|
||||
lifetimeRevenueQuality: 'lifetimeRevenueQuality'
|
||||
};
|
||||
|
||||
// Function to get backend key safely
|
||||
export function getBackendKey(frontendKey: string): string | null {
|
||||
return FRONTEND_TO_BACKEND_KEY_MAP[frontendKey] || null;
|
||||
}
|
||||
|
||||
export type ActiveFilterValue = FilterValue | FilterValueWithOperator;
|
||||
|
||||
interface FilterValueWithOperator {
|
||||
export interface FilterValueWithOperator {
|
||||
value: FilterValue | string[] | number[];
|
||||
operator: ComparisonOperator;
|
||||
}
|
||||
|
||||
type FilterValue = string | number | boolean;
|
||||
export type FilterValue = string | number | boolean;
|
||||
|
||||
export type ComparisonOperator =
|
||||
| "=" | "!=" | ">" | ">=" | "<" | "<=" | "between"
|
||||
|
||||
@@ -1,109 +1,19 @@
|
||||
import { ProductMetric, ProductStatus } from "@/types/products";
|
||||
|
||||
//Calculates the product status based on various metrics
|
||||
|
||||
export function getProductStatus(product: ProductMetric): ProductStatus {
|
||||
if (!product.isReplenishable) {
|
||||
return "Healthy"; // Non-replenishable items default to Healthy
|
||||
}
|
||||
|
||||
const {
|
||||
currentStock,
|
||||
stockCoverInDays,
|
||||
sellsOutInDays,
|
||||
overstockedUnits,
|
||||
configLeadTime,
|
||||
avgLeadTimeDays,
|
||||
dateLastSold,
|
||||
ageDays,
|
||||
isOldStock
|
||||
} = product;
|
||||
|
||||
const leadTime = configLeadTime ?? avgLeadTimeDays ?? 30; // Default lead time if none configured
|
||||
const safetyThresholdDays = leadTime * 0.5; // Safety threshold is 50% of lead time
|
||||
|
||||
// Check for overstock first
|
||||
if (overstockedUnits != null && overstockedUnits > 0) {
|
||||
return "Overstock";
|
||||
}
|
||||
|
||||
// Check for critical stock
|
||||
if (stockCoverInDays != null) {
|
||||
// Stock is <= 0 or very low compared to lead time
|
||||
if (currentStock <= 0 || stockCoverInDays <= 0) {
|
||||
return "Critical";
|
||||
}
|
||||
if (stockCoverInDays < safetyThresholdDays) {
|
||||
return "Critical";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for products that will need reordering soon
|
||||
if (sellsOutInDays != null && sellsOutInDays < (leadTime + 7)) { // Within lead time + 1 week
|
||||
// If also critically low, keep Critical status
|
||||
if (stockCoverInDays != null && stockCoverInDays < safetyThresholdDays) {
|
||||
return "Critical";
|
||||
}
|
||||
return "Reorder Soon";
|
||||
}
|
||||
|
||||
// Check for 'At Risk' - e.g., old stock or hasn't sold in a long time
|
||||
const ninetyDaysAgo = new Date();
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
|
||||
|
||||
if (isOldStock) {
|
||||
return "At Risk";
|
||||
}
|
||||
|
||||
if (dateLastSold && new Date(dateLastSold) < ninetyDaysAgo && (ageDays ?? 0) > 180) {
|
||||
return "At Risk";
|
||||
}
|
||||
|
||||
// Very high stock cover (more than a year) is at risk too
|
||||
if (stockCoverInDays != null && stockCoverInDays > 365) {
|
||||
return "At Risk";
|
||||
}
|
||||
|
||||
// If none of the above, assume Healthy
|
||||
return "Healthy";
|
||||
}
|
||||
|
||||
//Returns a Badge component HTML string for a given product status
|
||||
export function getStatusBadge(status: ProductStatus): string {
|
||||
switch (status) {
|
||||
case 'Critical':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-red-600 text-white">Critical</div>';
|
||||
case 'Reorder Soon':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-secondary bg-yellow-500 text-black">Reorder Soon</div>';
|
||||
case 'Healthy':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-green-600 text-white">Healthy</div>';
|
||||
case 'Overstock':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-secondary bg-blue-600 text-white">Overstock</div>';
|
||||
case 'At Risk':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-orange-500 text-orange-600">At Risk</div>';
|
||||
case 'New':
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-purple-600 text-white">New</div>';
|
||||
default:
|
||||
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">Unknown</div>';
|
||||
}
|
||||
}
|
||||
|
||||
//Formatting utilities for displaying metrics
|
||||
// Formatting utilities for displaying metrics
|
||||
export const formatCurrency = (value: number | null | undefined, digits = 2): string => {
|
||||
if (value == null) return 'N/A';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
export const formatNumber = (value: number | null | undefined, digits = 0): string => {
|
||||
if (value == null) return 'N/A';
|
||||
return value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits
|
||||
return value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits
|
||||
});
|
||||
};
|
||||
|
||||
@@ -120,10 +30,10 @@ export const formatDays = (value: number | null | undefined, digits = 0): string
|
||||
export const formatDate = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
return 'Invalid Date';
|
||||
@@ -133,4 +43,4 @@ export const formatDate = (dateString: string | null | undefined): string => {
|
||||
export const formatBoolean = (value: boolean | null | undefined): string => {
|
||||
if (value == null) return 'N/A';
|
||||
return value ? 'Yes' : 'No';
|
||||
};
|
||||
};
|
||||
|
||||
67
inventory/src/utils/transformUtils.ts
Normal file
67
inventory/src/utils/transformUtils.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Shared utilities for transforming data between backend (snake_case) and frontend (camelCase) formats.
|
||||
*/
|
||||
|
||||
// Fields that should remain as strings even if they look numeric
|
||||
const STRING_FIELDS = new Set([
|
||||
'sku', 'title', 'brand', 'vendor', 'barcode',
|
||||
'vendor_reference', 'notions_reference', 'harmonized_tariff_code'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Converts a snake_case key to camelCase.
|
||||
* Handles numeric suffixes like sales_7d -> sales7d
|
||||
*/
|
||||
export function snakeToCamelCase(key: string): string {
|
||||
let result = key;
|
||||
// First handle cases like sales_7d -> sales7d
|
||||
result = result.replace(/_(\d+[a-z])/g, '$1');
|
||||
// Then handle regular snake_case -> camelCase
|
||||
result = result.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a single metrics row from snake_case DB format to camelCase frontend format.
|
||||
* Converts numeric strings to actual numbers except for known string fields and dates.
|
||||
*/
|
||||
export function transformMetricsRow(row: Record<string, any>): Record<string, any> {
|
||||
const transformed: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
const camelKey = snakeToCamelCase(key);
|
||||
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
value !== '' &&
|
||||
!isNaN(Number(value)) &&
|
||||
!key.toLowerCase().includes('date') &&
|
||||
!STRING_FIELDS.has(key)
|
||||
) {
|
||||
transformed[camelKey] = Number(value);
|
||||
} else {
|
||||
transformed[camelKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure pid is a number
|
||||
if (transformed.pid !== undefined) {
|
||||
transformed.pid = typeof transformed.pid === 'string'
|
||||
? parseInt(transformed.pid, 10)
|
||||
: transformed.pid;
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps frontend operator names to backend query param suffixes.
|
||||
*/
|
||||
export const OPERATOR_MAP: Record<string, string> = {
|
||||
'=': 'eq', '!=': 'ne', '>': 'gt', '>=': 'gte', '<': 'lt', '<=': 'lte',
|
||||
'between': 'between', 'in': 'in', 'not_in': 'ne',
|
||||
'contains': 'ilike', 'equals': 'eq',
|
||||
'starts_with': 'starts_with', 'ends_with': 'ends_with', 'not_contains': 'not_contains',
|
||||
'is_empty': 'is_empty', 'is_not_empty': 'is_not_empty',
|
||||
'is_true': 'is_true', 'is_false': 'is_false',
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user