Updates and fixes for products page

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

View File

@@ -1,3 +1,4 @@
* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead. * 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 * 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

View File

@@ -435,7 +435,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id
LEFT JOIN product_last_sold pls ON p.pid = pls.pid LEFT JOIN product_last_sold pls ON p.pid = pls.pid
WHERE ${incrementalUpdate ? ` WHERE ${incrementalUpdate ? `
p.date_created > ? OR p.date_created >= DATE(?) OR
p.stamp > ? OR p.stamp > ? OR
ci.stamp > ? OR ci.stamp > ? OR
pcp.date_deactive > ? OR pcp.date_deactive > ? OR

View File

@@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { Pool } = require('pg'); // Assuming pg driver
// --- Configuration & Helpers --- // --- Configuration & Helpers ---
@@ -255,32 +254,98 @@ const SPECIAL_SORT_COLUMNS = {
}; };
// Status priority for sorting (lower number = higher priority) // Status priority for sorting (lower number = higher priority)
// Values must match what's stored in the DB status column
const STATUS_PRIORITY = { const STATUS_PRIORITY = {
'Critical': 1, 'Critical': 1,
'At Risk': 2, 'At Risk': 2,
'Reorder': 3, 'Reorder Soon': 3,
'Overstocked': 4, 'Overstock': 4,
'Healthy': 5, 'Healthy': 5,
'New': 6 'New': 6
// Any other status will be sorted alphabetically after these
}; };
// Get database column name from frontend column name // Get database column name from frontend column name
// Returns null for unknown keys so callers can skip them
function getDbColumn(frontendColumn) { 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) { 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 --- // --- 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 // GET /metrics/filter-options - Provide distinct values for filter dropdowns
router.get('/filter-options', async (req, res) => { router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
console.log('GET /metrics/filter-options');
try { try {
const [vendorRes, brandRes, abcClassRes] = await Promise.all([ 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`), 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 // GET /metrics/ - List all product metrics with filtering, sorting, pagination
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
console.log('GET /metrics received query:', req.query);
try { try {
// --- Pagination --- // --- Pagination ---
@@ -317,11 +381,9 @@ router.get('/', async (req, res) => {
// --- Sorting --- // --- Sorting ---
const sortQueryKey = req.query.sort || 'title'; // Default sort field key const sortQueryKey = req.query.sort || 'title'; // Default sort field key
const dbColumn = getDbColumn(sortQueryKey); const sortDbColumn = getDbColumn(sortQueryKey) || 'pm.title';
const columnType = getColumnType(sortQueryKey); const columnType = getColumnType(sortQueryKey);
console.log(`Sorting request: ${sortQueryKey} -> ${dbColumn} (${columnType})`);
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
// Always put nulls last regardless of sort direction or column type // 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') { if (SPECIAL_SORT_COLUMNS[sortQueryKey] === 'abs') {
// Sort by absolute value for columns where negative values matter // Sort by absolute value for columns where negative values matter
orderByClause = `ABS(${dbColumn}::numeric) ${sortDirection} ${nullsOrder}`; orderByClause = `ABS(${sortDbColumn}::numeric) ${sortDirection} ${nullsOrder}`;
} else if (columnType === 'number' || SPECIAL_SORT_COLUMNS[sortQueryKey] === true) { } else if (columnType === 'numeric' || SPECIAL_SORT_COLUMNS[sortQueryKey] === true) {
// For numeric columns, cast to numeric to ensure proper sorting // For numeric columns, cast to numeric to ensure proper sorting
orderByClause = `${dbColumn}::numeric ${sortDirection} ${nullsOrder}`; orderByClause = `${sortDbColumn}::numeric ${sortDirection} ${nullsOrder}`;
} else if (columnType === 'date') { } else if (columnType === 'date') {
// For date columns, cast to timestamp to ensure proper sorting // For date columns, cast to timestamp to ensure proper sorting
orderByClause = `CASE WHEN ${dbColumn} IS NULL THEN 1 ELSE 0 END, ${dbColumn}::timestamp ${sortDirection}`; orderByClause = `CASE WHEN ${sortDbColumn} IS NULL THEN 1 ELSE 0 END, ${sortDbColumn}::timestamp ${sortDirection}`;
} else if (columnType === 'status' || SPECIAL_SORT_COLUMNS[sortQueryKey] === 'priority') { } else if (SPECIAL_SORT_COLUMNS[sortQueryKey] === 'priority') {
// Special handling for status column, using priority for known statuses // Special handling for status column, using priority for known statuses
orderByClause = ` orderByClause = `
CASE WHEN ${dbColumn} IS NULL THEN 999 CASE WHEN ${sortDbColumn} IS NULL THEN 999
WHEN ${dbColumn} = 'Critical' THEN 1 WHEN ${sortDbColumn} = 'Critical' THEN 1
WHEN ${dbColumn} = 'At Risk' THEN 2 WHEN ${sortDbColumn} = 'At Risk' THEN 2
WHEN ${dbColumn} = 'Reorder' THEN 3 WHEN ${sortDbColumn} = 'Reorder Soon' THEN 3
WHEN ${dbColumn} = 'Overstocked' THEN 4 WHEN ${sortDbColumn} = 'Overstock' THEN 4
WHEN ${dbColumn} = 'Healthy' THEN 5 WHEN ${sortDbColumn} = 'Healthy' THEN 5
WHEN ${dbColumn} = 'New' THEN 6 WHEN ${sortDbColumn} = 'New' THEN 6
ELSE 100 ELSE 100
END ${sortDirection} ${nullsOrder}, END ${sortDirection} ${nullsOrder},
${dbColumn} ${sortDirection}`; ${sortDbColumn} ${sortDirection}`;
} else { } else {
// For string and boolean columns, no special casting needed // 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 --- // --- Filtering ---
@@ -389,26 +451,26 @@ router.get('/', async (req, res) => {
let operator = '='; // Default operator let operator = '='; // Default operator
let value = req.query[key]; let value = req.query[key];
// Check for operator suffixes (e.g., sales30d_gt, title_like) // 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|between|in)$/); 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) { if (operatorMatch) {
filterKey = operatorMatch[1]; // e.g., "sales30d" filterKey = operatorMatch[1]; // e.g., "sales30d"
operator = operatorMatch[2]; // e.g., "gt" operator = operatorMatch[2]; // e.g., "gt"
} }
// Get the database column for this filter key // Get the database column for this filter key
const dbColumn = getDbColumn(filterKey); const filterDbColumn = getDbColumn(filterKey);
const valueType = getColumnType(filterKey); const valueType = getColumnType(filterKey);
if (!dbColumn) { if (!filterDbColumn) {
console.warn(`Invalid filter key ignored: ${key}`); console.warn(`Invalid filter key ignored: ${key}`);
continue; // Skip if the key doesn't map to a known column continue; // Skip if the key doesn't map to a known column
} }
// --- Build WHERE clause fragment --- // --- Build WHERE clause fragment ---
let needsParam = true; // Declared outside try so catch can access it
try { try {
let conditionFragment = ''; let conditionFragment = '';
let needsParam = true; // Most operators need a parameter
switch (operator.toLowerCase()) { switch (operator.toLowerCase()) {
case 'eq': operator = '='; break; case 'eq': operator = '='; break;
@@ -417,48 +479,65 @@ router.get('/', async (req, res) => {
case 'gte': operator = '>='; break; case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break; case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break; case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; value = `%${value}%`; break; // Add wildcards for LIKE case 'like': operator = 'ILIKE'; value = `%${value}%`; break;
case 'ilike': operator = 'ILIKE'; value = `%${value}%`; break; // Add wildcards for ILIKE 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': case 'between':
const [val1, val2] = String(value).split(','); const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) { 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)); params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false; // Params added manually needsParam = false;
} else { } else {
console.warn(`Invalid 'between' value for ${key}: ${value}`); console.warn(`Invalid 'between' value for ${key}: ${value}`);
continue; // Skip this filter continue;
} }
break; break;
case 'in': case 'in':
const inValues = String(value).split(','); const inValues = String(value).split(',');
if (inValues.length > 0) { if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', '); const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`; conditionFragment = `${filterDbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType))); // Add all parsed values params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false; // Params added manually needsParam = false;
} else { } else {
console.warn(`Invalid 'in' value for ${key}: ${value}`); console.warn(`Invalid 'in' value for ${key}: ${value}`);
continue; // Skip this filter continue;
} }
break; break;
// Add other operators as needed (IS NULL, IS NOT NULL, etc.) case '=':
case '=': // Keep default '=' default: operator = '='; break;
default: operator = '='; break; // Ensure default is handled
} }
if (needsParam) { if (needsParam) {
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; conditionFragment = `${filterDbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType)); params.push(parseValue(value, valueType));
} }
if (conditionFragment) { if (conditionFragment) {
conditions.push(`(${conditionFragment})`); // Wrap condition in parentheses conditions.push(`(${conditionFragment})`);
} }
} catch (parseError) { } catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`); 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--; if (needsParam) paramCounter--;
} }
} }
@@ -466,13 +545,8 @@ router.get('/', async (req, res) => {
// --- Construct and Execute Queries --- // --- Construct and Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Debug log of conditions and parameters
console.log('Constructed WHERE conditions:', conditions);
console.log('Parameters:', params);
// Count Query // Count Query
const countSql = `SELECT COUNT(*) AS total FROM public.product_metrics pm ${whereClause}`; const countSql = `SELECT COUNT(*) AS total FROM public.product_metrics pm ${whereClause}`;
console.log('Executing Count Query:', countSql, params);
const countPromise = pool.query(countSql, params); const countPromise = pool.query(countSql, params);
// Data Query (Select all columns from metrics table for now) // Data Query (Select all columns from metrics table for now)
@@ -484,16 +558,6 @@ router.get('/', async (req, res) => {
LIMIT $${paramCounter} OFFSET $${paramCounter + 1} LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`; `;
const dataParams = [...params, limit, offset]; 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); const dataPromise = pool.query(dataSql, dataParams);
// Execute queries in parallel // Execute queries in parallel
@@ -501,7 +565,6 @@ router.get('/', async (req, res) => {
const total = parseInt(countResult.rows[0].total, 10); const total = parseInt(countResult.rows[0].total, 10);
const metrics = dataResult.rows; const metrics = dataResult.rows;
console.log(`Total: ${total}, Fetched: ${metrics.length} for page ${page}`);
// --- Respond --- // --- Respond ---
res.json({ res.json({
@@ -535,7 +598,6 @@ router.get('/:pid', async (req, res) => {
return res.status(400).json({ error: 'Invalid Product ID.' }); return res.status(400).json({ error: 'Invalid Product ID.' });
} }
console.log(`GET /metrics/${pid}`);
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT * FROM public.product_metrics WHERE pid = $1`, `SELECT * FROM public.product_metrics WHERE pid = $1`,
@@ -543,11 +605,8 @@ router.get('/:pid', async (req, res) => {
); );
if (rows.length === 0) { if (rows.length === 0) {
console.log(`Metrics not found for PID: ${pid}`);
return res.status(404).json({ error: 'Metrics not found for this product.' }); 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 // Data is pre-calculated, return the first (only) row
res.json(rows[0]); 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? if (value === null || value === undefined || value === '') return null; // Allow empty strings? Or handle differently?
switch (type) { switch (type) {
case 'number': case 'numeric':
const num = parseFloat(value); const num = parseFloat(value);
if (isNaN(num)) throw new Error(`Invalid number format: "${value}"`); if (isNaN(num)) throw new Error(`Invalid number format: "${value}"`);
return num; return num;

View File

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

View File

@@ -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" : ""}`}> <span className={`text-sm ${p.current_stock <= 0 ? "text-red-500" : p.is_low_stock ? "text-yellow-600" : ""}`}>
{p.current_stock ?? 0} {p.current_stock ?? 0}
</span> </span>
{p.on_order_qty > 0 && ( {p.preorder_count > 0 && (
<span className="text-xs text-blue-500 ml-1">(+{p.on_order_qty})</span> <span className="text-xs text-blue-500 ml-1">(+{p.preorder_count})</span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-center text-sm">{p.sales_7d ?? 0}</TableCell> <TableCell className="text-center text-sm">{p.sales_7d ?? 0}</TableCell>

View File

@@ -6,9 +6,8 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { ProductMetric, ProductStatus } from "@/types/products"; import { ProductMetric } from "@/types/products";
import { import {
getStatusBadge,
formatCurrency, formatCurrency,
formatNumber, formatNumber,
formatPercentage, formatPercentage,
@@ -16,9 +15,11 @@ import {
formatDate, formatDate,
formatBoolean formatBoolean
} from "@/utils/productUtils"; } from "@/utils/productUtils";
import { StatusBadge } from "@/components/products/StatusBadge";
import { transformMetricsRow } from "@/utils/transformUtils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import config from "@/config"; 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 { Badge } from "@/components/ui/badge";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
@@ -30,8 +31,7 @@ interface ProductPurchaseOrder {
receivedDate: string | null; receivedDate: string | null;
ordered: number; ordered: number;
received: number; received: number;
status: number; status: string;
receivingStatus: number;
costPrice: number; costPrice: number;
notes: string | null; notes: string | null;
leadTimeDays: number | null; leadTimeDays: number | null;
@@ -42,7 +42,6 @@ interface ProductTimeSeries {
month: string; month: string;
sales: number; sales: number;
revenue: number; revenue: number;
profit: number;
}[]; }[];
recentPurchases: ProductPurchaseOrder[]; 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'}`); throw new Error(`Failed to fetch product details (${response.status}): ${errorData.error || 'Server error'}`);
} }
const rawData = await response.json(); const rawData = await response.json();
return transformMetricsRow(rawData) as ProductMetric;
// 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;
}, },
enabled: !!productId, // Only run query when productId is truthy enabled: !!productId, // Only run query when productId is truthy
}); });
@@ -109,47 +79,57 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
} }
const data = await response.json(); const data = await response.json();
// Ensure the monthly_sales data is properly formatted for charts // Map backend field names (units_sold) to frontend chart keys (sales)
const formattedMonthlySales = data.monthly_sales.map((item: any) => ({ // 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, month: item.month,
sales: Number(item.sales), sales: Number(item.units_sold || 0),
revenue: Number(item.revenue), revenue: Number(item.revenue || 0),
profit: Number(item.profit || 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 { return {
monthlySales: formattedMonthlySales, monthlySales: formattedMonthlySales,
recentPurchases: data.recent_purchases || [] recentPurchases: formattedPurchases,
}; };
}, },
enabled: !!productId, // Only run query when productId is truthy enabled: !!productId, // Only run query when productId is truthy
}); });
// Get PO status names // Get PO status display names (DB stores text statuses)
const getPOStatusName = (status: number): string => { const getPOStatusName = (status: string): string => {
const statusMap: {[key: number]: string} = { const statusMap: Record<string, string> = {
0: 'Canceled', canceled: 'Canceled',
1: 'Created', created: 'Created',
10: 'Ready to Send', ordered: 'Ordered',
11: 'Ordered', electronically_sent: 'Electronically Sent',
12: 'Preordered', receiving_started: 'Receiving Started',
13: 'Electronically Sent', done: 'Completed',
15: 'Receiving Started',
50: 'Completed'
}; };
return statusMap[status] || 'Unknown'; return statusMap[status] || 'Unknown';
}; };
// Get status badge color class // Get status badge color class
const getStatusBadgeClass = (status: number): string => { const getStatusBadgeClass = (status: string): string => {
if (status === 0) return "bg-destructive text-destructive-foreground"; // Canceled if (status === 'canceled') return "bg-destructive text-destructive-foreground";
if (status === 50) return "bg-green-600 text-white"; // Completed if (status === 'done') return "bg-green-600 text-white";
if (status >= 15) return "bg-amber-500 text-black"; // In progress if (status === 'receiving_started') return "bg-amber-500 text-black";
return "bg-blue-600 text-white"; // Other statuses 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 if (!productId) return null; // Don't render anything if no ID
return ( return (
@@ -160,7 +140,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
{/* Header */} {/* Header */}
<div className="flex items-start justify-between p-4 border-b sticky top-0 bg-background z-10"> <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"> <div className="flex items-center gap-4 overflow-hidden">
{isLoading ? ( {isLoadingProduct ? (
<Skeleton className="h-16 w-16 rounded-lg" /> <Skeleton className="h-16 w-16 rounded-lg" />
) : product?.imageUrl ? ( ) : product?.imageUrl ? (
<div className="h-16 w-16 rounded-lg border bg-white p-1 flex-shrink-0"> <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"> <div className="flex-1 min-w-0">
<VaulDrawer.Title className="text-lg font-semibold truncate"> <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.Title>
<VaulDrawer.Description className="text-sm text-muted-foreground"> <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">
&middot; Updated {formatDate(product.lastCalculated)}
</span>
)}
</>
)}
</VaulDrawer.Description> </VaulDrawer.Description>
{/* Show Status Badge */} {/* Show Status Badge */}
{!isLoading && product && ( {!isLoadingProduct && product && (
<div className="mt-1" dangerouslySetInnerHTML={{ __html: getStatusBadge(product.status as ProductStatus) }} /> <div className="mt-1"><StatusBadge status={product.status as string} /></div>
)} )}
</div> </div>
</div> </div>
@@ -189,7 +178,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
{/* Content Area */} {/* Content Area */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{isLoading ? ( {isLoadingProduct ? (
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<Skeleton className="h-8 w-1/2" /> <Skeleton className="h-8 w-1/2" />
<div className="grid grid-cols-2 gap-4"> <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)} /> <InfoItem label="Landing Cost" value={formatCurrency(product.currentLandingCostPrice)} />
</CardContent> </CardContent>
</Card> </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>
<TabsContent value="inventory" className="space-y-4"> <TabsContent value="inventory" className="space-y-4">
@@ -248,7 +246,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<Card> <Card>
<CardHeader><CardTitle className="text-base">Stock Position</CardTitle></CardHeader> <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"> <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="Stock Cover" value={formatDays(product.stockCoverInDays)} />
<InfoItem label="Sells Out In" value={formatDays(product.sellsOutInDays)} /> <InfoItem label="Sells Out In" value={formatDays(product.sellsOutInDays)} />
<InfoItem label="Overstock Units" value={formatNumber(product.overstockedUnits)} /> <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)} /> <InfoItem label="Earliest Arrival" value={formatDate(product.earliestExpectedDate)} />
</CardContent> </CardContent>
</Card> </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>
<TabsContent value="performance" className="space-y-4"> <TabsContent value="performance" className="space-y-4">
@@ -290,7 +297,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<YAxis yAxisId="right" orientation="right" /> <YAxis yAxisId="right" orientation="right" />
<Tooltip <Tooltip
formatter={(value: number, name: string) => { formatter={(value: number, name: string) => {
if (name === 'revenue' || name === 'profit') { if (name === 'Revenue') {
return [formatCurrency(value), name]; return [formatCurrency(value), name];
} }
return [value, name]; return [value, name];
@@ -312,13 +319,6 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
name="Revenue" name="Revenue"
stroke="#82ca9d" stroke="#82ca9d"
/> />
<Line
yAxisId="right"
type="monotone"
dataKey="profit"
name="Profit"
stroke="#ffc658"
/>
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
@@ -342,56 +342,13 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Inventory KPIs Chart */}
<Card> <Card>
<CardHeader> <CardHeader><CardTitle className="text-base">Growth Analysis</CardTitle></CardHeader>
<CardTitle className="text-base">Key Inventory Metrics</CardTitle> <CardContent className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-2 text-sm">
</CardHeader> <InfoItem label="Sales Growth (30d vs Prev)" value={formatPercentage(product.salesGrowth30dVsPrev)} />
<CardContent className="h-[250px]"> <InfoItem label="Revenue Growth (30d vs Prev)" value={formatPercentage(product.revenueGrowth30dVsPrev)} />
{isLoading ? ( <InfoItem label="Sales Growth YoY" value={formatPercentage(product.salesGrowthYoy)} />
<Skeleton className="h-[200px] w-full" /> <InfoItem label="Revenue Growth YoY" value={formatPercentage(product.revenueGrowthYoy)} />
) : (
<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>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -508,9 +465,11 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
value={ value={
timeSeriesData?.recentPurchases ? timeSeriesData?.recentPurchases ?
(() => { (() => {
const totalOrdered = timeSeriesData.recentPurchases.reduce((acc, po) => acc + po.ordered, 0); // Only include POs where receiving has started or completed for an accurate rate
const totalReceived = timeSeriesData.recentPurchases.reduce((acc, po) => acc + po.received, 0); const receivingPOs = timeSeriesData.recentPurchases.filter(po => ['receiving_started', 'done'].includes(po.status));
return totalOrdered > 0 ? formatPercentage(totalReceived / totalOrdered) : 'N/A'; 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' })() : 'N/A'
} }
/> />
@@ -544,7 +503,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
timeSeriesData?.recentPurchases ? timeSeriesData?.recentPurchases ?
formatNumber( formatNumber(
timeSeriesData.recentPurchases.filter( timeSeriesData.recentPurchases.filter(
po => po.status < 50 && po.receivingStatus < 40 po => !['done', 'canceled'].includes(po.status)
).length ).length
) : 'N/A' ) : 'N/A'
} }
@@ -584,6 +543,29 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<InfoItem label="Forecast Lost Revenue" value={formatCurrency(product.forecastLostRevenue)} /> <InfoItem label="Forecast Lost Revenue" value={formatCurrency(product.forecastLostRevenue)} />
</CardContent> </CardContent>
</Card> </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> </TabsContent>
</Tabs> </Tabs>
) : null} ) : null}

View File

@@ -19,14 +19,21 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Skeleton } from "@/components/ui/skeleton"; 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 // Define operators for different filter types
const STRING_OPERATORS: ComparisonOperator[] = ["contains", "equals", "starts_with", "ends_with", "not_contains", "is_empty", "is_not_empty"]; 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 NUMBER_OPERATORS: ComparisonOperator[] = ["=", ">", ">=", "<", "<=", "between", "is_empty", "is_not_empty"];
const BOOLEAN_OPERATORS: ComparisonOperator[] = ["is_true", "is_false"]; const BOOLEAN_OPERATORS: ComparisonOperator[] = ["is_true", "is_false"];
const DATE_OPERATORS: ComparisonOperator[] = ["=", ">", ">=", "<", "<=", "between", "is_empty", "is_not_empty"]; 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 { interface FilterOption {
id: ProductMetricColumnKey | 'search'; id: ProductMetricColumnKey | 'search';
@@ -37,21 +44,6 @@ interface FilterOption {
operators?: ComparisonOperator[]; 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 { interface ActiveFilterDisplay {
id: string; id: string;
label: 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: [ { id: 'status', label: 'Status', type: 'select', group: 'Basic Info', operators: SELECT_OPERATORS, options: [
{ value: 'Critical', label: 'Critical' }, { value: 'Critical', label: 'Critical' },
{ value: 'At Risk', label: 'At Risk' }, { value: 'At Risk', label: 'At Risk' },
{ value: 'Reorder', label: 'Reorder' }, { value: 'Reorder Soon', label: 'Reorder Soon' },
{ value: 'Overstocked', label: 'Overstocked' }, { value: 'Overstock', label: 'Overstock' },
{ value: 'Healthy', label: 'Healthy' }, { value: 'Healthy', label: 'Healthy' },
{ value: 'New', label: 'New' }, { value: 'New', label: 'New' },
]}, ]},
@@ -347,7 +339,7 @@ export function ProductFilters({
if (!selectedFilter) return; if (!selectedFilter) return;
let valueToApply: FilterValue | [string, string]; // Use string for dates 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 (selectedOperator === 'between') {
if (!inputValue || !inputValue2) return; // Need both values if (!inputValue || !inputValue2) return; // Need both values
@@ -357,9 +349,9 @@ export function ProductFilters({
const numVal = parseFloat(inputValue); const numVal = parseFloat(inputValue);
if (isNaN(numVal)) return; // Invalid number if (isNaN(numVal)) return; // Invalid number
valueToApply = numVal; valueToApply = numVal;
} else if (selectedFilter.type === 'boolean' || selectedFilter.type === 'select') { } else if (selectedFilter.type === 'select') {
valueToApply = inputValue; // Value set directly via CommandItem 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) } else { // Text or Date (not between)
if (!inputValue.trim()) return; if (!inputValue.trim()) return;
valueToApply = inputValue.trim(); valueToApply = inputValue.trim();
@@ -404,9 +396,15 @@ export function ProductFilters({
if (!option) return String(value); // Fallback if (!option) return String(value); // Fallback
if (typeof value === 'object' && value !== null && 'operator' in value) { 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} `; const opLabel = value.operator === '=' ? '' : `${value.operator} `;
if (value.operator === 'between' && Array.isArray(value.value)) { 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}`; return `${option.label}: ${opLabel}${value.value}`;
} }
@@ -529,8 +527,8 @@ export function ProductFilters({
{selectedFilter.label} {selectedFilter.label}
</Button> </Button>
{/* Render Operator Select ONLY if type is number or date */} {/* Render Operator Select for number, date, and text types */}
{(selectedFilter.type === 'number' || selectedFilter.type === 'date') && renderOperatorSelect()} {(selectedFilter.type === 'number' || selectedFilter.type === 'date' || selectedFilter.type === 'text') && renderOperatorSelect()}
{/* Render Input based on type */} {/* Render Input based on type */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -606,8 +604,42 @@ export function ProductFilters({
{/* Select and Boolean types are handled via CommandList below */} {/* Select and Boolean types are handled via CommandList below */}
</div> </div>
{/* CommandList for Select and Boolean */} {/* Boolean type: show Yes/No buttons */}
{(selectedFilter.type === 'select' || selectedFilter.type === 'boolean') && ( {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"> <Command className="mt-2 border rounded-md">
<CommandInput <CommandInput
ref={selectInputRef} ref={selectInputRef}

View 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>
);
}

View File

@@ -25,19 +25,10 @@ import {
useSortable, useSortable,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; 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 { Skeleton } from "@/components/ui/skeleton";
import { getStatusBadge } from "@/utils/productUtils"; import { StatusBadge } from "@/components/products/StatusBadge";
import { ColumnDef } from "@/components/products/columnDefinitions";
// Column definition
interface ColumnDef {
key: ProductMetricColumnKey;
label: string;
group: string;
noLabel?: boolean;
width?: string;
format?: (value: any, product?: ProductMetric) => React.ReactNode;
}
interface ProductTableProps { interface ProductTableProps {
products: ProductMetric[]; products: ProductMetric[];
@@ -145,10 +136,6 @@ export function ProductTable({
return columnOrder.filter(col => visibleColumns.has(col)); return columnOrder.filter(col => visibleColumns.has(col));
}, [columnOrder, visibleColumns]); }, [columnOrder, visibleColumns]);
const handleDragStart = () => {
// No need to set activeId as it's not used in the new implementation
};
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; 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 formatColumnValue = (product: ProductMetric, columnKey: ProductMetricColumnKey): React.ReactNode => {
const value = product[columnKey as keyof ProductMetric]; 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 // Use the format function from column definition if available
if (columnDef?.format) { if (columnDef?.format) {
return columnDef.format(value, product); return columnDef.format(value, product);
} }
// Special handling for status // Special handling for status - proper React component instead of dangerouslySetInnerHTML
if (columnKey === 'status') { if (columnKey === 'status') {
return <div dangerouslySetInnerHTML={{ __html: getStatusBadge(value as ProductStatus) }} />; return <StatusBadge status={value as string} />;
} }
// Special handling for boolean values // Special handling for boolean values
@@ -204,9 +196,7 @@ export function ProductTable({
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={() => {}}
> >
<div className="border rounded-md relative"> <div className="border rounded-md relative">
{isLoading && ( {isLoading && (
@@ -226,7 +216,7 @@ export function ProductTable({
<SortableHeader <SortableHeader
key={columnKey} key={columnKey}
column={columnKey} column={columnKey}
columnDef={columnDefs.find(def => def.key === columnKey)} columnDef={columnDefMap.get(columnKey)}
onSort={onSort} onSort={onSort}
sortColumn={sortColumn} sortColumn={sortColumn}
sortDirection={sortDirection} sortDirection={sortDirection}
@@ -254,7 +244,7 @@ export function ProductTable({
data-state={isLoading ? 'loading' : undefined} data-state={isLoading ? 'loading' : undefined}
> >
{orderedVisibleColumns.map((columnKey) => { {orderedVisibleColumns.map((columnKey) => {
const colDef = columnDefs.find(c => c.key === columnKey); const colDef = columnDefMap.get(columnKey);
return ( return (
<TableCell <TableCell
key={`${product.pid}-${columnKey}`} key={`${product.pid}-${columnKey}`}
@@ -289,7 +279,7 @@ export function ProductTable({
{isLoading && products.length === 0 && Array.from({length: 10}).map((_, i) => ( {isLoading && products.length === 0 && Array.from({length: 10}).map((_, i) => (
<TableRow key={`skel-${i}`}> <TableRow key={`skel-${i}`}>
{orderedVisibleColumns.map(key => { {orderedVisibleColumns.map(key => {
const colDef = columnDefs.find(c => c.key === key); const colDef = columnDefMap.get(key);
return ( return (
<TableCell <TableCell
key={`skel-${i}-${key}`} key={`skel-${i}-${key}`}

View File

@@ -61,16 +61,42 @@ 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 { interface ProductViewsProps {
activeView: string activeView: string
onViewChange: (view: string) => void 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 ( return (
<Tabs value={activeView} onValueChange={onViewChange} className="w-full"> <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"> <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) => ( {PRODUCT_VIEWS.map((view) => {
const count = viewCounts ? getCountForView(view.id, viewCounts) : null;
return (
<TabsTrigger <TabsTrigger
key={view.id} key={view.id}
value={view.id} value={view.id}
@@ -78,8 +104,14 @@ export function ProductViews({ activeView, onViewChange }: ProductViewsProps) {
> >
<view.icon className={`h-4 w-4 ${view.iconClassName} mr-2`} /> <view.icon className={`h-4 w-4 ${view.iconClassName} mr-2`} />
{view.label} {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> </TabsTrigger>
))} );
})}
</TabsList> </TabsList>
</Tabs> </Tabs>
) )

View File

@@ -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>
);
}

View 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>
);
}

View 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[]>);

View File

@@ -1,9 +1,10 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Settings2 } from 'lucide-react'; import { Settings2, Search, X } from 'lucide-react';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@@ -29,320 +30,123 @@ import { ProductFilters } from "@/components/products/ProductFilters";
import { ProductDetail } from "@/components/products/ProductDetail"; import { ProductDetail } from "@/components/products/ProductDetail";
import { ProductViews } from "@/components/products/ProductViews"; import { ProductViews } from "@/components/products/ProductViews";
import { ProductTableSkeleton } from "@/components/products/ProductTableSkeleton"; import { ProductTableSkeleton } from "@/components/products/ProductTableSkeleton";
import { ProductSummaryCards } from "@/components/products/ProductSummaryCards";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useDebounce } from "@/hooks/useDebounce";
import { AVAILABLE_COLUMNS, VIEW_COLUMNS, COLUMNS_BY_GROUP } from "@/components/products/columnDefinitions";
import { transformMetricsRow, OPERATOR_MAP } from "@/utils/transformUtils";
// Column definition type // --- ColumnToggle Component ---
interface ColumnDef { // Extracted as a proper component for stable React identity and correct internal state management.
key: ProductMetricColumnKey;
label: string; interface ColumnToggleProps {
group: string; visibleColumns: Set<ProductMetricColumnKey>;
noLabel?: boolean; onColumnVisibilityChange: (column: ProductMetricColumnKey, isVisible: boolean) => void;
width?: string; onResetToDefault: () => void;
format?: (value: any, product?: ProductMetric) => React.ReactNode;
} }
// Define available columns with their groups function ColumnToggle({ visibleColumns, onColumnVisibilityChange, onResetToDefault }: ColumnToggleProps) {
const AVAILABLE_COLUMNS: ColumnDef[] = [ const [open, setOpen] = React.useState(false);
// 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 return (
{ key: 'vendor', label: 'Supplier', group: 'Supply Chain' }, <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
{ key: 'vendorReference', label: 'Supplier #', group: 'Supply Chain' }, <DropdownMenuTrigger asChild>
{ key: 'notionsReference', label: 'Notions #', group: 'Supply Chain' }, <Button variant="outline" className="ml-auto">
{ key: 'harmonizedTariffCode', label: 'Tariff Code', group: 'Supply Chain' }, <Settings2 className="mr-2 h-4 w-4" />
{ key: 'countryOfOrigin', label: 'Country', group: 'Supply Chain' }, Columns
{ key: 'location', label: 'Location', group: 'Supply Chain' }, </Button>
{ key: 'moq', label: 'MOQ', group: 'Supply Chain', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, </DropdownMenuTrigger>
<DropdownMenuContent
// Physical Properties align="end"
{ key: 'weight', label: 'Weight', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, className="w-[600px] max-h-[calc(100vh-16rem)] overflow-y-auto"
{ key: 'length', label: 'Length', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, onCloseAutoFocus={(e) => e.preventDefault()}
{ key: 'width', label: 'Width', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, onPointerDownOutside={(e) => {
{ key: 'height', label: 'Height', group: 'Physical', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, if (!(e.target as HTMLElement).closest('[role="dialog"]')) {
{ key: 'dimensions', label: 'Dimensions', group: 'Physical', format: (_, product) => { setOpen(false);
// Handle dimensions as separate length, width, height fields }
const length = product?.length; }}
const width = product?.width; onInteractOutside={(e) => {
const height = product?.height; if ((e.target as HTMLElement).closest('[role="dialog"]')) {
if (length && width && height) { e.preventDefault();
return `${length}×${width}×${height}`; }
}}
>
<div className="sticky top-0 bg-background z-10 flex items-center justify-between">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
onResetToDefault();
e.stopPropagation();
}}
>
Reset to Default
</Button>
</div>
<DropdownMenuSeparator className="sticky top-[29px] bg-background z-10" />
<div style={{ columnCount: 3, columnGap: '2rem' }} className="p-2">
{Object.entries(COLUMNS_BY_GROUP).map(([group, columns]) => (
<div key={group} style={{ breakInside: 'avoid' }} className="mb-4">
<DropdownMenuLabel className="text-xs font-semibold text-muted-foreground mb-2">
{group}
</DropdownMenuLabel>
<div className="flex flex-col gap-1">
{columns.map((column) => (
<DropdownMenuCheckboxItem
key={column.key}
className="capitalize"
checked={visibleColumns.has(column.key)}
onCheckedChange={(checked) => {
onColumnVisibilityChange(column.key, checked);
}}
onSelect={(e) => {
e.preventDefault();
}}
>
{column.label}
</DropdownMenuCheckboxItem>
))}
</div>
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
} }
return '-';
}},
// Customer Engagement // --- Helper: Transform frontend filter state to API query params ---
{ 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 function transformFilters(filters: Record<string, any>): Record<string, any> {
{ key: 'currentStock', label: 'Current Stock', group: 'Inventory', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, const transformedFilters: Record<string, any> = {};
{ 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 Object.entries(filters).forEach(([key, value]) => {
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, if (typeof value === 'object' && value !== null && 'operator' in value) {
{ key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, const operatorSuffix = OPERATOR_MAP[value.operator] || 'eq';
{ 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 // For between, send as comma-separated string (backend splits on comma)
{ key: 'dateFirstReceived', label: 'First Received', group: 'Dates' }, if (value.operator === 'between' && Array.isArray(value.value)) {
{ key: 'dateLastReceived', label: 'Last Received', group: 'Dates' }, transformedFilters[`${key}_${operatorSuffix}`] = value.value.join(',');
{ key: 'dateFirstSold', label: 'First Sold', group: 'Dates' }, } else {
{ key: 'dateLastSold', label: 'Last Sold', group: 'Dates' }, transformedFilters[`${key}_${operatorSuffix}`] = value.value;
{ 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) : '-' }, } else {
{ key: 'replenishDate', label: 'Replenish Date', group: 'Dates' }, // Simple values (from select/boolean filters) are passed as-is
{ key: 'planningPeriodDays', label: 'Planning Period (Days)', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, transformedFilters[key] = value;
}
});
// Sales & Revenue return transformedFilters;
{ 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) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ 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 // --- Main Products Page Component ---
{ 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
{ key: 'salesGrowth30dVsPrev', label: 'Sales Growth % (30d vs Prev)', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'revenueGrowth30dVsPrev', label: 'Revenue Growth % (30d vs Prev)', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'salesGrowthYoy', label: 'Sales Growth % YoY', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'revenueGrowthYoy', label: 'Revenue Growth % YoY', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
// 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
const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
all: [
'imageUrl',
'title',
'brand',
'status',
'currentStock',
'currentPrice',
'salesVelocityDaily',
'sales30d',
'revenue30d',
'profit30d',
'stockCoverInDays',
'currentStockCost',
'salesGrowth30dVsPrev'
],
critical: [
'status',
'imageUrl',
'title',
'currentStock',
'configSafetyStock',
'replenishmentUnits',
'salesVelocityDaily',
'sales7d',
'sales30d',
'onOrderQty',
'earliestExpectedDate',
'vendor',
'dateLastReceived',
'avgLeadTimeDays',
'serviceLevel30d',
'stockoutIncidents30d'
],
reorder: [
'status',
'imageUrl',
'title',
'currentStock',
'configSafetyStock',
'replenishmentUnits',
'salesVelocityDaily',
'sellsOutInDays',
'currentCostPrice',
'sales30d',
'vendor',
'avgLeadTimeDays',
'dateLastReceived',
'demandPattern'
],
overstocked: [
'status',
'imageUrl',
'title',
'currentStock',
'overstockedUnits',
'sales7d',
'sales30d',
'salesVelocityDaily',
'stockCoverInDays',
'stockturn30d',
'currentStockCost',
'overstockedCost',
'dateLastSold',
'salesVariance30d'
],
'at-risk': [
'status',
'imageUrl',
'title',
'currentStock',
'configSafetyStock',
'salesVelocityDaily',
'sales7d',
'sales30d',
'stockCoverInDays',
'sellsOutInDays',
'dateLastSold',
'avgLeadTimeDays',
'profit30d',
'fillRate30d',
'salesGrowth30dVsPrev'
],
new: [
'status',
'imageUrl',
'title',
'currentStock',
'salesVelocityDaily',
'sales7d',
'vendor',
'brand',
'currentPrice',
'currentCostPrice',
'dateFirstReceived',
'ageDays',
'abcClass',
'first7DaysSales',
'first30DaysSales'
],
healthy: [
'status',
'imageUrl',
'title',
'currentStock',
'stockCoverInDays',
'salesVelocityDaily',
'sales30d',
'revenue30d',
'profit30d',
'margin30d',
'gmroi30d',
'stockturn30d',
'salesGrowth30dVsPrev',
'serviceLevel30d'
],
};
export function Products() { export function Products() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [filters, setFilters] = useState<Record<string, ActiveFilterValue>>({}); const [filters, setFilters] = useState<Record<string, ActiveFilterValue>>({});
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 300);
const [sortColumn, setSortColumn] = useState<ProductMetricColumnKey>('title'); const [sortColumn, setSortColumn] = useState<ProductMetricColumnKey>('title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// Track last sort direction for each column // Track last sort direction for each column
@@ -353,9 +157,10 @@ export function Products() {
const [activeView, setActiveView] = useState(searchParams.get('view') || "all"); const [activeView, setActiveView] = useState(searchParams.get('view') || "all");
const [pageSize] = useState(50); const [pageSize] = useState(50);
const [showNonReplenishable, setShowNonReplenishable] = useState(false); const [showNonReplenishable, setShowNonReplenishable] = useState(false);
const [showInvisible, setShowInvisible] = useState(false);
const [selectedProductId, setSelectedProductId] = useState<number | null>(null); const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
const [, setIsLoading] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const searchInputRef = React.useRef<HTMLInputElement>(null);
// Store visible columns and order for each view // Store visible columns and order for each view
const [viewColumns, setViewColumns] = useState<Record<string, Set<ProductMetricColumnKey>>>(() => { const [viewColumns, setViewColumns] = useState<Record<string, Set<ProductMetricColumnKey>>>(() => {
@@ -377,7 +182,7 @@ export function Products() {
return initialOrder; return initialOrder;
}); });
// Get current view's columns // Get current view's columns, auto-adding indicator columns when toggles are active
const visibleColumns = useMemo(() => { const visibleColumns = useMemo(() => {
const columns = new Set(viewColumns[activeView] || VIEW_COLUMNS.all); const columns = new Set(viewColumns[activeView] || VIEW_COLUMNS.all);
@@ -385,32 +190,41 @@ export function Products() {
if (showNonReplenishable) { if (showNonReplenishable) {
columns.add('isReplenishable'); columns.add('isReplenishable');
} }
// Add isVisible column when showing invisible products so users can distinguish them
if (showInvisible) {
columns.add('isVisible');
}
return columns; return columns;
}, [viewColumns, activeView, showNonReplenishable]); }, [viewColumns, activeView, showNonReplenishable, showInvisible]);
const columnOrder = viewColumnOrder[activeView] || viewColumnOrder.all; const columnOrder = viewColumnOrder[activeView] || viewColumnOrder.all;
// Handle column visibility changes // Handle column visibility changes
const handleColumnVisibilityChange = (column: ProductMetricColumnKey, isVisible: boolean) => { const handleColumnVisibilityChange = useCallback((column: ProductMetricColumnKey, isVisible: boolean) => {
setViewColumns(prev => ({ setViewColumns(prev => ({
...prev, ...prev,
[activeView]: isVisible [activeView]: isVisible
? new Set([...prev[activeView], column]) ? new Set([...prev[activeView], column])
: new Set([...prev[activeView]].filter(col => col !== column)) : new Set([...prev[activeView]].filter(col => col !== column))
})); }));
}; }, [activeView]);
// Handle column order changes // Handle column order changes (newVisibleOrder only contains visible columns from drag reorder)
const handleColumnOrderChange = (newOrder: ProductMetricColumnKey[]) => { const handleColumnOrderChange = (newVisibleOrder: ProductMetricColumnKey[]) => {
setViewColumnOrder(prev => ({ setViewColumnOrder(prev => {
const oldFullOrder = prev[activeView] || [];
// Append hidden columns (preserving their relative order) after the reordered visible columns
const hiddenColumns = oldFullOrder.filter(col => !visibleColumns.has(col));
return {
...prev, ...prev,
[activeView]: newOrder [activeView]: [...newVisibleOrder, ...hiddenColumns]
})); };
});
}; };
// Reset columns to default for current view // Reset columns to default for current view
const resetColumnsToDefault = () => { const resetColumnsToDefault = useCallback(() => {
setViewColumns(prev => ({ setViewColumns(prev => ({
...prev, ...prev,
[activeView]: new Set(VIEW_COLUMNS[activeView] || VIEW_COLUMNS.all) [activeView]: new Set(VIEW_COLUMNS[activeView] || VIEW_COLUMNS.all)
@@ -423,45 +237,25 @@ export function Products() {
.filter(key => !(VIEW_COLUMNS[activeView] || VIEW_COLUMNS.all).includes(key)) .filter(key => !(VIEW_COLUMNS[activeView] || VIEW_COLUMNS.all).includes(key))
] ]
})); }));
}; }, [activeView]);
// Function to fetch products data // Merge search query into filters for the API call
const transformFilters = (filters: Record<string, any>) => { const effectiveFilters = useMemo(() => {
const transformedFilters: Record<string, any> = {}; const merged = { ...filters };
if (debouncedSearch.trim()) {
Object.entries(filters).forEach(([key, value]) => { merged['title'] = { value: debouncedSearch.trim(), operator: 'contains' as const };
if (typeof value === 'object' && 'operator' in value) {
// Convert the operator format to match what the backend expects
// Backend expects keys like "sales30d_gt" instead of separate operator parameters
const operatorSuffix = value.operator === '=' ? 'eq' :
value.operator === '>' ? 'gt' :
value.operator === '>=' ? 'gte' :
value.operator === '<' ? 'lt' :
value.operator === '<=' ? 'lte' :
value.operator === 'between' ? 'between' : 'eq';
// Create a key with the correct suffix format: key_operator
transformedFilters[`${key}_${operatorSuffix}`] = value.value;
} else {
// Simple values are passed as-is
transformedFilters[key] = value;
} }
}); return merged;
}, [filters, debouncedSearch]);
return transformedFilters; // Fetch products data with stable callback reference
}; const fetchProducts = useCallback(async () => {
const fetchProducts = async () => {
setIsLoading(true);
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('page', currentPage.toString()); params.append('page', currentPage.toString());
params.append('limit', pageSize.toString()); params.append('limit', pageSize.toString());
if (sortColumn) { if (sortColumn) {
// Don't convert camelCase to snake_case - use the column name directly
// as defined in the backend's COLUMN_MAP
console.log(`Sorting: ${sortColumn} (${sortDirection})`);
params.append('sort', sortColumn); params.append('sort', sortColumn);
params.append('order', sortDirection); params.append('order', sortDirection);
} }
@@ -472,89 +266,37 @@ export function Products() {
activeView === 'overstocked' ? 'Overstock' : activeView === 'overstocked' ? 'Overstock' :
activeView === 'new' ? 'New' : activeView === 'new' ? 'New' :
activeView.charAt(0).toUpperCase() + activeView.slice(1); activeView.charAt(0).toUpperCase() + activeView.slice(1);
console.log(`View: ${activeView} → Stock Status: ${stockStatus}`);
params.append('stock_status', stockStatus); params.append('stock_status', stockStatus);
} }
// Transform filters to match API expectations // Transform filters to match API expectations
const transformedFilters = transformFilters(filters); const transformedFilters = transformFilters(effectiveFilters);
Object.entries(transformedFilters).forEach(([key, value]) => { Object.entries(transformedFilters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') { if (value !== undefined && value !== null && value !== '') {
// Don't convert camelCase to snake_case - use the filter name directly
if (Array.isArray(value)) { if (Array.isArray(value)) {
params.append(key, JSON.stringify(value)); params.append(key, value.join(','));
} else { } else {
params.append(key, value.toString()); params.append(key, value.toString());
} }
} }
}); });
if (!showNonReplenishable) { if (showNonReplenishable) {
params.append('showNonReplenishable', 'false'); params.append('showNonReplenishable', 'true');
} }
// Log the final query parameters for debugging if (showInvisible) {
console.log('API Query:', params.toString()); params.append('showInvisible', 'true');
}
const response = await fetch(`/api/metrics?${params.toString()}`); const response = await fetch(`/api/metrics?${params.toString()}`, {credentials: 'include'});
if (!response.ok) throw new Error('Failed to fetch products'); if (!response.ok) throw new Error('Failed to fetch products');
const data = await response.json(); const data = await response.json();
// Transform snake_case keys to camelCase and convert string numbers to actual numbers // Use shared transform utility instead of inline conversion
const transformedProducts = data.metrics?.map((product: any) => { const transformedProducts = data.metrics?.map((product: any) => transformMetricsRow(product)) || [];
const transformed: any = {};
// Process all keys to convert from snake_case to camelCase
Object.entries(product).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, but handle empty strings properly
if (typeof value === 'string' && value !== '' && !isNaN(Number(value)) &&
!key.toLowerCase().includes('date') && key !== 'sku' && key !== 'title' &&
key !== 'brand' && key !== 'vendor') {
transformed[camelKey] = Number(value);
} else {
transformed[camelKey] = value;
}
});
// Ensure pid is a number
transformed.pid = typeof transformed.pid === 'string' ?
parseInt(transformed.pid, 10) : transformed.pid;
return transformed;
}) || [];
// Debug: Log the first item to check field mapping
if (transformedProducts.length > 0) {
console.log('Sample product after transformation:');
console.log('sales7d:', transformedProducts[0].sales7d);
console.log('sales30d:', transformedProducts[0].sales30d);
console.log('revenue30d:', transformedProducts[0].revenue30d);
console.log('margin30d:', transformedProducts[0].margin30d);
console.log('markup30d:', transformedProducts[0].markup30d);
// Debug specific fields with issues
console.log('configSafetyStock:', transformedProducts[0].configSafetyStock);
console.log('length:', transformedProducts[0].length);
console.log('width:', transformedProducts[0].width);
console.log('height:', transformedProducts[0].height);
console.log('first7DaysSales:', transformedProducts[0].first7DaysSales);
console.log('first30DaysSales:', transformedProducts[0].first30DaysSales);
console.log('first7DaysRevenue:', transformedProducts[0].first7DaysRevenue);
console.log('first30DaysRevenue:', transformedProducts[0].first30DaysRevenue);
}
// Transform the metrics response to match our expected format
return { return {
products: transformedProducts, products: transformedProducts,
pagination: data.pagination || { pagination: data.pagination || {
@@ -573,10 +315,8 @@ export function Products() {
variant: "destructive", variant: "destructive",
}); });
return null; return null;
} finally {
setIsLoading(false);
} }
}; }, [currentPage, pageSize, sortColumn, sortDirection, activeView, effectiveFilters, showNonReplenishable, showInvisible, toast]);
// Query for filter options // Query for filter options
const { data: filterOptionsData, isLoading: isLoadingFilterOptions } = useQuery({ const { data: filterOptionsData, isLoading: isLoadingFilterOptions } = useQuery({
@@ -601,13 +341,32 @@ export function Products() {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes staleTime: 5 * 60 * 1000, // Cache for 5 minutes
}); });
// Query for summary data (powers KPI cards and view counts)
const { data: summaryData } = useQuery({
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,
});
// Query for products data // Query for products data
const { data, isFetching } = useQuery({ const { data, isFetching } = useQuery({
queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, filters, showNonReplenishable], queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, effectiveFilters, showNonReplenishable, showInvisible],
queryFn: fetchProducts, queryFn: fetchProducts,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
// Reset page when debounced search changes
useEffect(() => {
setCurrentPage(1);
}, [debouncedSearch]);
// Update current page if it exceeds the total pages // Update current page if it exceeds the total pages
useEffect(() => { useEffect(() => {
if (data?.pagination.pages && currentPage > data.pagination.pages) { if (data?.pagination.pages && currentPage > data.pagination.pages) {
@@ -615,6 +374,18 @@ export function Products() {
} }
}, [currentPage, data?.pagination.pages]); }, [currentPage, data?.pagination.pages]);
// Global keyboard shortcut: "/" to focus search
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement).tagName)) {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Handle sort column change with improved column-specific direction memory // Handle sort column change with improved column-specific direction memory
const handleSort = (column: ProductMetricColumnKey) => { const handleSort = (column: ProductMetricColumnKey) => {
let nextDirection: 'asc' | 'desc'; let nextDirection: 'asc' | 'desc';
@@ -664,6 +435,7 @@ export function Products() {
const handleClearFilters = () => { const handleClearFilters = () => {
setFilters({}); setFilters({});
setSearchQuery('');
setCurrentPage(1); setCurrentPage(1);
}; };
@@ -673,89 +445,6 @@ export function Products() {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
// Group columns by their group property
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
if (!acc[col.group]) {
acc[col.group] = [];
}
acc[col.group].push(col);
return acc;
}, {} as Record<string, typeof AVAILABLE_COLUMNS>);
const renderColumnToggle = () => {
const [open, setOpen] = React.useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
<Settings2 className="mr-2 h-4 w-4" />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-[600px] max-h-[calc(100vh-16rem)] overflow-y-auto"
onCloseAutoFocus={(e) => e.preventDefault()}
onPointerDownOutside={(e) => {
// Only close if clicking outside the dropdown
if (!(e.target as HTMLElement).closest('[role="dialog"]')) {
setOpen(false);
}
}}
onInteractOutside={(e) => {
// Prevent closing when interacting with checkboxes
if ((e.target as HTMLElement).closest('[role="dialog"]')) {
e.preventDefault();
}
}}
>
<div className="sticky top-0 bg-background z-10 flex items-center justify-between">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
resetColumnsToDefault();
// Prevent closing by stopping propagation
e.stopPropagation();
}}
>
Reset to Default
</Button>
</div>
<DropdownMenuSeparator className="sticky top-[29px] bg-background z-10" />
<div style={{ columnCount: 3, columnGap: '2rem' }} className="p-2">
{Object.entries(columnsByGroup).map(([group, columns]) => (
<div key={group} style={{ breakInside: 'avoid' }} className="mb-4">
<DropdownMenuLabel className="text-xs font-semibold text-muted-foreground mb-2">
{group}
</DropdownMenuLabel>
<div className="flex flex-col gap-1">
{columns.map((column) => (
<DropdownMenuCheckboxItem
key={column.key}
className="capitalize"
checked={visibleColumns.has(column.key)}
onCheckedChange={(checked) => {
handleColumnVisibilityChange(column.key, checked);
}}
onSelect={(e) => {
e.preventDefault();
}}
>
{column.label}
</DropdownMenuCheckboxItem>
))}
</div>
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};
// Calculate pagination numbers // Calculate pagination numbers
const totalPages = data?.pagination.pages || 1; const totalPages = data?.pagination.pages || 1;
const showEllipsis = totalPages > 7; const showEllipsis = totalPages > 7;
@@ -767,10 +456,20 @@ export function Products() {
: [currentPage - 2, currentPage - 1, currentPage, currentPage + 1, currentPage + 2] : [currentPage - 2, currentPage - 1, currentPage, currentPage + 1, currentPage + 2]
: Array.from({ length: totalPages }, (_, i) => i + 1); : Array.from({ length: totalPages }, (_, i) => i + 1);
// Update URL when view changes // Update URL when view changes, reset filters and conditionally reset sort
const handleViewChange = (view: string) => { const handleViewChange = (view: string) => {
setActiveView(view); setActiveView(view);
setCurrentPage(1); setCurrentPage(1);
setFilters({}); // Clear filters when switching views to avoid confusion
setSearchQuery(''); // Clear search when switching views
// Reset sort if the current sort column isn't visible in the new view
const newViewColumns = VIEW_COLUMNS[view] || VIEW_COLUMNS.all;
if (!newViewColumns.includes(sortColumn)) {
setSortColumn('title');
setSortDirection('asc');
}
setSearchParams(prev => { setSearchParams(prev => {
const newParams = new URLSearchParams(prev); const newParams = new URLSearchParams(prev);
newParams.set('view', view); newParams.set('view', view);
@@ -784,6 +483,7 @@ export function Products() {
if (viewParam && viewParam !== activeView) { if (viewParam && viewParam !== activeView) {
setActiveView(viewParam); setActiveView(viewParam);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]); }, [searchParams]);
return ( return (
@@ -795,11 +495,37 @@ export function Products() {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Products</h1> <h1 className="text-3xl font-bold tracking-tight">Products</h1>
<div className="relative w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
type="text"
placeholder='Search products by name... (press "/")'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div> </div>
</div>
<ProductSummaryCards
activeView={activeView}
showNonReplenishable={showNonReplenishable}
showInvisible={showInvisible}
/>
<ProductViews <ProductViews
activeView={activeView} activeView={activeView}
onViewChange={handleViewChange} onViewChange={handleViewChange}
viewCounts={summaryData ?? null}
/> />
<div> <div>
@@ -827,12 +553,27 @@ export function Products() {
/> />
<Label htmlFor="show-non-replenishable">Show Non-Replenishable</Label> <Label htmlFor="show-non-replenishable">Show Non-Replenishable</Label>
</div> </div>
<div className="flex items-center space-x-2">
<Switch
id="show-invisible"
checked={showInvisible}
onCheckedChange={(checked) => {
setShowInvisible(checked);
setCurrentPage(1);
}}
/>
<Label htmlFor="show-invisible">Show Invisible</Label>
</div>
{data?.pagination?.total !== undefined && ( {data?.pagination?.total !== undefined && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{data.pagination.total.toLocaleString()} products {data.pagination.total.toLocaleString()} products
</div> </div>
)} )}
{renderColumnToggle()} <ColumnToggle
visibleColumns={visibleColumns}
onColumnVisibilityChange={handleColumnVisibilityChange}
onResetToDefault={resetColumnsToDefault}
/>
</div> </div>
</div> </div>
@@ -843,8 +584,7 @@ export function Products() {
<ProductTable <ProductTable
products={data?.products?.map((product: ProductMetric) => ({ products={data?.products?.map((product: ProductMetric) => ({
...product, ...product,
// No need to calculate status anymore since it comes from the backend status: product.status || 'Unknown'
status: product.status || 'Healthy' // Fallback only if status is null
})) || []} })) || []}
onSort={handleSort} onSort={handleSort}
sortColumn={sortColumn} sortColumn={sortColumn}

View File

@@ -92,6 +92,7 @@ export interface ProductMetric {
imageUrl: string | null; imageUrl: string | null;
isVisible: boolean; isVisible: boolean;
isReplenishable: boolean; isReplenishable: boolean;
lastCalculated: string | null;
// Additional Product Fields // Additional Product Fields
barcode: string | null; barcode: string | null;
@@ -334,11 +335,6 @@ export type ProductMetricColumnKey =
| 'configDaysOfStock' | 'configDaysOfStock'
| 'poCoverInDays' | 'poCoverInDays'
| 'toOrderUnits' | 'toOrderUnits'
| 'costPrice'
| 'valueAtCost'
| 'profit'
| 'margin'
| 'targetPrice'
| 'replenishmentCost' | 'replenishmentCost'
| 'replenishmentRetail' | 'replenishmentRetail'
| 'replenishmentProfit' | 'replenishmentProfit'
@@ -347,20 +343,13 @@ export type ProductMetricColumnKey =
| 'sales14d' | 'sales14d'
| 'revenue14d' | 'revenue14d'
| 'sales30d' | 'sales30d'
| 'units30d'
| 'revenue30d' | 'revenue30d'
| 'sales365d' | 'sales365d'
| 'revenue365d' | 'revenue365d'
| 'avgSalePrice30d'
| 'avgDailySales30d'
| 'avgDailyRevenue30d'
| 'stockturnRate30d'
| 'margin30d' | 'margin30d'
| 'markup30d' | 'markup30d'
| 'cogs30d' | 'cogs30d'
| 'profit30d' | 'profit30d'
| 'roas30d'
| 'adSpend30d'
| 'gmroi30d' | 'gmroi30d'
| 'stockturn30d' | 'stockturn30d'
| 'sellThrough30d' | 'sellThrough30d'
@@ -421,100 +410,14 @@ export type ProductMetricColumnKey =
| 'peakSeason' | 'peakSeason'
| 'imageUrl'; | '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; export type ActiveFilterValue = FilterValue | FilterValueWithOperator;
interface FilterValueWithOperator { export interface FilterValueWithOperator {
value: FilterValue | string[] | number[]; value: FilterValue | string[] | number[];
operator: ComparisonOperator; operator: ComparisonOperator;
} }
type FilterValue = string | number | boolean; export type FilterValue = string | number | boolean;
export type ComparisonOperator = export type ComparisonOperator =
| "=" | "!=" | ">" | ">=" | "<" | "<=" | "between" | "=" | "!=" | ">" | ">=" | "<" | "<=" | "between"

View File

@@ -1,93 +1,3 @@
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 => { export const formatCurrency = (value: number | null | undefined, digits = 2): string => {
if (value == null) return 'N/A'; if (value == null) return 'N/A';

View 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