Compare commits
5 Commits
e4f5e2c4dd
...
4b2b3d5a9f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b2b3d5a9f | |||
| e43abdafd0 | |||
| 54f8cc2706 | |||
| b95bd4a4a0 | |||
| 407731e17d |
@@ -163,6 +163,148 @@ router.get('/forecast', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Enhanced forecast endpoint: returns flat product list with line/artist/category/metrics
|
||||
// for client-side grouping by line or category
|
||||
router.get('/forecast-v2', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
const brand = (req.query.brand || '').toString();
|
||||
const startDateStr = req.query.startDate;
|
||||
const endDateStr = req.query.endDate;
|
||||
|
||||
if (!brand) {
|
||||
return res.status(400).json({ error: 'Missing required parameter: brand' });
|
||||
}
|
||||
|
||||
const endDate = endDateStr ? new Date(endDateStr) : new Date();
|
||||
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
|
||||
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
|
||||
|
||||
const sql = `
|
||||
WITH params AS (
|
||||
SELECT $1::date AS start_date, $2::date AS end_date, $3::text AS brand
|
||||
),
|
||||
category_path AS (
|
||||
WITH RECURSIVE cp AS (
|
||||
SELECT c.cat_id, c.name, c.parent_id, c.name::text AS path
|
||||
FROM categories c WHERE c.parent_id IS NULL
|
||||
UNION ALL
|
||||
SELECT c.cat_id, c.name, c.parent_id, (cp.path || ' > ' || c.name)::text
|
||||
FROM categories c JOIN cp ON c.parent_id = cp.cat_id
|
||||
)
|
||||
SELECT * FROM cp
|
||||
),
|
||||
product_first_received AS (
|
||||
SELECT
|
||||
p.pid,
|
||||
COALESCE(p.first_received::date, MIN(r.received_date)::date) AS first_received_date
|
||||
FROM products p
|
||||
LEFT JOIN receivings r ON r.pid = p.pid
|
||||
GROUP BY p.pid, p.first_received
|
||||
),
|
||||
recent_products AS (
|
||||
SELECT p.pid
|
||||
FROM products p
|
||||
JOIN product_first_received fr ON fr.pid = p.pid
|
||||
JOIN params pr ON 1=1
|
||||
WHERE p.visible = true
|
||||
AND COALESCE(p.brand,'Unbranded') = pr.brand
|
||||
AND fr.first_received_date BETWEEN pr.start_date AND pr.end_date
|
||||
),
|
||||
product_pick_category AS (
|
||||
(
|
||||
SELECT DISTINCT ON (pc.pid)
|
||||
pc.pid,
|
||||
c.name AS category_name,
|
||||
COALESCE(cp.path, c.name) AS path
|
||||
FROM product_categories pc
|
||||
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
|
||||
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
|
||||
WHERE pc.pid IN (SELECT pid FROM recent_products)
|
||||
AND (cp.path IS NULL OR (
|
||||
cp.path NOT ILIKE '%Black Friday%'
|
||||
AND cp.path NOT ILIKE '%Deals%'
|
||||
))
|
||||
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
|
||||
ORDER BY pc.pid, length(COALESCE(cp.path,'')) DESC
|
||||
)
|
||||
UNION ALL
|
||||
(
|
||||
SELECT rp.pid, 'Uncategorized'::text, 'Uncategorized'::text
|
||||
FROM recent_products rp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM product_categories pc
|
||||
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
|
||||
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
|
||||
WHERE pc.pid = rp.pid
|
||||
AND (cp.path IS NULL OR (
|
||||
cp.path NOT ILIKE '%Black Friday%'
|
||||
AND cp.path NOT ILIKE '%Deals%'
|
||||
))
|
||||
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
|
||||
)
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
p.pid, p.title, p.sku,
|
||||
COALESCE(p.line, '') AS line,
|
||||
COALESCE(p.artist, '') AS artist,
|
||||
ppc.category_name,
|
||||
ppc.path AS category_path,
|
||||
COALESCE(pm.lifetime_sales, 0) AS lifetime_sales,
|
||||
COALESCE(pm.first_30_days_sales, 0) AS first_30d_sales,
|
||||
COALESCE(pm.first_60_days_sales, 0) AS first_60d_sales,
|
||||
COALESCE(pm.first_90_days_sales, 0) AS first_90d_sales,
|
||||
COALESCE(pm.current_stock, 0) AS current_stock,
|
||||
COALESCE(pm.date_first_received, pfr.first_received_date)::date AS date_first_received,
|
||||
COALESCE(p.stock_quantity, 0) AS stock_quantity,
|
||||
COALESCE(p.price, 0) AS price
|
||||
FROM recent_products rp
|
||||
JOIN products p ON p.pid = rp.pid
|
||||
JOIN product_pick_category ppc ON ppc.pid = p.pid
|
||||
LEFT JOIN product_metrics pm ON pm.pid = p.pid
|
||||
LEFT JOIN product_first_received pfr ON pfr.pid = p.pid
|
||||
ORDER BY COALESCE(p.line, ''), ppc.category_name, pm.lifetime_sales DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(sql, [startISO, endISO, brand]);
|
||||
|
||||
// Collect distinct artists
|
||||
const artistSet = new Set();
|
||||
const products = rows.map(r => {
|
||||
if (r.artist) artistSet.add(r.artist);
|
||||
return {
|
||||
pid: String(r.pid),
|
||||
title: r.title,
|
||||
sku: r.sku,
|
||||
line: r.line || '',
|
||||
artist: r.artist || '',
|
||||
category: r.category_name,
|
||||
categoryPath: r.category_path,
|
||||
lifetimeSales: Number(r.lifetime_sales) || 0,
|
||||
first30dSales: Number(r.first_30d_sales) || 0,
|
||||
first60dSales: Number(r.first_60d_sales) || 0,
|
||||
first90dSales: Number(r.first_90d_sales) || 0,
|
||||
currentStock: Number(r.current_stock) || 0,
|
||||
dateFirstReceived: r.date_first_received || null,
|
||||
price: Number(r.price) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
products,
|
||||
artists: [...artistSet].sort(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching forecast-v2 data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch forecast data' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Inventory Intelligence Endpoints ────────────────────────────────────────
|
||||
|
||||
// Inventory KPI summary cards
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { parseValue } = require('../utils/apiHelpers');
|
||||
|
||||
// --- Configuration & Helpers ---
|
||||
const DEFAULT_PAGE_LIMIT = 50;
|
||||
const MAX_PAGE_LIMIT = 200;
|
||||
|
||||
// Base aggregation query that produces line-level metrics from product_metrics
|
||||
// Filters to lines with stock or sales in the past year
|
||||
const LINE_AGGREGATE_SQL = `
|
||||
SELECT
|
||||
pm.brand,
|
||||
pm.line,
|
||||
COUNT(*) AS product_count,
|
||||
COUNT(CASE WHEN pm.is_visible THEN 1 END) AS active_product_count,
|
||||
COUNT(CASE WHEN pm.is_replenishable THEN 1 END) AS replenishable_product_count,
|
||||
-- Stock
|
||||
SUM(COALESCE(pm.current_stock, 0)) AS current_stock_units,
|
||||
SUM(COALESCE(pm.current_stock_cost, 0)) AS current_stock_cost,
|
||||
SUM(COALESCE(pm.current_stock_retail, 0)) AS current_stock_retail,
|
||||
SUM(COALESCE(pm.on_order_qty, 0)) AS on_order_qty,
|
||||
SUM(COALESCE(pm.on_order_cost, 0)) AS on_order_cost,
|
||||
-- Sales periods
|
||||
SUM(COALESCE(pm.sales_7d, 0)) AS sales_7d,
|
||||
SUM(COALESCE(pm.revenue_7d, 0)) AS revenue_7d,
|
||||
SUM(COALESCE(pm.sales_30d, 0)) AS sales_30d,
|
||||
SUM(COALESCE(pm.revenue_30d, 0)) AS revenue_30d,
|
||||
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||
SUM(COALESCE(pm.sales_365d, 0)) AS sales_365d,
|
||||
SUM(COALESCE(pm.revenue_365d, 0)) AS revenue_365d,
|
||||
SUM(COALESCE(pm.lifetime_sales, 0)) AS lifetime_sales,
|
||||
SUM(COALESCE(pm.lifetime_revenue, 0)) AS lifetime_revenue,
|
||||
-- Weighted average margin (by revenue)
|
||||
CASE WHEN SUM(pm.revenue_30d) > 0
|
||||
THEN SUM(pm.profit_30d) * 100.0 / SUM(pm.revenue_30d)
|
||||
ELSE NULL
|
||||
END AS avg_margin_30d,
|
||||
-- Velocity & coverage
|
||||
SUM(COALESCE(pm.sales_velocity_daily, 0)) AS total_velocity_daily,
|
||||
AVG(CASE WHEN pm.current_stock > 0 AND pm.sales_velocity_daily > 0
|
||||
THEN pm.stock_cover_in_days ELSE NULL END) AS avg_stock_cover_days,
|
||||
AVG(CASE WHEN pm.sells_out_in_days IS NOT NULL AND pm.current_stock > 0
|
||||
THEN pm.sells_out_in_days ELSE NULL END) AS avg_sells_out_in_days,
|
||||
-- Stockouts & replenishment
|
||||
SUM(COALESCE(pm.stockout_days_30d, 0)) AS total_stockout_days_30d,
|
||||
SUM(COALESCE(pm.replenishment_units, 0)) AS total_replenishment_units,
|
||||
SUM(COALESCE(pm.replenishment_cost, 0)) AS total_replenishment_cost,
|
||||
-- Lifecycle distribution (counts per phase)
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'launch' THEN 1 END) AS phase_launch,
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'mature' THEN 1 END) AS phase_mature,
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'slow_mover' THEN 1 END) AS phase_slow_mover,
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'decay' THEN 1 END) AS phase_decay,
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'dormant' THEN 1 END) AS phase_dormant,
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'preorder' THEN 1 END) AS phase_preorder,
|
||||
-- Status distribution (exclude preorder-phase products — they get their own segment)
|
||||
COUNT(CASE WHEN pm.status = 'Healthy' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_healthy,
|
||||
COUNT(CASE WHEN pm.status = 'Reorder Soon' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_reorder,
|
||||
COUNT(CASE WHEN pm.status = 'Critical' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_critical,
|
||||
COUNT(CASE WHEN pm.status = 'Overstock' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_overstock,
|
||||
COUNT(CASE WHEN pm.status = 'At Risk' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_at_risk,
|
||||
COUNT(CASE WHEN pm.status = 'New' AND COALESCE(pm.lifecycle_phase, '') != 'preorder' THEN 1 END) AS status_new,
|
||||
-- ABC class distribution
|
||||
COUNT(CASE WHEN pm.abc_class = 'A' THEN 1 END) AS abc_a_count,
|
||||
COUNT(CASE WHEN pm.abc_class = 'B' THEN 1 END) AS abc_b_count,
|
||||
COUNT(CASE WHEN pm.abc_class = 'C' THEN 1 END) AS abc_c_count,
|
||||
-- Date range
|
||||
MIN(pm.date_first_received) AS earliest_received,
|
||||
MAX(pm.date_last_sold) AS latest_sale,
|
||||
-- Growth (weighted by revenue)
|
||||
CASE WHEN SUM(CASE WHEN pm.sales_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_30d ELSE 0 END) > 0
|
||||
THEN SUM(CASE WHEN pm.sales_growth_30d_vs_prev IS NOT NULL THEN pm.sales_growth_30d_vs_prev * pm.revenue_30d ELSE 0 END)
|
||||
/ SUM(CASE WHEN pm.sales_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_30d ELSE 0 END)
|
||||
ELSE NULL
|
||||
END AS sales_growth_30d_vs_prev,
|
||||
CASE WHEN SUM(CASE WHEN pm.revenue_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_30d ELSE 0 END) > 0
|
||||
THEN SUM(CASE WHEN pm.revenue_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_growth_30d_vs_prev * pm.revenue_30d ELSE 0 END)
|
||||
/ SUM(CASE WHEN pm.revenue_growth_30d_vs_prev IS NOT NULL THEN pm.revenue_30d ELSE 0 END)
|
||||
ELSE NULL
|
||||
END AS revenue_growth_30d_vs_prev,
|
||||
-- Derived line status
|
||||
CASE
|
||||
WHEN COUNT(CASE WHEN pm.lifecycle_phase = 'preorder' THEN 1 END) > COUNT(*) * 0.5 THEN 'preorder'
|
||||
WHEN SUM(CASE WHEN pm.current_stock > 0 THEN 1 ELSE 0 END) = 0 THEN 'out_of_stock'
|
||||
WHEN SUM(pm.sales_30d) > 0 THEN 'active'
|
||||
WHEN SUM(pm.sales_365d) > 0 THEN 'slow'
|
||||
ELSE 'dormant'
|
||||
END AS line_status,
|
||||
-- Dominant lifecycle phase
|
||||
(ARRAY['launch','mature','slow_mover','decay','dormant','preorder'])[
|
||||
(SELECT i FROM unnest(ARRAY[
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'launch' THEN 1 END),
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'mature' THEN 1 END),
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'slow_mover' THEN 1 END),
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'decay' THEN 1 END),
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'dormant' THEN 1 END),
|
||||
COUNT(CASE WHEN pm.lifecycle_phase = 'preorder' THEN 1 END)
|
||||
]) WITH ORDINALITY AS t(cnt, i)
|
||||
ORDER BY cnt DESC LIMIT 1)
|
||||
] AS dominant_lifecycle_phase
|
||||
FROM product_metrics pm
|
||||
WHERE pm.line IS NOT NULL AND pm.line != ''
|
||||
AND (pm.current_stock > 0 OR pm.sales_365d > 0)
|
||||
GROUP BY pm.brand, pm.line
|
||||
`;
|
||||
|
||||
// Column map for sorting/filtering on the aggregated result
|
||||
const COLUMN_MAP = {
|
||||
brand: { dbCol: 'agg.brand', type: 'string' },
|
||||
line: { dbCol: 'agg.line', type: 'string' },
|
||||
productCount: { dbCol: 'agg.product_count', type: 'number' },
|
||||
activeProductCount: { dbCol: 'agg.active_product_count', type: 'number' },
|
||||
currentStockUnits: { dbCol: 'agg.current_stock_units', type: 'number' },
|
||||
currentStockCost: { dbCol: 'agg.current_stock_cost', type: 'number' },
|
||||
currentStockRetail: { dbCol: 'agg.current_stock_retail', type: 'number' },
|
||||
onOrderQty: { dbCol: 'agg.on_order_qty', type: 'number' },
|
||||
sales7d: { dbCol: 'agg.sales_7d', type: 'number' },
|
||||
revenue7d: { dbCol: 'agg.revenue_7d', type: 'number' },
|
||||
sales30d: { dbCol: 'agg.sales_30d', type: 'number' },
|
||||
revenue30d: { dbCol: 'agg.revenue_30d', type: 'number' },
|
||||
profit30d: { dbCol: 'agg.profit_30d', type: 'number' },
|
||||
sales365d: { dbCol: 'agg.sales_365d', type: 'number' },
|
||||
revenue365d: { dbCol: 'agg.revenue_365d', type: 'number' },
|
||||
lifetimeSales: { dbCol: 'agg.lifetime_sales', type: 'number' },
|
||||
lifetimeRevenue: { dbCol: 'agg.lifetime_revenue', type: 'number' },
|
||||
avgMargin30d: { dbCol: 'agg.avg_margin_30d', type: 'number' },
|
||||
totalVelocityDaily: { dbCol: 'agg.total_velocity_daily', type: 'number' },
|
||||
avgStockCoverDays: { dbCol: 'agg.avg_stock_cover_days', type: 'number' },
|
||||
avgSellsOutInDays: { dbCol: 'agg.avg_sells_out_in_days', type: 'number' },
|
||||
salesGrowth30dVsPrev: { dbCol: 'agg.sales_growth_30d_vs_prev', type: 'number' },
|
||||
revenueGrowth30dVsPrev: { dbCol: 'agg.revenue_growth_30d_vs_prev', type: 'number' },
|
||||
lineStatus: { dbCol: 'agg.line_status', type: 'string' },
|
||||
dominantLifecyclePhase: { dbCol: 'agg.dominant_lifecycle_phase', type: 'string' },
|
||||
name: { dbCol: 'agg.line', type: 'string' },
|
||||
};
|
||||
|
||||
function getSafeColumnInfo(queryParamKey) {
|
||||
return COLUMN_MAP[queryParamKey] || null;
|
||||
}
|
||||
|
||||
// --- Route Handlers ---
|
||||
|
||||
// GET /lines-aggregate/filter-options
|
||||
router.get('/filter-options', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
try {
|
||||
const { rows: brandRows } = await pool.query(`
|
||||
SELECT DISTINCT pm.brand
|
||||
FROM product_metrics pm
|
||||
WHERE pm.line IS NOT NULL AND pm.line != ''
|
||||
AND (pm.current_stock > 0 OR pm.sales_365d > 0)
|
||||
AND pm.brand IS NOT NULL AND pm.brand != ''
|
||||
ORDER BY pm.brand
|
||||
`);
|
||||
|
||||
const statuses = ['active', 'preorder', 'slow', 'out_of_stock', 'dormant'];
|
||||
const phases = ['launch', 'mature', 'slow_mover', 'decay', 'dormant', 'preorder'];
|
||||
|
||||
res.json({
|
||||
brands: brandRows.map(r => r.brand),
|
||||
statuses,
|
||||
phases,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching line filter options:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch filter options' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /lines-aggregate/stats
|
||||
router.get('/stats', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
try {
|
||||
const { rows: [stats] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) AS total_lines,
|
||||
COUNT(DISTINCT brand) AS brand_count,
|
||||
COUNT(CASE WHEN line_status = 'active' THEN 1 END) AS active_lines,
|
||||
COUNT(CASE WHEN line_status = 'out_of_stock' THEN 1 END) AS oos_lines,
|
||||
COUNT(CASE WHEN line_status = 'active' AND replenishable_product_count > 0 AND status_reorder > replenishable_product_count * 0.2 THEN 1 END) AS lines_needing_restock,
|
||||
ROUND(AVG(product_count), 1) AS avg_products_per_line,
|
||||
SUM(revenue_30d) AS total_revenue_30d,
|
||||
CASE WHEN SUM(revenue_30d) > 0
|
||||
THEN SUM(profit_30d) * 100.0 / SUM(revenue_30d)
|
||||
ELSE 0
|
||||
END AS overall_avg_margin
|
||||
FROM (${LINE_AGGREGATE_SQL}) agg
|
||||
`);
|
||||
|
||||
res.json({
|
||||
totalLines: parseInt(stats?.total_lines || 0),
|
||||
brandCount: parseInt(stats?.brand_count || 0),
|
||||
activeLines: parseInt(stats?.active_lines || 0),
|
||||
oosLines: parseInt(stats?.oos_lines || 0),
|
||||
linesNeedingRestock: parseInt(stats?.lines_needing_restock || 0),
|
||||
avgProductsPerLine: parseFloat(stats?.avg_products_per_line || 0),
|
||||
totalRevenue30d: parseFloat(stats?.total_revenue_30d || 0),
|
||||
avgMargin: parseFloat(stats?.overall_avg_margin || 0),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching line stats:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch line stats.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /lines-aggregate/ (List product lines)
|
||||
router.get('/', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
try {
|
||||
// --- Pagination ---
|
||||
let page = parseInt(req.query.page, 10) || 1;
|
||||
let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT;
|
||||
limit = Math.min(limit, MAX_PAGE_LIMIT);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// --- Sorting ---
|
||||
// Sort presets for curated multi-column sorts
|
||||
const SORT_PRESETS = {
|
||||
'by-brand': 'ORDER BY agg.brand ASC, agg.revenue_30d DESC NULLS LAST',
|
||||
'top-revenue': 'ORDER BY agg.revenue_30d DESC NULLS LAST',
|
||||
'needs-restock': 'ORDER BY agg.total_replenishment_units DESC NULLS LAST, agg.avg_stock_cover_days ASC NULLS FIRST',
|
||||
'fastest-growing': 'ORDER BY agg.sales_growth_30d_vs_prev DESC NULLS LAST',
|
||||
'newest': 'ORDER BY agg.earliest_received DESC NULLS LAST',
|
||||
'low-stock': 'ORDER BY CASE WHEN agg.sales_30d > 0 THEN 0 ELSE 1 END, agg.avg_stock_cover_days ASC NULLS LAST',
|
||||
};
|
||||
|
||||
let sortClause;
|
||||
const sortQueryKey = req.query.sort || 'by-brand';
|
||||
if (SORT_PRESETS[sortQueryKey]) {
|
||||
sortClause = SORT_PRESETS[sortQueryKey];
|
||||
} else {
|
||||
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
|
||||
const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'agg.brand';
|
||||
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST');
|
||||
sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`;
|
||||
}
|
||||
|
||||
// --- Filtering ---
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
let paramCounter = 1;
|
||||
|
||||
for (const key in req.query) {
|
||||
if (['page', 'limit', 'sort', 'order', 'preset'].includes(key)) continue;
|
||||
|
||||
let filterKey = key;
|
||||
let operator = '=';
|
||||
const value = req.query[key];
|
||||
|
||||
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
|
||||
if (operatorMatch) {
|
||||
filterKey = operatorMatch[1];
|
||||
operator = operatorMatch[2];
|
||||
}
|
||||
|
||||
const columnInfo = getSafeColumnInfo(filterKey);
|
||||
if (columnInfo) {
|
||||
const dbColumn = columnInfo.dbCol;
|
||||
const valueType = columnInfo.type;
|
||||
try {
|
||||
let conditionFragment = '';
|
||||
let needsParam = true;
|
||||
switch (operator.toLowerCase()) {
|
||||
case 'eq': operator = '='; break;
|
||||
case 'ne': operator = '<>'; break;
|
||||
case 'gt': operator = '>'; break;
|
||||
case 'gte': operator = '>='; break;
|
||||
case 'lt': operator = '<'; break;
|
||||
case 'lte': operator = '<='; break;
|
||||
case 'like': operator = 'LIKE'; needsParam = false; params.push(`%${parseValue(value, valueType)}%`); break;
|
||||
case 'ilike': operator = 'ILIKE'; needsParam = false; params.push(`%${parseValue(value, valueType)}%`); break;
|
||||
case 'between':
|
||||
const [val1, val2] = String(value).split(',');
|
||||
if (val1 !== undefined && val2 !== undefined) {
|
||||
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
|
||||
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
|
||||
needsParam = false;
|
||||
} else continue;
|
||||
break;
|
||||
case 'in':
|
||||
const inValues = String(value).split(',');
|
||||
if (inValues.length > 0) {
|
||||
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
|
||||
conditionFragment = `${dbColumn} IN (${placeholders})`;
|
||||
params.push(...inValues.map(v => parseValue(v, valueType)));
|
||||
needsParam = false;
|
||||
} else continue;
|
||||
break;
|
||||
default: operator = '='; break;
|
||||
}
|
||||
|
||||
if (needsParam) {
|
||||
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
|
||||
params.push(parseValue(value, valueType));
|
||||
} else if (!conditionFragment) {
|
||||
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
|
||||
}
|
||||
|
||||
if (conditionFragment) {
|
||||
conditions.push(`(${conditionFragment})`);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute Queries ---
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const baseSql = `FROM (${LINE_AGGREGATE_SQL}) agg ${whereClause}`;
|
||||
|
||||
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
|
||||
const dataSql = `SELECT agg.* ${baseSql} ${sortClause} LIMIT $${paramCounter} OFFSET $${paramCounter + 1}`;
|
||||
const dataParams = [...params, limit, offset];
|
||||
|
||||
const [countResult, dataResult] = await Promise.all([
|
||||
pool.query(countSql, params),
|
||||
pool.query(dataSql, dataParams)
|
||||
]);
|
||||
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
const lines = dataResult.rows.map(row => {
|
||||
const transformedRow = { ...row };
|
||||
for (const key in row) {
|
||||
if (row[key] === null || row[key] === undefined) continue;
|
||||
// snake_case -> camelCase
|
||||
let camelKey = key.replace(/_(\d+[a-z])/g, '$1');
|
||||
camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
if (camelKey !== key) {
|
||||
transformedRow[camelKey] = row[key];
|
||||
}
|
||||
}
|
||||
return transformedRow;
|
||||
});
|
||||
|
||||
res.json({
|
||||
lines,
|
||||
pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching line metrics list:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch line metrics.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /lines-aggregate/:brand/:line/products (All products in a line)
|
||||
router.get('/:brand/:line/products', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
const { brand, line } = req.params;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
pm.pid, pm.title, pm.sku,
|
||||
pm.current_stock, pm.current_stock_retail,
|
||||
pm.sales_30d, pm.revenue_30d, pm.profit_30d, pm.margin_30d,
|
||||
pm.sales_365d, pm.revenue_365d,
|
||||
pm.sales_velocity_daily, pm.stock_cover_in_days,
|
||||
pm.lifecycle_phase, pm.status, pm.abc_class,
|
||||
pm.on_order_qty, pm.replenishment_units,
|
||||
pm.date_first_received, pm.date_last_sold
|
||||
FROM product_metrics pm
|
||||
WHERE pm.brand = $1 AND pm.line = $2
|
||||
AND (pm.current_stock > 0 OR pm.sales_365d > 0)
|
||||
ORDER BY pm.revenue_30d DESC NULLS LAST
|
||||
`, [brand, line]);
|
||||
|
||||
res.json({
|
||||
totalProducts: rows.length,
|
||||
products: rows,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching line products:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch line products.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -134,11 +134,10 @@ router.post('/', async (req, res) => {
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating template:', error);
|
||||
// Check for unique constraint violation
|
||||
if (error instanceof Error && error.message.includes('unique constraint')) {
|
||||
// Check for unique constraint violation (PostgreSQL error code 23505)
|
||||
if (error?.code === '23505') {
|
||||
return res.status(409).json({
|
||||
error: 'Template already exists for this company and product type',
|
||||
details: error.message
|
||||
error: 'A template already exists for this company and product type combination. Please edit the existing template instead.',
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
@@ -232,11 +231,10 @@ router.put('/:id', async (req, res) => {
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating template:', error);
|
||||
// Check for unique constraint violation
|
||||
if (error instanceof Error && error.message.includes('unique constraint')) {
|
||||
// Check for unique constraint violation (PostgreSQL error code 23505)
|
||||
if (error?.code === '23505') {
|
||||
return res.status(409).json({
|
||||
error: 'Template already exists for this company and product type',
|
||||
details: error.message
|
||||
error: 'A template already exists for this company and product type combination. Please edit the existing template instead.',
|
||||
});
|
||||
}
|
||||
res.status(500).json({
|
||||
|
||||
@@ -27,6 +27,7 @@ const importSessionsRouter = require('./routes/import-sessions');
|
||||
const importAuditLogRouter = require('./routes/import-audit-log');
|
||||
const productEditorAuditLogRouter = require('./routes/product-editor-audit-log');
|
||||
const newsletterRouter = require('./routes/newsletter');
|
||||
const linesAggregateRouter = require('./routes/linesAggregate');
|
||||
|
||||
// Get the absolute path to the .env file
|
||||
const envPath = '/var/www/html/inventory/.env';
|
||||
@@ -138,6 +139,7 @@ async function startServer() {
|
||||
app.use('/api/import-audit-log', importAuditLogRouter);
|
||||
app.use('/api/product-editor-audit-log', productEditorAuditLogRouter);
|
||||
app.use('/api/newsletter', newsletterRouter);
|
||||
app.use('/api/lines-aggregate', linesAggregateRouter);
|
||||
|
||||
// Basic health check route
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ const HtsLookup = lazy(() => import('./pages/HtsLookup'));
|
||||
const Vendors = lazy(() => import('./pages/Vendors'));
|
||||
const Categories = lazy(() => import('./pages/Categories'));
|
||||
const Brands = lazy(() => import('./pages/Brands'));
|
||||
const ProductLines = lazy(() => import('./pages/ProductLines'));
|
||||
const PurchaseOrders = lazy(() => import('./pages/PurchaseOrders'));
|
||||
const BlackFridayDashboard = lazy(() => import('./pages/BlackFridayDashboard'));
|
||||
const Newsletter = lazy(() => import('./pages/Newsletter'));
|
||||
@@ -149,6 +150,13 @@ function App() {
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/product-lines" element={
|
||||
<Protected page="product_lines">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<ProductLines />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/purchase-orders" element={
|
||||
<Protected page="purchase_orders">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
|
||||
@@ -3,40 +3,398 @@ import { ArrowUpDown, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface Product {
|
||||
// ─── Raw product from API ───────────────────────────────────────────────────
|
||||
|
||||
export interface ProductDetail {
|
||||
pid: string;
|
||||
sku: string;
|
||||
title: string;
|
||||
total_sold: number;
|
||||
}
|
||||
|
||||
export interface ForecastItem {
|
||||
sku: string;
|
||||
line: string;
|
||||
artist: string;
|
||||
category: string;
|
||||
categoryPath: string;
|
||||
totalSold: number;
|
||||
numProducts: number;
|
||||
avgTotalSold: number;
|
||||
minSold: number;
|
||||
maxSold: number;
|
||||
products?: Product[];
|
||||
lifetimeSales: number;
|
||||
first30dSales: number;
|
||||
first60dSales: number;
|
||||
first90dSales: number;
|
||||
currentStock: number;
|
||||
dateFirstReceived: string | null;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<ForecastItem>[] = [
|
||||
// ─── Grouped row types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface GroupStats {
|
||||
productCount: number;
|
||||
avgLifetimeSales: number;
|
||||
medianLifetimeSales: number;
|
||||
avgFirst30dSales: number;
|
||||
minSales: number;
|
||||
maxSales: number;
|
||||
totalSales: number;
|
||||
}
|
||||
|
||||
export interface CategoryBreakdown extends GroupStats {
|
||||
category: string;
|
||||
categoryPath: string;
|
||||
products: ProductDetail[];
|
||||
}
|
||||
|
||||
/** Line view: one row per collection */
|
||||
export interface LineGroup extends GroupStats {
|
||||
line: string;
|
||||
artist: string;
|
||||
dateFirstReceived: string | null;
|
||||
categories: CategoryBreakdown[];
|
||||
products: ProductDetail[];
|
||||
}
|
||||
|
||||
/** Category view: one row per product type (enhanced) */
|
||||
export interface CategoryGroup extends GroupStats {
|
||||
category: string;
|
||||
categoryPath: string;
|
||||
products: ProductDetail[];
|
||||
}
|
||||
|
||||
/** Designer view: one row per artist, with line + category breakdowns */
|
||||
export interface LineSummary extends GroupStats {
|
||||
line: string;
|
||||
dateFirstReceived: string | null;
|
||||
}
|
||||
|
||||
export interface DesignerGroup extends GroupStats {
|
||||
artist: string;
|
||||
lineCount: number;
|
||||
lines: LineSummary[];
|
||||
categories: CategoryBreakdown[];
|
||||
products: ProductDetail[];
|
||||
}
|
||||
|
||||
/** Cross-line average (one per category across all lines) */
|
||||
export interface CrossLineAverage {
|
||||
category: string;
|
||||
lineCount: number;
|
||||
avgProductCount: number;
|
||||
avgLifetimeSales: number;
|
||||
medianLifetimeSales: number;
|
||||
avgFirst30dSales: number;
|
||||
minAvgSales: number;
|
||||
maxAvgSales: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
export function computeGroupStats(products: ProductDetail[]): GroupStats {
|
||||
const sales = products.map(p => p.lifetimeSales);
|
||||
const total = sales.reduce((s, v) => s + v, 0);
|
||||
return {
|
||||
productCount: products.length,
|
||||
avgLifetimeSales: products.length > 0 ? total / products.length : 0,
|
||||
medianLifetimeSales: median(sales),
|
||||
avgFirst30dSales: products.length > 0
|
||||
? products.reduce((s, p) => s + p.first30dSales, 0) / products.length
|
||||
: 0,
|
||||
minSales: sales.length > 0 ? Math.min(...sales) : 0,
|
||||
maxSales: sales.length > 0 ? Math.max(...sales) : 0,
|
||||
totalSales: total,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Grouping logic ─────────────────────────────────────────────────────────
|
||||
|
||||
export function groupByLine(products: ProductDetail[]): LineGroup[] {
|
||||
const lineMap = new Map<string, ProductDetail[]>();
|
||||
for (const p of products) {
|
||||
const key = p.line || '(No Line)';
|
||||
if (!lineMap.has(key)) lineMap.set(key, []);
|
||||
lineMap.get(key)!.push(p);
|
||||
}
|
||||
|
||||
return [...lineMap.entries()].map(([line, prods]) => {
|
||||
// Build category breakdown within this line
|
||||
const catMap = new Map<string, ProductDetail[]>();
|
||||
for (const p of prods) {
|
||||
const key = p.category || 'Uncategorized';
|
||||
if (!catMap.has(key)) catMap.set(key, []);
|
||||
catMap.get(key)!.push(p);
|
||||
}
|
||||
const categories: CategoryBreakdown[] = [...catMap.entries()]
|
||||
.map(([cat, catProds]) => ({
|
||||
category: cat,
|
||||
categoryPath: catProds[0]?.categoryPath || '',
|
||||
...computeGroupStats(catProds),
|
||||
products: catProds,
|
||||
}))
|
||||
.sort((a, b) => b.avgLifetimeSales - a.avgLifetimeSales);
|
||||
|
||||
const stats = computeGroupStats(prods);
|
||||
const firstArtist = prods.find(p => p.artist)?.artist || '';
|
||||
const dates = prods
|
||||
.map(p => p.dateFirstReceived)
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
line,
|
||||
artist: firstArtist,
|
||||
dateFirstReceived: dates[0] || null,
|
||||
...stats,
|
||||
categories,
|
||||
products: prods,
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
// Sort by received date descending (newest first)
|
||||
if (a.dateFirstReceived && b.dateFirstReceived) {
|
||||
return b.dateFirstReceived.localeCompare(a.dateFirstReceived);
|
||||
}
|
||||
if (a.dateFirstReceived) return -1;
|
||||
if (b.dateFirstReceived) return 1;
|
||||
return b.avgLifetimeSales - a.avgLifetimeSales;
|
||||
});
|
||||
}
|
||||
|
||||
export function groupByCategory(products: ProductDetail[]): CategoryGroup[] {
|
||||
const catMap = new Map<string, ProductDetail[]>();
|
||||
for (const p of products) {
|
||||
const key = p.category || 'Uncategorized';
|
||||
if (!catMap.has(key)) catMap.set(key, []);
|
||||
catMap.get(key)!.push(p);
|
||||
}
|
||||
|
||||
return [...catMap.entries()]
|
||||
.map(([cat, prods]) => ({
|
||||
category: cat,
|
||||
categoryPath: prods[0]?.categoryPath || '',
|
||||
...computeGroupStats(prods),
|
||||
products: prods,
|
||||
}))
|
||||
.sort((a, b) => b.avgLifetimeSales - a.avgLifetimeSales);
|
||||
}
|
||||
|
||||
export function groupByDesigner(products: ProductDetail[]): DesignerGroup[] {
|
||||
const artistMap = new Map<string, ProductDetail[]>();
|
||||
for (const p of products) {
|
||||
const key = p.artist || '(Unknown Designer)';
|
||||
if (!artistMap.has(key)) artistMap.set(key, []);
|
||||
artistMap.get(key)!.push(p);
|
||||
}
|
||||
|
||||
return [...artistMap.entries()]
|
||||
.map(([artist, prods]) => {
|
||||
// Line breakdown within this designer
|
||||
const lineMap = new Map<string, ProductDetail[]>();
|
||||
for (const p of prods) {
|
||||
const key = p.line || '(No Line)';
|
||||
if (!lineMap.has(key)) lineMap.set(key, []);
|
||||
lineMap.get(key)!.push(p);
|
||||
}
|
||||
const lines: LineSummary[] = [...lineMap.entries()]
|
||||
.map(([line, lineProds]) => {
|
||||
const dates = lineProds.map(p => p.dateFirstReceived).filter(Boolean).sort();
|
||||
return {
|
||||
line,
|
||||
dateFirstReceived: dates[0] || null,
|
||||
...computeGroupStats(lineProds),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.dateFirstReceived && b.dateFirstReceived)
|
||||
return b.dateFirstReceived.localeCompare(a.dateFirstReceived);
|
||||
if (a.dateFirstReceived) return -1;
|
||||
if (b.dateFirstReceived) return 1;
|
||||
return b.avgLifetimeSales - a.avgLifetimeSales;
|
||||
});
|
||||
|
||||
// Category breakdown across all this designer's lines
|
||||
const catMap = new Map<string, ProductDetail[]>();
|
||||
for (const p of prods) {
|
||||
const key = p.category || 'Uncategorized';
|
||||
if (!catMap.has(key)) catMap.set(key, []);
|
||||
catMap.get(key)!.push(p);
|
||||
}
|
||||
const categories: CategoryBreakdown[] = [...catMap.entries()]
|
||||
.map(([cat, catProds]) => ({
|
||||
category: cat,
|
||||
categoryPath: catProds[0]?.categoryPath || '',
|
||||
...computeGroupStats(catProds),
|
||||
products: catProds,
|
||||
}))
|
||||
.sort((a, b) => b.avgLifetimeSales - a.avgLifetimeSales);
|
||||
|
||||
return {
|
||||
artist,
|
||||
lineCount: lines.filter(l => l.line !== '(No Line)').length,
|
||||
...computeGroupStats(prods),
|
||||
lines,
|
||||
categories,
|
||||
products: prods,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.avgLifetimeSales - a.avgLifetimeSales);
|
||||
}
|
||||
|
||||
export function computeCrossLineAverages(lines: LineGroup[]): CrossLineAverage[] {
|
||||
// For each category that appears across lines, compute the average of each line's average
|
||||
const catLineData = new Map<string, { avgSales: number[]; avgFirst30d: number[]; productCounts: number[] }>();
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.line === '(No Line)') continue;
|
||||
for (const cat of line.categories) {
|
||||
if (!catLineData.has(cat.category)) {
|
||||
catLineData.set(cat.category, { avgSales: [], avgFirst30d: [], productCounts: [] });
|
||||
}
|
||||
const d = catLineData.get(cat.category)!;
|
||||
d.avgSales.push(cat.avgLifetimeSales);
|
||||
d.avgFirst30d.push(cat.avgFirst30dSales);
|
||||
d.productCounts.push(cat.productCount);
|
||||
}
|
||||
}
|
||||
|
||||
return [...catLineData.entries()]
|
||||
.filter(([, d]) => d.avgSales.length >= 2) // Need at least 2 lines to be meaningful
|
||||
.map(([category, d]) => ({
|
||||
category,
|
||||
lineCount: d.avgSales.length,
|
||||
avgProductCount: d.productCounts.reduce((s, v) => s + v, 0) / d.productCounts.length,
|
||||
avgLifetimeSales: d.avgSales.reduce((s, v) => s + v, 0) / d.avgSales.length,
|
||||
medianLifetimeSales: median(d.avgSales),
|
||||
avgFirst30dSales: d.avgFirst30d.reduce((s, v) => s + v, 0) / d.avgFirst30d.length,
|
||||
minAvgSales: Math.min(...d.avgSales),
|
||||
maxAvgSales: Math.max(...d.avgSales),
|
||||
}))
|
||||
.sort((a, b) => b.avgLifetimeSales - a.avgLifetimeSales);
|
||||
}
|
||||
|
||||
// ─── Line view columns ─────────────────────────────────────────────────────
|
||||
|
||||
const fmt = (v: number, decimals = 0) =>
|
||||
v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
|
||||
export const lineColumns: ColumnDef<LineGroup>[] = [
|
||||
{
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
return row.getCanExpand() ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => row.toggleExpanded()}
|
||||
className="p-0 h-auto"
|
||||
>
|
||||
cell: ({ row }) =>
|
||||
row.getCanExpand() ? (
|
||||
<Button variant="ghost" onClick={() => row.toggleExpanded()} className="p-0 h-auto">
|
||||
{row.getIsExpanded() ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
) : null;
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
accessorKey: "line",
|
||||
header: "Line / Collection",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.original.line}</div>
|
||||
{row.original.artist && (
|
||||
<div className="text-xs text-muted-foreground">{row.original.artist}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "received",
|
||||
header: "Received",
|
||||
cell: ({ row }) => {
|
||||
const d = row.original.dateFirstReceived;
|
||||
if (!d) return <span className="text-muted-foreground">—</span>;
|
||||
return <span className="text-sm whitespace-nowrap">{new Date(d).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "productCount",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
# Products <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.productCount),
|
||||
},
|
||||
{
|
||||
accessorKey: "avgLifetimeSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Avg Lifetime <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.avgLifetimeSales, 1),
|
||||
},
|
||||
{
|
||||
accessorKey: "medianLifetimeSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Median <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.medianLifetimeSales, 0),
|
||||
},
|
||||
{
|
||||
accessorKey: "avgFirst30dSales",
|
||||
header: ({ column }) => (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
First 30d <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Average sales in the first 30 days after product was received</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.avgFirst30dSales, 1),
|
||||
},
|
||||
{
|
||||
accessorKey: "minSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Min <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.minSales),
|
||||
},
|
||||
{
|
||||
accessorKey: "maxSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Max <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.maxSales),
|
||||
},
|
||||
{
|
||||
accessorKey: "totalSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Total <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.totalSales),
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Category view columns (enhanced) ───────────────────────────────────────
|
||||
|
||||
export const categoryColumns: ColumnDef<CategoryGroup>[] = [
|
||||
{
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
row.getCanExpand() ? (
|
||||
<Button variant="ghost" onClick={() => row.toggleExpanded()} className="p-0 h-auto">
|
||||
{row.getIsExpanded() ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
accessorKey: "category",
|
||||
@@ -44,141 +402,415 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.original.category}</div>
|
||||
{row.original.categoryPath && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{row.original.categoryPath}
|
||||
</div>
|
||||
{row.original.categoryPath && row.original.categoryPath !== row.original.category && (
|
||||
<div className="text-xs text-muted-foreground">{row.original.categoryPath}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "avgTotalSold",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Avg Total Sold
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
accessorKey: "productCount",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
# Products <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("avgTotalSold") as number;
|
||||
return value?.toFixed(2) || "0.00";
|
||||
},
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.productCount),
|
||||
},
|
||||
{
|
||||
accessorKey: "minSold",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Min Sold
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
accessorKey: "avgLifetimeSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Avg Lifetime <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("minSold") as number;
|
||||
return value?.toLocaleString() || "0";
|
||||
},
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.avgLifetimeSales, 1),
|
||||
},
|
||||
{
|
||||
accessorKey: "maxSold",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Max Sold
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
accessorKey: "medianLifetimeSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Median <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("maxSold") as number;
|
||||
return value?.toLocaleString() || "0";
|
||||
},
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.medianLifetimeSales, 0),
|
||||
},
|
||||
{
|
||||
accessorKey: "totalSold",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Total Sold
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
accessorKey: "avgFirst30dSales",
|
||||
header: ({ column }) => (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
First 30d <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("totalSold") as number;
|
||||
return value?.toLocaleString() || "0";
|
||||
},
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Average sales in the first 30 days after product was received</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.avgFirst30dSales, 1),
|
||||
},
|
||||
{
|
||||
accessorKey: "numProducts",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
# Products
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
accessorKey: "minSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Min <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.minSales),
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("numProducts") as number;
|
||||
return value?.toLocaleString() || "0";
|
||||
{
|
||||
accessorKey: "maxSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Max <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.maxSales),
|
||||
},
|
||||
{
|
||||
accessorKey: "totalSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Total <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.totalSales),
|
||||
},
|
||||
];
|
||||
|
||||
export const renderSubComponent = ({ row }: { row: any }) => {
|
||||
const products = row.original.products || [];
|
||||
// ─── Sub-component for Line view: category breakdown + product list ─────────
|
||||
|
||||
export function LineSubComponent({ row }: { row: { original: LineGroup } }) {
|
||||
const { categories, products } = row.original;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[400px] w-full rounded-md border p-4">
|
||||
<div className="p-4 space-y-4 bg-muted/30">
|
||||
{/* Category breakdown summary */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Category breakdown — {categories.length} categories, {products.length} products
|
||||
</p>
|
||||
<div className="rounded border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Sold</TableHead>
|
||||
<TableRow className="text-xs">
|
||||
<TableHead className="py-1.5">Category</TableHead>
|
||||
<TableHead className="py-1.5 text-right"># Products</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Avg Lifetime</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Median</TableHead>
|
||||
<TableHead className="py-1.5 text-right">First 30d</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Min</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Max</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map((product: Product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
{categories.map((cat) => (
|
||||
<TableRow key={cat.category} className="text-xs">
|
||||
<TableCell className="py-1.5 font-medium">{cat.category}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{cat.productCount}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(cat.avgLifetimeSales, 1)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(cat.medianLifetimeSales)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(cat.avgFirst30dSales, 1)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(cat.minSales)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(cat.maxSales)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full product list */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">All products in this line</p>
|
||||
<ScrollArea className="h-[350px] rounded border bg-background">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow className="text-xs">
|
||||
<TableHead className="py-1.5">Product</TableHead>
|
||||
<TableHead className="py-1.5">Category</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Lifetime</TableHead>
|
||||
<TableHead className="py-1.5 text-right">First 30d</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Stock</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products
|
||||
.sort((a, b) => b.lifetimeSales - a.lifetimeSales)
|
||||
.map((p) => (
|
||||
<TableRow key={p.pid} className="text-xs">
|
||||
<TableCell className="py-1.5 max-w-[300px]">
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
href={`https://backend.acherryontop.com/product/${p.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
className="hover:underline block truncate"
|
||||
>
|
||||
{product.title}
|
||||
{p.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
<span className="text-muted-foreground">{p.sku}</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-muted-foreground">{p.category}</TableCell>
|
||||
<TableCell className="py-1.5 text-right font-medium">{fmt(p.lifetimeSales)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(p.first30dSales)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">
|
||||
<span className={p.currentStock === 0 ? 'text-red-500' : ''}>
|
||||
{fmt(p.currentStock)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.total_sold?.toLocaleString?.() ?? product.total_sold}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Sub-component for Category view: product list with richer detail ────────
|
||||
|
||||
export function CategorySubComponent({ row }: { row: { original: CategoryGroup } }) {
|
||||
const { products } = row.original;
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-muted/30">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{products.length} products
|
||||
</p>
|
||||
<ScrollArea className="h-[350px] rounded border bg-background">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow className="text-xs">
|
||||
<TableHead className="py-1.5">Product</TableHead>
|
||||
<TableHead className="py-1.5">Line</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Lifetime</TableHead>
|
||||
<TableHead className="py-1.5 text-right">First 30d</TableHead>
|
||||
<TableHead className="py-1.5 text-right">First 90d</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Stock</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products
|
||||
.sort((a, b) => b.lifetimeSales - a.lifetimeSales)
|
||||
.map((p) => (
|
||||
<TableRow key={p.pid} className="text-xs">
|
||||
<TableCell className="py-1.5 max-w-[280px]">
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${p.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline block truncate"
|
||||
>
|
||||
{p.title}
|
||||
</a>
|
||||
<span className="text-muted-foreground">{p.sku}</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-muted-foreground text-xs">{p.line || '—'}</TableCell>
|
||||
<TableCell className="py-1.5 text-right font-medium">{fmt(p.lifetimeSales)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(p.first30dSales)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(p.first90dSales)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">
|
||||
<span className={p.currentStock === 0 ? 'text-red-500' : ''}>
|
||||
{fmt(p.currentStock)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Designer view columns ──────────────────────────────────────────────────
|
||||
|
||||
export const designerColumns: ColumnDef<DesignerGroup>[] = [
|
||||
{
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
row.getCanExpand() ? (
|
||||
<Button variant="ghost" onClick={() => row.toggleExpanded()} className="p-0 h-auto">
|
||||
{row.getIsExpanded() ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
accessorKey: "artist",
|
||||
header: "Designer",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.original.artist}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.original.lineCount} line{row.original.lineCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "productCount",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
# Products <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.productCount),
|
||||
},
|
||||
{
|
||||
accessorKey: "avgLifetimeSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Avg Lifetime <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.avgLifetimeSales, 1),
|
||||
},
|
||||
{
|
||||
accessorKey: "medianLifetimeSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Median <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.medianLifetimeSales, 0),
|
||||
},
|
||||
{
|
||||
accessorKey: "avgFirst30dSales",
|
||||
header: ({ column }) => (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
First 30d <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Average sales in the first 30 days after product was received</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.avgFirst30dSales, 1),
|
||||
},
|
||||
{
|
||||
accessorKey: "minSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Min <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.minSales),
|
||||
},
|
||||
{
|
||||
accessorKey: "maxSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Max <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.maxSales),
|
||||
},
|
||||
{
|
||||
accessorKey: "totalSales",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="whitespace-nowrap">
|
||||
Total <ArrowUpDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => fmt(row.original.totalSales),
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Sub-component for Designer view ────────────────────────────────────────
|
||||
|
||||
export function DesignerSubComponent({ row }: { row: { original: DesignerGroup } }) {
|
||||
const { lines, categories } = row.original;
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 bg-muted/30">
|
||||
{/* Per-line performance */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Line performance — {lines.length} lines
|
||||
</p>
|
||||
<div className="rounded border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-xs">
|
||||
<TableHead className="py-1.5">Line</TableHead>
|
||||
<TableHead className="py-1.5">Received</TableHead>
|
||||
<TableHead className="py-1.5 text-right"># Products</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Avg Lifetime</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Median</TableHead>
|
||||
<TableHead className="py-1.5 text-right">First 30d</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Min</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Max</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((l) => (
|
||||
<TableRow key={l.line} className="text-xs">
|
||||
<TableCell className="py-1.5 font-medium">{l.line}</TableCell>
|
||||
<TableCell className="py-1.5 text-muted-foreground whitespace-nowrap">
|
||||
{l.dateFirstReceived
|
||||
? new Date(l.dateFirstReceived).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{l.productCount}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(l.avgLifetimeSales, 1)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(l.medianLifetimeSales)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(l.avgFirst30dSales, 1)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(l.minSales)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(l.maxSales)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category averages across all their lines */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Category averages across all {row.original.artist} lines
|
||||
</p>
|
||||
<div className="rounded border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-xs">
|
||||
<TableHead className="py-1.5">Category</TableHead>
|
||||
<TableHead className="py-1.5 text-right"># Products</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Avg Lifetime</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Median</TableHead>
|
||||
<TableHead className="py-1.5 text-right">First 30d</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Min</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Max</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categories.map((cat) => (
|
||||
<TableRow key={cat.category} className="text-xs">
|
||||
<TableCell className="py-1.5 font-medium">{cat.category}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{cat.productCount}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(cat.avgLifetimeSales, 1)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(cat.medianLifetimeSales)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(cat.avgFirst30dSales, 1)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(cat.minSales)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(cat.maxSales)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Legacy exports for backward compatibility with QuickOrderBuilder ───────
|
||||
// The old ForecastItem type mapped to CategoryGroup
|
||||
export type ForecastItem = CategoryGroup;
|
||||
export const columns = categoryColumns;
|
||||
export const renderSubComponent = CategorySubComponent;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FilePenLine,
|
||||
PenLine,
|
||||
Mail,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { IconCrystalBall } from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -78,6 +79,12 @@ const inventoryItems = [
|
||||
url: "/brands",
|
||||
permission: "access:brands"
|
||||
},
|
||||
{
|
||||
title: "Product Lines",
|
||||
icon: Layers,
|
||||
url: "/product-lines",
|
||||
permission: "access:product_lines"
|
||||
},
|
||||
{
|
||||
title: "Vendors",
|
||||
icon: Truck,
|
||||
|
||||
@@ -55,7 +55,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "UPC",
|
||||
key: "upc",
|
||||
description: "Universal Product Code/Barcode",
|
||||
alternateMatches: ["barcode", "bar code", "jan", "ean", "upc code"],
|
||||
alternateMatches: ["barcode", "bar code", "jan", "ean", "upc code", "upc number"],
|
||||
fieldType: { type: "input" },
|
||||
width: 165,
|
||||
validations: [
|
||||
@@ -103,7 +103,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "Name",
|
||||
key: "name",
|
||||
description: "Product name/title",
|
||||
alternateMatches: ["sku description","product name"],
|
||||
alternateMatches: ["sku description","product name","online name"],
|
||||
fieldType: { type: "input" },
|
||||
width: 400,
|
||||
validations: [
|
||||
@@ -115,7 +115,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "MSRP",
|
||||
key: "msrp",
|
||||
description: "Manufacturer's Suggested Retail Price",
|
||||
alternateMatches: ["retail", "retail price", "sugg retail", "price", "sugg. retail","default price"],
|
||||
alternateMatches: ["retail", "retail price", "sugg retail", "price", "sugg. retail","default price","sugg. retail (indv. pack)"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
price: true
|
||||
@@ -130,7 +130,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "Min Qty",
|
||||
key: "qty_per_unit",
|
||||
description: "Quantity of items per individual unit",
|
||||
alternateMatches: ["inner pack", "inner", "min qty", "unit qty", "min. order qty", "supplier qty/unit"],
|
||||
alternateMatches: ["inner pack", "inner", "min qty", "unit qty", "min. order qty", "supplier qty/unit", "wholesale pkg qty"],
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
@@ -142,7 +142,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "Cost Each",
|
||||
key: "cost_each",
|
||||
description: "Wholesale cost per unit",
|
||||
alternateMatches: ["wholesale", "wholesale price", "supplier cost each", "cost each","whls"],
|
||||
alternateMatches: ["wholesale", "wholesale price", "supplier cost each", "cost each","whls","wholesale cost"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
price: true
|
||||
@@ -318,6 +318,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "Themes",
|
||||
key: "themes",
|
||||
description: "Product themes/styles",
|
||||
alternateMatches: ["themes"],
|
||||
fieldType: {
|
||||
type: "multi-select",
|
||||
options: [], // Will be populated from API
|
||||
|
||||
@@ -329,7 +329,13 @@ export const ImageUploadStep = ({
|
||||
handleAddImageFromUrl(index, urlInputs[index]);
|
||||
}
|
||||
}}
|
||||
onImageUpload={(files: FileList | File[]) => handleImageUpload(files, index)}
|
||||
onImageUpload={async (files: FileList | File[]) => {
|
||||
const { skipped } = await handleImageUpload(files, index);
|
||||
if (skipped > 0) {
|
||||
const label = product.name || `Product #${index + 1}`;
|
||||
toast.info(`Skipped ${skipped} duplicate ${skipped === 1 ? 'image' : 'images'} for ${label}`);
|
||||
}
|
||||
}}
|
||||
onDragOver={(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
+12
-6
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { UnassignedImage, Product } from "../types";
|
||||
|
||||
type HandleImageUploadFn = (files: FileList | File[], productIndex: number) => Promise<void>;
|
||||
type HandleImageUploadFn = (files: FileList | File[], productIndex: number) => Promise<{ uploaded: number; skipped: number }>;
|
||||
|
||||
interface UseBulkImageUploadProps {
|
||||
data: Product[];
|
||||
@@ -91,6 +91,8 @@ export const useBulkImageUpload = ({ data, handleImageUpload }: UseBulkImageUplo
|
||||
|
||||
setProcessingBulk(true);
|
||||
const unassigned: UnassignedImage[] = [];
|
||||
let totalUploaded = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const file of files) {
|
||||
// Extract identifiers from filename
|
||||
@@ -103,7 +105,9 @@ export const useBulkImageUpload = ({ data, handleImageUpload }: UseBulkImageUplo
|
||||
|
||||
if (productIndex !== -1) {
|
||||
// Found a match, upload to this product
|
||||
await handleImageUpload([file], productIndex);
|
||||
const result = await handleImageUpload([file], productIndex);
|
||||
totalUploaded += result.uploaded;
|
||||
totalSkipped += result.skipped;
|
||||
assigned = true;
|
||||
break;
|
||||
}
|
||||
@@ -122,10 +126,12 @@ export const useBulkImageUpload = ({ data, handleImageUpload }: UseBulkImageUplo
|
||||
setUnassignedImages(prev => [...prev, ...unassigned]);
|
||||
setProcessingBulk(false);
|
||||
|
||||
// Show summary toast
|
||||
const assignedCount = files.length - unassigned.length;
|
||||
if (assignedCount > 0) {
|
||||
toast.success(`Auto-assigned ${assignedCount} ${assignedCount === 1 ? 'image' : 'images'} to products`);
|
||||
// Show summary toasts
|
||||
if (totalUploaded > 0) {
|
||||
toast.success(`Auto-assigned ${totalUploaded} ${totalUploaded === 1 ? 'image' : 'images'} to products`);
|
||||
}
|
||||
if (totalSkipped > 0) {
|
||||
toast.info(`Skipped ${totalSkipped} ${totalSkipped === 1 ? 'image' : 'images'} already uploaded for their products`);
|
||||
}
|
||||
if (unassigned.length > 0) {
|
||||
toast.warning(`Could not auto-assign ${unassigned.length} ${unassigned.length === 1 ? 'image' : 'images'}`);
|
||||
|
||||
+24
-4
@@ -144,20 +144,37 @@ export const useProductImageOperations = ({
|
||||
product.product_images = [product.product_images].filter(Boolean);
|
||||
}
|
||||
|
||||
// Only add if the URL doesn't already exist
|
||||
// Only add if the URL doesn't already exist (spread to avoid mutating frozen Immer arrays)
|
||||
if (!product.product_images.includes(imageUrl)) {
|
||||
product.product_images.push(imageUrl);
|
||||
product.product_images = [...product.product_images, imageUrl];
|
||||
}
|
||||
|
||||
return newData;
|
||||
};
|
||||
|
||||
// Function to handle image upload - update product data
|
||||
const handleImageUpload = async (files: FileList | File[], productIndex: number) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const handleImageUpload = async (files: FileList | File[], productIndex: number): Promise<{ uploaded: number; skipped: number }> => {
|
||||
if (!files || files.length === 0) return { uploaded: 0, skipped: 0 };
|
||||
|
||||
let uploaded = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Check for exact duplicate (same filename, size, and type already uploaded for this product)
|
||||
const isDuplicate = productImages.some(
|
||||
img => img.productIndex === productIndex &&
|
||||
!img.loading &&
|
||||
img.imageUrl &&
|
||||
img.fileName === file.name &&
|
||||
img.metadata?.size === file.size
|
||||
);
|
||||
if (isDuplicate) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageId = `image-${productIndex}-${Date.now()}-${i}`;
|
||||
const productLabel = data[productIndex].name || `Product #${productIndex + 1}`;
|
||||
|
||||
@@ -306,6 +323,7 @@ export const useProductImageOperations = ({
|
||||
// Update the product data with the new image URL
|
||||
addImageToProduct(productIndex, result.imageUrl);
|
||||
|
||||
uploaded++;
|
||||
toast.success(`Image uploaded for ${productLabel}`);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
@@ -316,6 +334,8 @@ export const useProductImageOperations = ({
|
||||
toast.error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { uploaded, skipped };
|
||||
};
|
||||
|
||||
// Function to remove an image - update to work with product_images
|
||||
|
||||
+25
-1
@@ -1475,7 +1475,10 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
// Get the pre-created onChange handler for this column
|
||||
const handleChange = columnChangeHandlers.get(column.index);
|
||||
|
||||
const isCostEach = "value" in column && column.value === "cost_each";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<FieldSelector
|
||||
column={column}
|
||||
isUnmapped={isUnmapped}
|
||||
@@ -1487,8 +1490,29 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
isFieldMappedToOtherColumn={isFieldMappedToOtherColumn}
|
||||
handleCommandListWheel={handleCommandListWheel}
|
||||
/>
|
||||
{isCostEach && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!globalSelections.costIsTotalCost}
|
||||
onChange={(e) => setGlobalSelections(prev => ({ ...prev, costIsTotalCost: e.target.checked }))}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300"
|
||||
/>
|
||||
Divide by min qty
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[250px]">
|
||||
<p>Enable if the spreadsheet lists total cost per pack rather than cost per individual item. The value will be divided by Min Qty to calculate the actual cost each.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [availableFieldCategories, allFields, columnChangeHandlers, isFieldMappedToOtherColumn, handleCommandListWheel]);
|
||||
}, [availableFieldCategories, allFields, columnChangeHandlers, isFieldMappedToOtherColumn, handleCommandListWheel, globalSelections.costIsTotalCost, setGlobalSelections]);
|
||||
|
||||
// Replace the renderValueMappings function with a memoized version
|
||||
const renderValueMappings = useCallback((column: Column<T>) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export type GlobalSelections = {
|
||||
company?: string
|
||||
line?: string
|
||||
subline?: string
|
||||
costIsTotalCost?: boolean
|
||||
}
|
||||
|
||||
export enum ColumnType {
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import lavenstein from "js-levenshtein"
|
||||
import type { Fields } from "../../../types"
|
||||
|
||||
type AutoMatchAccumulator<T> = {
|
||||
distance: number
|
||||
value: T
|
||||
}
|
||||
|
||||
export const findMatch = <T extends string>(
|
||||
header: string,
|
||||
fields: Fields<T>,
|
||||
autoMapDistance: number,
|
||||
_autoMapDistance: number,
|
||||
): T | undefined => {
|
||||
const headerLower = header.toLowerCase()
|
||||
const smallestValue = fields.reduce<AutoMatchAccumulator<T>>((acc, field) => {
|
||||
const distance = Math.min(
|
||||
...[
|
||||
lavenstein(field.key.toLowerCase(), headerLower),
|
||||
...(field.alternateMatches?.map((alternate) => lavenstein(alternate.toLowerCase(), headerLower)) || []),
|
||||
],
|
||||
)
|
||||
return distance < acc.distance || acc.distance === undefined
|
||||
? ({ value: field.key, distance } as AutoMatchAccumulator<T>)
|
||||
: acc
|
||||
}, {} as AutoMatchAccumulator<T>)
|
||||
return smallestValue.distance <= autoMapDistance ? smallestValue.value : undefined
|
||||
const headerLower = header.toLowerCase().trim()
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.label.toLowerCase().trim() === headerLower) return field.key as T
|
||||
if ((field.key as string).toLowerCase() === headerLower) return field.key as T
|
||||
if (field.alternateMatches?.some((alt) => (alt as string).toLowerCase().trim() === headerLower)) return field.key as T
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
+3
-16
@@ -1,4 +1,3 @@
|
||||
import lavenstein from "js-levenshtein"
|
||||
import { findMatch } from "./findMatch"
|
||||
import type { Field, Fields } from "../../../types"
|
||||
import { setColumn } from "./setColumn"
|
||||
@@ -16,21 +15,9 @@ export const getMatchedColumns = <T extends string>(
|
||||
if (autoMatch) {
|
||||
const field = fields.find((field) => field.key === autoMatch) as Field<T>
|
||||
const duplicateIndex = arr.findIndex((column) => "value" in column && column.value === field.key)
|
||||
const duplicate = arr[duplicateIndex]
|
||||
if (duplicate && "value" in duplicate) {
|
||||
return lavenstein(duplicate.value, duplicate.header) < lavenstein(autoMatch, column.header)
|
||||
? [
|
||||
...arr.slice(0, duplicateIndex),
|
||||
setColumn(arr[duplicateIndex], field, data, autoMapSelectValues),
|
||||
...arr.slice(duplicateIndex + 1),
|
||||
setColumn(column),
|
||||
]
|
||||
: [
|
||||
...arr.slice(0, duplicateIndex),
|
||||
setColumn(arr[duplicateIndex]),
|
||||
...arr.slice(duplicateIndex + 1),
|
||||
setColumn(column, field, data, autoMapSelectValues),
|
||||
]
|
||||
if (duplicateIndex >= 0) {
|
||||
// Field already matched by an earlier column — keep the first match
|
||||
return [...arr, column]
|
||||
} else {
|
||||
return [...arr, setColumn(column, field, data, autoMapSelectValues)]
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
onContinue={async (values, rawData, columns, globalSelections, useNewValidation) => {
|
||||
try {
|
||||
const data = await matchColumnsStepHook(values, rawData, columns)
|
||||
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
|
||||
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook, undefined, { costIsTotalCost: globalSelections?.costIsTotalCost })
|
||||
|
||||
// Apply global selections to each row of data if they exist
|
||||
const dataWithGlobalSelections = globalSelections
|
||||
|
||||
+265
-140
@@ -1014,7 +1014,9 @@ CellWrapper.displayName = 'CellWrapper';
|
||||
* Template column width
|
||||
*/
|
||||
const TEMPLATE_COLUMN_WIDTH = 200;
|
||||
const NAME_COLUMN_STICKY_LEFT = 0;
|
||||
|
||||
/** Maximum number of columns that can be pinned simultaneously */
|
||||
const MAX_PINNED_COLUMNS = 3;
|
||||
|
||||
/**
|
||||
* TemplateCell Component
|
||||
@@ -1273,6 +1275,15 @@ TemplateCell.displayName = 'TemplateCell';
|
||||
* for a given row. It passes all data down to CellWrapper as props.
|
||||
* This reduces subscriptions from ~2100 (7 per cell) to ~150 (5 per row).
|
||||
*/
|
||||
/** Layout info for a single pinned column */
|
||||
type PinnedColumnEntry = {
|
||||
left: number; // Cumulative left offset for sticky-left positioning
|
||||
right: number; // Cumulative right offset for sticky-right positioning
|
||||
naturalLeft: number; // Natural left position in the full-width row (for stuck detection)
|
||||
width: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
interface VirtualRowProps {
|
||||
rowIndex: number;
|
||||
rowId: string;
|
||||
@@ -1280,10 +1291,14 @@ interface VirtualRowProps {
|
||||
columns: ColumnDef<RowData>[];
|
||||
fields: Field<string>[];
|
||||
totalRowCount: number;
|
||||
/** Whether the name column sticky behavior is enabled */
|
||||
nameColumnSticky: boolean;
|
||||
/** Direction for sticky name column: 'left', 'right', or null (not sticky) */
|
||||
stickyDirection: 'left' | 'right' | null;
|
||||
/** Map of field key → sticky layout for all currently pinned columns */
|
||||
pinnedColumnLayout: Map<string, PinnedColumnEntry>;
|
||||
/** Per-column stick direction: only columns that are actually stuck appear in this map */
|
||||
stickyStates: Map<string, 'left' | 'right'>;
|
||||
/** Field key of the left-shadow column (rightmost column stuck left) */
|
||||
shadowLeftKey: string | null;
|
||||
/** Field key of the right-shadow column (leftmost column stuck right) */
|
||||
shadowRightKey: string | null;
|
||||
}
|
||||
|
||||
const VirtualRow = memo(({
|
||||
@@ -1293,8 +1308,10 @@ const VirtualRow = memo(({
|
||||
columns,
|
||||
fields,
|
||||
totalRowCount,
|
||||
nameColumnSticky,
|
||||
stickyDirection,
|
||||
pinnedColumnLayout,
|
||||
stickyStates,
|
||||
shadowLeftKey,
|
||||
shadowRightKey,
|
||||
}: VirtualRowProps) => {
|
||||
// Subscribe to row data - this is THE subscription for all cell values in this row
|
||||
const rowData = useValidationStore(
|
||||
@@ -1478,10 +1495,14 @@ const VirtualRow = memo(({
|
||||
|
||||
const isNameColumn = field.key === 'name';
|
||||
|
||||
// Determine sticky behavior for name column
|
||||
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
|
||||
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
|
||||
const stickyRight = shouldBeSticky && stickyDirection === 'right';
|
||||
// Determine sticky behavior per column (guard: pinnedEntry must exist — stickyStates can lag behind unpins)
|
||||
const pinnedEntry = pinnedColumnLayout.get(field.key);
|
||||
const columnStickDir = pinnedEntry ? stickyStates.get(field.key) : undefined;
|
||||
const shouldBeSticky = !!columnStickDir;
|
||||
const stickyLeft = columnStickDir === 'left';
|
||||
const stickyRight = columnStickDir === 'right';
|
||||
// Shadow on the outermost actually-stuck column per side
|
||||
const isShadowColumn = field.key === shadowLeftKey || field.key === shadowRightKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1492,16 +1513,13 @@ const VirtualRow = memo(({
|
||||
// Use box-shadow for right border - renders more consistently with transforms
|
||||
// last:shadow-none removes the shadow from the last cell
|
||||
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
|
||||
// Name column needs overflow-visible for the floating AI suggestion badge
|
||||
// Description handles AI suggestions inside its popover, so no overflow needed
|
||||
isNameColumn ? "overflow-visible" : "overflow-hidden",
|
||||
// Name column sticky behavior - only when enabled and scrolled appropriately
|
||||
shouldBeSticky && "lg:sticky lg:z-10",
|
||||
// Add left border when sticky-right since content scrolls behind from the left
|
||||
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border))]",
|
||||
// Directional drop shadow on the outside edge where content scrolls behind (combined with border shadow)
|
||||
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||
// overflow-visible needed for: name column (AI suggestion badge) and shadow columns (drop shadow)
|
||||
(isNameColumn || isShadowColumn) ? "overflow-visible" : "overflow-hidden",
|
||||
// Pinned column sticky behavior - only when enabled and scrolled appropriately
|
||||
shouldBeSticky && "lg:sticky",
|
||||
// Drop shadow only on the outermost actually-stuck column
|
||||
isShadowColumn && stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||
isShadowColumn && stickyRight && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||
// Solid background when sticky to overlay content
|
||||
// Use explicit [background:] syntax for consistent specificity
|
||||
// Selection (blue) takes priority over errors (red)
|
||||
@@ -1519,9 +1537,11 @@ const VirtualRow = memo(({
|
||||
width: columnWidth,
|
||||
minWidth: columnWidth,
|
||||
flexShrink: 0,
|
||||
// Z-index layered per pinned column so rightmost renders on top
|
||||
...(shouldBeSticky && { zIndex: pinnedEntry!.zIndex }),
|
||||
// Position sticky left or right based on scroll direction
|
||||
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
|
||||
...(stickyRight && { right: 0 }),
|
||||
...(stickyLeft && { left: pinnedEntry!.left }),
|
||||
...(stickyRight && { right: pinnedEntry!.right }),
|
||||
}}
|
||||
>
|
||||
<CellWrapper
|
||||
@@ -1652,6 +1672,8 @@ interface PriceColumnHeaderProps {
|
||||
fieldKey: 'msrp' | 'cost_each';
|
||||
label: string;
|
||||
isRequired: boolean;
|
||||
pinButton?: React.ReactNode;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
const MSRP_MULTIPLIERS = [2.0, 2.1, 2.2, 2.3, 2.4, 2.5];
|
||||
@@ -1667,7 +1689,7 @@ const roundToNine = (value: number): number => {
|
||||
return wholePart + (tenths / 10) + 0.09;
|
||||
};
|
||||
|
||||
const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHeaderProps) => {
|
||||
const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: PriceColumnHeaderProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [hasFillableCells, setHasFillableCells] = useState(false);
|
||||
@@ -1788,7 +1810,7 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 truncate w-full group relative"
|
||||
className="flex items-center gap-1 min-w-0 w-full group relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => {
|
||||
if (!isPopoverOpen) setIsHovered(false);
|
||||
@@ -1798,9 +1820,10 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
{isRequired && (
|
||||
<span className="text-destructive flex-shrink-0">*</span>
|
||||
)}
|
||||
{/* Button group: pin button always visible when pinned, action buttons only on hover */}
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{(isHovered || isPopoverOpen) && hasFillableCells && (
|
||||
isMsrp ? (
|
||||
// MSRP: Show popover with multiplier options
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -1810,11 +1833,9 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||
'flex items-center gap-0.5',
|
||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||
'rounded border border-input bg-background p-1 shadow-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-opacity'
|
||||
)}
|
||||
>
|
||||
<Calculator className="h-3 w-3" />
|
||||
@@ -1875,7 +1896,6 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
// Cost Each: Simple click behavior
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1886,11 +1906,9 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
handleCalculateCostEach();
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||
'flex items-center gap-0.5',
|
||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||
'rounded border border-input bg-background p-1 shadow-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-opacity'
|
||||
)}
|
||||
>
|
||||
<Calculator className="h-3 w-3" />
|
||||
@@ -1903,6 +1921,8 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
</TooltipProvider>
|
||||
)
|
||||
)}
|
||||
{pinButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1923,6 +1943,8 @@ interface UnitConversionColumnHeaderProps {
|
||||
fieldKey: 'weight' | 'length' | 'width' | 'height';
|
||||
label: string;
|
||||
isRequired: boolean;
|
||||
pinButton?: React.ReactNode;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
type ConversionOption = {
|
||||
@@ -1942,7 +1964,7 @@ const DIMENSION_CONVERSIONS: ConversionOption[] = [
|
||||
{ label: 'Millimeters → Inches', factor: 0.0393701, roundTo: 2 },
|
||||
];
|
||||
|
||||
const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitConversionColumnHeaderProps) => {
|
||||
const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: UnitConversionColumnHeaderProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [hasConvertibleCells, setHasConvertibleCells] = useState(false);
|
||||
@@ -2008,7 +2030,7 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 truncate w-full group relative"
|
||||
className="flex items-center gap-1 min-w-0 w-full group relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => {
|
||||
if (!isPopoverOpen) setIsHovered(false);
|
||||
@@ -2018,6 +2040,8 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
|
||||
{isRequired && (
|
||||
<span className="text-destructive flex-shrink-0">*</span>
|
||||
)}
|
||||
{/* Button group: pin button always visible when pinned, action buttons only on hover */}
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{(isHovered || isPopoverOpen) && hasConvertibleCells && (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<TooltipProvider>
|
||||
@@ -2030,11 +2054,9 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||
'flex items-center gap-0.5',
|
||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||
'rounded border border-input bg-background p-1 shadow-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-opacity'
|
||||
)}
|
||||
>
|
||||
<Scale className="h-3 w-3" />
|
||||
@@ -2066,6 +2088,8 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{pinButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2084,6 +2108,8 @@ interface DefaultValueColumnHeaderProps {
|
||||
fieldKey: 'tax_cat' | 'ship_restrictions';
|
||||
label: string;
|
||||
isRequired: boolean;
|
||||
pinButton?: React.ReactNode;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_VALUE_CONFIG: Record<string, { value: string; displayName: string; buttonLabel: string }> = {
|
||||
@@ -2091,7 +2117,7 @@ const DEFAULT_VALUE_CONFIG: Record<string, { value: string; displayName: string;
|
||||
ship_restrictions: { value: '0', displayName: 'None', buttonLabel: 'Set All None' },
|
||||
};
|
||||
|
||||
const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultValueColumnHeaderProps) => {
|
||||
const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: DefaultValueColumnHeaderProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [hasEmptyCells, setHasEmptyCells] = useState(false);
|
||||
|
||||
@@ -2139,7 +2165,7 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 truncate w-full group relative"
|
||||
className="flex items-center gap-1 min-w-0 w-full group relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
@@ -2147,6 +2173,8 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
|
||||
{isRequired && (
|
||||
<span className="text-destructive flex-shrink-0">*</span>
|
||||
)}
|
||||
{/* Button group: pin button always visible when pinned, action buttons only on hover */}
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{isHovered && hasEmptyCells && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -2158,11 +2186,10 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
|
||||
handleSetDefault();
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||
'flex items-center gap-0.5',
|
||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||
'rounded border border-input bg-background p-1 shadow-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-opacity whitespace-nowrap'
|
||||
'whitespace-nowrap'
|
||||
)}
|
||||
>
|
||||
<Wand2 className="h-3 w-3" />
|
||||
@@ -2174,6 +2201,8 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{pinButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2181,25 +2210,17 @@ const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultV
|
||||
DefaultValueColumnHeader.displayName = 'DefaultValueColumnHeader';
|
||||
|
||||
/**
|
||||
* NameColumnHeader Component
|
||||
*
|
||||
* Renders the Name column header with a sticky toggle button.
|
||||
* Pin icon toggles whether the name column sticks to edges when scrolling.
|
||||
* PinButton Component - renders a pin/unpin toggle for any column header.
|
||||
* Hidden by default, visible on column header hover (via parent `group` class).
|
||||
* Always visible when the column is pinned.
|
||||
*/
|
||||
interface NameColumnHeaderProps {
|
||||
label: string;
|
||||
isRequired: boolean;
|
||||
isSticky: boolean;
|
||||
onToggleSticky: () => void;
|
||||
interface PinButtonProps {
|
||||
isPinned: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const NameColumnHeader = memo(({ label, isRequired, isSticky, onToggleSticky }: NameColumnHeaderProps) => {
|
||||
const PinButton = memo(({ isPinned, onToggle }: PinButtonProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1 truncate w-full group relative">
|
||||
<span className="truncate">{label}</span>
|
||||
{isRequired && (
|
||||
<span className="text-destructive flex-shrink-0">*</span>
|
||||
)}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2207,29 +2228,31 @@ const NameColumnHeader = memo(({ label, isRequired, isSticky, onToggleSticky }:
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSticky();
|
||||
onToggle();
|
||||
}}
|
||||
className={cn(
|
||||
'ml-auto flex items-center justify-center w-6 h-6 rounded',
|
||||
'transition-colors',
|
||||
isSticky
|
||||
? 'text-primary bg-primary/10 hover:bg-primary/20'
|
||||
: 'text-muted-foreground hover:bg-muted'
|
||||
// Matches existing action button style (Calculator, Scale, Wand2)
|
||||
// No absolute positioning — parent container handles placement
|
||||
'flex items-center justify-center',
|
||||
'rounded border border-input bg-background p-1 shadow-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-opacity',
|
||||
// Visible when pinned, otherwise show on hover (via parent group)
|
||||
isPinned ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
{isSticky ? <Pin className="h-3.5 w-3.5" /> : <PinOff className="h-3.5 w-3.5" />}
|
||||
{isPinned ? <Pin className="h-3 w-3" /> : <PinOff className="h-3 w-3" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{isSticky ? 'Unpin column' : 'Pin column'}</p>
|
||||
<p>{isPinned ? 'Unpin column' : 'Pin column'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
NameColumnHeader.displayName = 'NameColumnHeader';
|
||||
PinButton.displayName = 'PinButton';
|
||||
|
||||
/**
|
||||
* Main table component
|
||||
@@ -2250,57 +2273,145 @@ export const ValidationTable = () => {
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Calculate name column's natural left position (before it becomes sticky)
|
||||
// Selection (40) + Template (200) + all field columns before 'name'
|
||||
const nameColumnLeftOffset = useMemo(() => {
|
||||
let offset = 40 + TEMPLATE_COLUMN_WIDTH; // Selection + Template columns
|
||||
for (const field of fields) {
|
||||
if (field.key === 'name') break;
|
||||
offset += field.width || 150;
|
||||
// Set of currently pinned field keys — defaults to 'name' pinned (same as before)
|
||||
const [pinnedColumns, setPinnedColumns] = useState<Set<string>>(() => new Set(['name']));
|
||||
|
||||
|
||||
// Toggle pin for a column — enforces MAX_PINNED_COLUMNS cap
|
||||
const togglePinColumn = useCallback((fieldKey: string) => {
|
||||
setPinnedColumns(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(fieldKey)) {
|
||||
next.delete(fieldKey);
|
||||
} else {
|
||||
if (next.size >= MAX_PINNED_COLUMNS) {
|
||||
toast.warning(`Maximum of ${MAX_PINNED_COLUMNS} pinned columns`);
|
||||
return prev;
|
||||
}
|
||||
return offset;
|
||||
}, [fields]);
|
||||
next.add(fieldKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Name column sticky toggle - when enabled, column sticks to left/right edges based on scroll
|
||||
const [nameColumnSticky, setNameColumnSticky] = useState(true);
|
||||
// Compute layout info for each pinned column: cumulative left/right offsets, natural position, width, z-index
|
||||
// Pinned columns maintain their natural field order (no reordering)
|
||||
const pinnedColumnLayout = useMemo(() => {
|
||||
const layout = new Map<string, PinnedColumnEntry>();
|
||||
if (pinnedColumns.size === 0) return layout;
|
||||
|
||||
// Track scroll direction relative to name column: 'left' (stick to left) or 'right' (stick to right)
|
||||
const [stickyDirection, setStickyDirection] = useState<'left' | 'right' | null>(null);
|
||||
// Collect pinned fields in field order with their natural left positions
|
||||
const baseOffset = 40 + TEMPLATE_COLUMN_WIDTH; // Selection + Template columns
|
||||
const pinnedInOrder: { key: string; width: number; naturalLeft: number }[] = [];
|
||||
let runningOffset = baseOffset;
|
||||
for (const field of fields) {
|
||||
const w = field.width || 150;
|
||||
if (pinnedColumns.has(field.key)) {
|
||||
pinnedInOrder.push({ key: field.key, width: w, naturalLeft: runningOffset });
|
||||
}
|
||||
runningOffset += w;
|
||||
}
|
||||
|
||||
// Calculate name column width
|
||||
const nameColumnWidth = useMemo(() => {
|
||||
const nameField = fields.find(f => f.key === 'name');
|
||||
return nameField?.width || 400;
|
||||
}, [fields]);
|
||||
// Compute cumulative right offsets (from the right edge inward)
|
||||
const rightOffsets: number[] = new Array(pinnedInOrder.length).fill(0);
|
||||
let cumulativeRight = 0;
|
||||
for (let i = pinnedInOrder.length - 1; i >= 0; i--) {
|
||||
rightOffsets[i] = cumulativeRight;
|
||||
cumulativeRight += pinnedInOrder[i].width;
|
||||
}
|
||||
|
||||
// Sync header scroll with body scroll + track sticky direction
|
||||
// Assign cumulative left offsets, right offsets, and z-indexes
|
||||
let cumulativeLeft = 0;
|
||||
for (let i = 0; i < pinnedInOrder.length; i++) {
|
||||
const { key, width, naturalLeft } = pinnedInOrder[i];
|
||||
layout.set(key, {
|
||||
left: cumulativeLeft,
|
||||
right: rightOffsets[i],
|
||||
naturalLeft,
|
||||
width,
|
||||
zIndex: 10 + i, // Increasing z so rightmost pinned is on top
|
||||
});
|
||||
cumulativeLeft += width;
|
||||
}
|
||||
return layout;
|
||||
}, [fields, pinnedColumns]);
|
||||
|
||||
// Per-column sticky state: each pinned column independently sticks left, right, or not at all.
|
||||
// Only columns that are actually stuck appear in this map.
|
||||
const [stickyStates, setStickyStates] = useState<Map<string, 'left' | 'right'>>(() => new Map());
|
||||
const stickyStatesRef = useRef<Map<string, 'left' | 'right'>>(new Map());
|
||||
// Shadow keys: the outermost stuck column on each side
|
||||
const [shadowLeftKey, setShadowLeftKey] = useState<string | null>(null);
|
||||
const [shadowRightKey, setShadowRightKey] = useState<string | null>(null);
|
||||
const shadowLeftRef = useRef<string | null>(null);
|
||||
const shadowRightRef = useRef<string | null>(null);
|
||||
|
||||
// Compute per-column sticky state and shadow keys from scroll position
|
||||
const updateStickyState = useCallback((scrollLeft: number, viewportWidth: number) => {
|
||||
let newLeftShadow: string | null = null;
|
||||
let newRightShadow: string | null = null;
|
||||
let changed = false;
|
||||
const newStates = new Map<string, 'left' | 'right'>();
|
||||
|
||||
for (const [key, entry] of pinnedColumnLayout) {
|
||||
// CSS sticky; left: L engages when naturalLeft < scrollLeft + L
|
||||
const stuckLeft = entry.naturalLeft < scrollLeft + entry.left;
|
||||
// CSS sticky; right: R engages when naturalLeft + width > scrollLeft + viewportWidth - R
|
||||
const stuckRight = entry.naturalLeft + entry.width > scrollLeft + viewportWidth - entry.right;
|
||||
|
||||
if (stuckLeft) {
|
||||
newStates.set(key, 'left');
|
||||
newLeftShadow = key; // Keep overwriting — last match = rightmost stuck-left
|
||||
} else if (stuckRight) {
|
||||
newStates.set(key, 'right');
|
||||
if (newRightShadow === null) newRightShadow = key; // First match = leftmost stuck-right
|
||||
}
|
||||
}
|
||||
|
||||
// Compare with previous states to avoid unnecessary re-renders
|
||||
if (newStates.size !== stickyStatesRef.current.size) {
|
||||
changed = true;
|
||||
} else {
|
||||
for (const [key, dir] of newStates) {
|
||||
if (stickyStatesRef.current.get(key) !== dir) { changed = true; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
stickyStatesRef.current = newStates;
|
||||
setStickyStates(newStates);
|
||||
}
|
||||
if (newLeftShadow !== shadowLeftRef.current) {
|
||||
shadowLeftRef.current = newLeftShadow;
|
||||
setShadowLeftKey(newLeftShadow);
|
||||
}
|
||||
if (newRightShadow !== shadowRightRef.current) {
|
||||
shadowRightRef.current = newRightShadow;
|
||||
setShadowRightKey(newRightShadow);
|
||||
}
|
||||
}, [pinnedColumnLayout]);
|
||||
|
||||
// Body scroll → sync header + update sticky state
|
||||
const handleScroll = useCallback(() => {
|
||||
if (tableContainerRef.current && headerRef.current) {
|
||||
const scrollLeft = tableContainerRef.current.scrollLeft;
|
||||
const viewportWidth = tableContainerRef.current.clientWidth;
|
||||
if (headerRef.current.scrollLeft !== scrollLeft) {
|
||||
headerRef.current.scrollLeft = scrollLeft;
|
||||
}
|
||||
updateStickyState(scrollLeft, viewportWidth);
|
||||
}
|
||||
}, [updateStickyState]);
|
||||
|
||||
// Calculate name column's position relative to viewport
|
||||
const namePositionInViewport = nameColumnLeftOffset - scrollLeft;
|
||||
const nameRightEdge = namePositionInViewport + nameColumnWidth;
|
||||
|
||||
// Determine sticky direction for name column
|
||||
if (nameColumnSticky) {
|
||||
if (scrollLeft > nameColumnLeftOffset) {
|
||||
// Scrolled right past name column - stick to left
|
||||
setStickyDirection('left');
|
||||
} else if (nameRightEdge > viewportWidth) {
|
||||
// Name column extends beyond viewport to the right - stick to right
|
||||
setStickyDirection('right');
|
||||
} else {
|
||||
// Name column is fully visible - no sticky needed
|
||||
setStickyDirection(null);
|
||||
}
|
||||
} else {
|
||||
setStickyDirection(null);
|
||||
// Header scroll → sync body (handles scroll-wheel over header area)
|
||||
const handleHeaderScroll = useCallback(() => {
|
||||
if (headerRef.current && tableContainerRef.current) {
|
||||
const scrollLeft = headerRef.current.scrollLeft;
|
||||
if (tableContainerRef.current.scrollLeft !== scrollLeft) {
|
||||
tableContainerRef.current.scrollLeft = scrollLeft;
|
||||
}
|
||||
}
|
||||
}, [nameColumnLeftOffset, nameColumnWidth, nameColumnSticky]);
|
||||
}, []);
|
||||
|
||||
// Compute filtered indices AND row IDs in a single pass
|
||||
// This avoids calling getState() during render for each row
|
||||
@@ -2343,12 +2454,7 @@ export const ValidationTable = () => {
|
||||
return { filteredIndices: indices, rowIdMap: idMap };
|
||||
}, [rowCount, filters.searchText, filters.showErrorsOnly]);
|
||||
|
||||
// Toggle for sticky name column
|
||||
const toggleNameColumnSticky = useCallback(() => {
|
||||
setNameColumnSticky(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Build columns - ONLY depends on fields, NOT selection state
|
||||
// Build columns - ONLY depends on fields and pinnedColumns, NOT selection state
|
||||
// Selection state is handled by isolated HeaderCheckbox component
|
||||
const columns = useMemo<ColumnDef<RowData>[]>(() => {
|
||||
// Selection column - uses isolated HeaderCheckbox to prevent cascading re-renders
|
||||
@@ -2371,26 +2477,26 @@ export const ValidationTable = () => {
|
||||
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
|
||||
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height';
|
||||
const isDefaultValueColumn = field.key === 'tax_cat' || field.key === 'ship_restrictions';
|
||||
const isNameColumn = field.key === 'name';
|
||||
const isPinned = pinnedColumns.has(field.key);
|
||||
|
||||
// Pin button passed to each header — rendered alongside action buttons
|
||||
const pinBtn = (
|
||||
<PinButton
|
||||
isPinned={isPinned}
|
||||
onToggle={() => togglePinColumn(field.key)}
|
||||
/>
|
||||
);
|
||||
|
||||
// Determine which header component to render
|
||||
const renderHeader = () => {
|
||||
if (isNameColumn) {
|
||||
return (
|
||||
<NameColumnHeader
|
||||
label={field.label}
|
||||
isRequired={isRequired}
|
||||
isSticky={nameColumnSticky}
|
||||
onToggleSticky={toggleNameColumnSticky}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isPriceColumn) {
|
||||
return (
|
||||
<PriceColumnHeader
|
||||
fieldKey={field.key as 'msrp' | 'cost_each'}
|
||||
label={field.label}
|
||||
isRequired={isRequired}
|
||||
pinButton={pinBtn}
|
||||
isPinned={isPinned}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2400,6 +2506,8 @@ export const ValidationTable = () => {
|
||||
fieldKey={field.key as 'weight' | 'length' | 'width' | 'height'}
|
||||
label={field.label}
|
||||
isRequired={isRequired}
|
||||
pinButton={pinBtn}
|
||||
isPinned={isPinned}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2409,15 +2517,25 @@ export const ValidationTable = () => {
|
||||
fieldKey={field.key as 'tax_cat' | 'ship_restrictions'}
|
||||
label={field.label}
|
||||
isRequired={isRequired}
|
||||
pinButton={pinBtn}
|
||||
isPinned={isPinned}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Plain header — pin button in its own absolutely-positioned container
|
||||
return (
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
<div className="flex items-center gap-1 min-w-0 w-full group relative">
|
||||
<span className="truncate">{field.label}</span>
|
||||
{isRequired && (
|
||||
<span className="text-destructive flex-shrink-0">*</span>
|
||||
)}
|
||||
<div className={cn(
|
||||
'absolute right-0 top-1/2 -translate-y-1/2',
|
||||
'transition-opacity',
|
||||
!isPinned && 'opacity-0 group-hover:opacity-100'
|
||||
)}>
|
||||
{pinBtn}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2430,7 +2548,7 @@ export const ValidationTable = () => {
|
||||
});
|
||||
|
||||
return [selectionColumn, templateColumn, ...dataColumns];
|
||||
}, [fields, nameColumnSticky, toggleNameColumnSticky]); // Added nameColumnSticky dependencies
|
||||
}, [fields, pinnedColumns, togglePinColumn]);
|
||||
|
||||
// Calculate total table width for horizontal scrolling
|
||||
const totalTableWidth = useMemo(() => {
|
||||
@@ -2454,22 +2572,25 @@ export const ValidationTable = () => {
|
||||
{/* Copy-down banner - shows when copy-down mode is active */}
|
||||
<CopyDownBanner />
|
||||
|
||||
{/* Fixed header - OUTSIDE the scroll container but syncs horizontal scroll */}
|
||||
{/* Fixed header - syncs horizontal scroll bidirectionally with body */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="flex-shrink-0 bg-muted/50 border-b overflow-hidden"
|
||||
className="flex-shrink-0 bg-muted/50 border-b overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [scrollbar-width:none]"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
onScroll={handleHeaderScroll}
|
||||
>
|
||||
<div
|
||||
className="flex h-full"
|
||||
style={{ minWidth: totalTableWidth }}
|
||||
>
|
||||
{columns.map((column, index) => {
|
||||
const isNameColumn = column.id === 'name';
|
||||
// Determine sticky behavior for header name column
|
||||
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
|
||||
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
|
||||
const stickyRight = shouldBeSticky && stickyDirection === 'right';
|
||||
// Per-column sticky state (guard: pinnedEntry must exist — stickyStates can lag behind unpins)
|
||||
const pinnedEntry = column.id ? pinnedColumnLayout.get(column.id) : undefined;
|
||||
const columnStickDir = pinnedEntry && column.id ? stickyStates.get(column.id) : undefined;
|
||||
const shouldBeSticky = !!columnStickDir;
|
||||
const stickyLeft = columnStickDir === 'left';
|
||||
const stickyRight = columnStickDir === 'right';
|
||||
const isShadowColumn = column.id === shadowLeftKey || column.id === shadowRightKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2479,18 +2600,20 @@ export const ValidationTable = () => {
|
||||
// Use box-shadow for right border - renders more consistently
|
||||
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
|
||||
// Sticky header - only when enabled and scrolled appropriately
|
||||
shouldBeSticky && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
|
||||
// Directional shadow on the outside edge where content scrolls behind (combined with border shadow)
|
||||
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||
shouldBeSticky && "lg:sticky lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
|
||||
// Drop shadow only on the outermost actually-stuck column per side
|
||||
isShadowColumn && stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||
isShadowColumn && stickyRight && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||
)}
|
||||
style={{
|
||||
width: column.size || 150,
|
||||
minWidth: column.size || 150,
|
||||
flexShrink: 0,
|
||||
// Z-index layered per pinned column (header z is 10 above cell z)
|
||||
...(shouldBeSticky && { zIndex: pinnedEntry!.zIndex + 10 }),
|
||||
// Position sticky left or right based on scroll direction
|
||||
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
|
||||
...(stickyRight && { right: 0 }),
|
||||
...(stickyLeft && { left: pinnedEntry!.left }),
|
||||
...(stickyRight && { right: pinnedEntry!.right }),
|
||||
}}
|
||||
>
|
||||
{typeof column.header === 'function'
|
||||
@@ -2527,8 +2650,10 @@ export const ValidationTable = () => {
|
||||
columns={columns}
|
||||
fields={fields}
|
||||
totalRowCount={rowCount}
|
||||
nameColumnSticky={nameColumnSticky}
|
||||
stickyDirection={stickyDirection}
|
||||
pinnedColumnLayout={pinnedColumnLayout}
|
||||
stickyStates={stickyStates}
|
||||
shadowLeftKey={shadowLeftKey}
|
||||
shadowRightKey={shadowRightKey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const addErrorsAndRunHooks = async <T extends string>(
|
||||
rowHook?: RowHook<T>,
|
||||
tableHook?: TableHook<T>,
|
||||
changedRowIndexes?: number[],
|
||||
options?: { costIsTotalCost?: boolean },
|
||||
): Promise<DataWithMeta<T>[]> => {
|
||||
const errors: Errors = {}
|
||||
|
||||
@@ -57,6 +58,29 @@ export const addErrorsAndRunHooks = async <T extends string>(
|
||||
}
|
||||
}
|
||||
|
||||
// Extract numeric value from qty_per_unit (e.g. "pack of 25" → "25")
|
||||
processedData.forEach((row) => {
|
||||
const qty = (row as Record<string, unknown>).qty_per_unit
|
||||
if (typeof qty === "string" && qty.trim()) {
|
||||
const match = qty.match(/\d+/)
|
||||
if (match) {
|
||||
(row as Record<string, unknown>).qty_per_unit = match[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Divide cost_each by qty_per_unit when cost represents total cost
|
||||
if (options?.costIsTotalCost) {
|
||||
processedData.forEach((row) => {
|
||||
const r = row as Record<string, unknown>
|
||||
const cost = parseFloat(String(r.cost_each ?? ""))
|
||||
const qty = parseInt(String(r.qty_per_unit ?? ""), 10)
|
||||
if (!isNaN(cost) && qty > 0) {
|
||||
r.cost_each = (cost / qty).toFixed(2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Normalize country of origin (coo) to 2-letter ISO codes
|
||||
processedData.forEach((row) => {
|
||||
const coo = (row as Record<string, unknown>).coo
|
||||
|
||||
-981
@@ -1,981 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, CheckIcon, XIcon } from "lucide-react";
|
||||
import { Code } from "@/components/ui/code";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
AiValidationDetails,
|
||||
AiValidationProgress,
|
||||
CurrentPrompt,
|
||||
} from "../hooks/useAiValidation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Protected } from "@/components/auth/Protected";
|
||||
|
||||
interface TaxonomyStats {
|
||||
categories: number;
|
||||
themes: number;
|
||||
colors: number;
|
||||
taxCodes: number;
|
||||
sizeCategories: number;
|
||||
suppliers: number;
|
||||
companies: number;
|
||||
artists: number;
|
||||
}
|
||||
|
||||
interface DebugData {
|
||||
taxonomyStats: TaxonomyStats | null;
|
||||
basePrompt: string;
|
||||
sampleFullPrompt: string;
|
||||
promptLength: number;
|
||||
apiFormat?: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
}>;
|
||||
promptSources?: {
|
||||
systemPrompt?: { id: number; prompt_text: string };
|
||||
generalPrompt?: { id: number; prompt_text: string };
|
||||
companyPrompts?: Array<{
|
||||
id: number;
|
||||
company: string;
|
||||
companyName?: string;
|
||||
prompt_text: string;
|
||||
}>;
|
||||
};
|
||||
estimatedProcessingTime?: {
|
||||
seconds: number | null;
|
||||
sampleCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface AiValidationDialogsProps {
|
||||
aiValidationProgress: AiValidationProgress;
|
||||
aiValidationDetails: AiValidationDetails;
|
||||
currentPrompt: CurrentPrompt;
|
||||
setAiValidationProgress: React.Dispatch<
|
||||
React.SetStateAction<AiValidationProgress>
|
||||
>;
|
||||
setAiValidationDetails: React.Dispatch<
|
||||
React.SetStateAction<AiValidationDetails>
|
||||
>;
|
||||
setCurrentPrompt: React.Dispatch<React.SetStateAction<CurrentPrompt>>;
|
||||
revertAiChange: (productIndex: number, fieldKey: string) => void;
|
||||
isChangeReverted: (productIndex: number, fieldKey: string) => boolean;
|
||||
getFieldDisplayValueWithHighlight: (
|
||||
fieldKey: string,
|
||||
originalValue: any,
|
||||
correctedValue: any
|
||||
) => { originalHtml: string; correctedHtml: string };
|
||||
fields: readonly any[];
|
||||
debugData?: DebugData;
|
||||
}
|
||||
|
||||
export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
||||
aiValidationProgress,
|
||||
aiValidationDetails,
|
||||
currentPrompt,
|
||||
setAiValidationProgress,
|
||||
setAiValidationDetails,
|
||||
setCurrentPrompt,
|
||||
revertAiChange,
|
||||
isChangeReverted,
|
||||
getFieldDisplayValueWithHighlight,
|
||||
fields,
|
||||
}) => {
|
||||
const [costPerMillionTokens, setCostPerMillionTokens] = useState(1.25); // Default cost
|
||||
|
||||
// Create our own state to track changes
|
||||
const [localReversionState, setLocalReversionState] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
// Initialize local state from the isChangeReverted function when component mounts
|
||||
// or when aiValidationDetails changes
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
aiValidationDetails.changeDetails &&
|
||||
aiValidationDetails.changeDetails.length > 0
|
||||
) {
|
||||
const initialState: Record<string, boolean> = {};
|
||||
|
||||
aiValidationDetails.changeDetails.forEach((product) => {
|
||||
product.changes.forEach((change) => {
|
||||
const key = `${product.productIndex}-${change.field}`;
|
||||
initialState[key] = isChangeReverted(
|
||||
product.productIndex,
|
||||
change.field
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
setLocalReversionState(initialState);
|
||||
}
|
||||
}, [aiValidationDetails.changeDetails, isChangeReverted]);
|
||||
|
||||
// This function will toggle the local state for a given change
|
||||
const toggleChangeAcceptance = (productIndex: number, fieldKey: string) => {
|
||||
const key = `${productIndex}-${fieldKey}`;
|
||||
const currentlyRejected = !!localReversionState[key];
|
||||
|
||||
// Toggle the local state
|
||||
setLocalReversionState((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
|
||||
// Only call revertAiChange when toggling to rejected state
|
||||
// Since revertAiChange is specifically for rejecting changes
|
||||
if (!currentlyRejected) {
|
||||
revertAiChange(productIndex, fieldKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to check local reversion state
|
||||
const isChangeLocallyReverted = (
|
||||
productIndex: number,
|
||||
fieldKey: string
|
||||
): boolean => {
|
||||
const key = `${productIndex}-${fieldKey}`;
|
||||
return !!localReversionState[key];
|
||||
};
|
||||
|
||||
// Format time helper
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)} seconds`;
|
||||
} else {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate token costs
|
||||
const calculateTokenCost = (promptLength: number): number => {
|
||||
const estimatedTokens = Math.round(promptLength / 4);
|
||||
return (estimatedTokens / 1_000_000) * costPerMillionTokens * 100; // In cents
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return "—";
|
||||
}
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
// Use the prompt length from the current prompt
|
||||
const promptLength = currentPrompt.prompt ? currentPrompt.prompt.length : 0;
|
||||
const tokenUsage = aiValidationDetails.tokenUsage;
|
||||
const formattedReasoningEffort = aiValidationDetails.reasoningEffort
|
||||
? aiValidationDetails.reasoningEffort.charAt(0).toUpperCase() +
|
||||
aiValidationDetails.reasoningEffort.slice(1)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Current Prompt Dialog with Debug Info */}
|
||||
<Dialog
|
||||
open={currentPrompt.isOpen}
|
||||
onOpenChange={(open) =>
|
||||
setCurrentPrompt((prev) => ({ ...prev, isOpen: open }))
|
||||
}
|
||||
>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Current AI Prompt</DialogTitle>
|
||||
<DialogDescription>
|
||||
This is the current prompt that would be sent to the AI for
|
||||
validation
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col h-[calc(90vh-120px)] overflow-hidden">
|
||||
{/* Debug Information Section - Fixed at the top */}
|
||||
<div className="flex-shrink-0">
|
||||
{currentPrompt.isLoading ? (
|
||||
<div className="flex justify-center items-center h-[100px]"></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<Card className="py-2">
|
||||
<CardHeader className="py-2">
|
||||
<CardTitle className="text-base">
|
||||
Prompt Length
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Characters:
|
||||
</span>{" "}
|
||||
<span className="font-semibold">
|
||||
{promptLength}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Tokens:
|
||||
</span>{" "}
|
||||
<span className="font-semibold">
|
||||
~{Math.round(promptLength / 4)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="py-2">
|
||||
<CardHeader className="py-2">
|
||||
<CardTitle className="text-base">
|
||||
Cost Estimate
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-row items-center">
|
||||
<label
|
||||
htmlFor="costPerMillion"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
$
|
||||
</label>
|
||||
<input
|
||||
id="costPerMillion"
|
||||
className="w-[40px] px-1 border rounded-md text-sm"
|
||||
defaultValue={costPerMillionTokens.toFixed(2)}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
if (!isNaN(value)) {
|
||||
setCostPerMillionTokens(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="costPerMillion"
|
||||
className="text-sm text-muted-foreground ml-1"
|
||||
>
|
||||
per million input tokens
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Cost:</span>{" "}
|
||||
<span className="font-semibold">
|
||||
{calculateTokenCost(promptLength).toFixed(1)}¢
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="py-2">
|
||||
<CardHeader className="py-2">
|
||||
<CardTitle className="text-base">
|
||||
Processing Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{currentPrompt.debugData?.estimatedProcessingTime ? (
|
||||
currentPrompt.debugData.estimatedProcessingTime
|
||||
.seconds ? (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Estimated time:
|
||||
</span>{" "}
|
||||
<span className="font-semibold">
|
||||
{formatTime(
|
||||
currentPrompt.debugData
|
||||
.estimatedProcessingTime.seconds
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Based on{" "}
|
||||
{
|
||||
currentPrompt.debugData
|
||||
.estimatedProcessingTime.sampleCount
|
||||
}{" "}
|
||||
similar validation
|
||||
{currentPrompt.debugData
|
||||
.estimatedProcessingTime.sampleCount !== 1
|
||||
? "s"
|
||||
: ""}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No historical data available for this prompt
|
||||
size
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No processing time data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt Section - Scrollable content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{currentPrompt.isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentPrompt.debugData?.apiFormat ? (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Prompt Sources Card - Fixed at the top of the content area */}
|
||||
<Card className="py-2 mb-4 flex-shrink-0">
|
||||
<CardHeader className="py-2">
|
||||
<CardTitle className="text-base">
|
||||
Prompt Sources
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-purple-100 hover:bg-purple-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("system-message")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
System
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-green-100 hover:bg-green-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("general-section")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
General
|
||||
</Badge>
|
||||
|
||||
{currentPrompt.debugData.promptSources?.companyPrompts?.map(
|
||||
(company, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="outline"
|
||||
className="bg-blue-100 hover:bg-blue-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("company-section")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
{company.companyName ||
|
||||
`Company ${company.company}`}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-amber-100 hover:bg-amber-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("taxonomy-section")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Taxonomy
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-pink-100 hover:bg-pink-200 cursor-pointer"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("product-section")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Products
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ScrollArea className="flex-1 w-full overflow-y-auto">
|
||||
{currentPrompt.debugData.apiFormat.map(
|
||||
(message, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border rounded-md p-2 mb-4"
|
||||
>
|
||||
<div
|
||||
id={
|
||||
message.role === "system"
|
||||
? "system-message"
|
||||
: ""
|
||||
}
|
||||
className={`p-2 mb-2 rounded-sm font-medium ${
|
||||
message.role === "system"
|
||||
? "bg-purple-50 text-purple-800"
|
||||
: "bg-green-50 text-green-800"
|
||||
}`}
|
||||
>
|
||||
Role: {message.role}
|
||||
</div>
|
||||
|
||||
<Code
|
||||
className={`whitespace-pre-wrap p-4 break-normal max-w-full ${
|
||||
message.role === "system"
|
||||
? "bg-purple-50/30"
|
||||
: "bg-green-50/30"
|
||||
}`}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<div className="text-wrapper">
|
||||
{(() => {
|
||||
const content = message.content;
|
||||
|
||||
// Find section boundaries by looking for specific markers
|
||||
const companySpecificStartIndex =
|
||||
content.indexOf(
|
||||
"--- COMPANY-SPECIFIC INSTRUCTIONS ---"
|
||||
);
|
||||
const companySpecificEndIndex =
|
||||
content.indexOf(
|
||||
"--- END COMPANY-SPECIFIC INSTRUCTIONS ---"
|
||||
);
|
||||
|
||||
const taxonomyStartIndex =
|
||||
content.indexOf(
|
||||
"All Available Categories:"
|
||||
);
|
||||
const taxonomyFallbackStartIndex =
|
||||
content.indexOf(
|
||||
"Available Categories:"
|
||||
);
|
||||
const actualTaxonomyStartIndex =
|
||||
taxonomyStartIndex >= 0
|
||||
? taxonomyStartIndex
|
||||
: taxonomyFallbackStartIndex;
|
||||
|
||||
const productDataStartIndex =
|
||||
content.indexOf(
|
||||
"----------Here is the product data to validate----------"
|
||||
);
|
||||
|
||||
// If we can't find any markers, just return the content as-is
|
||||
if (
|
||||
actualTaxonomyStartIndex < 0 &&
|
||||
productDataStartIndex < 0 &&
|
||||
companySpecificStartIndex < 0
|
||||
) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Determine section indices
|
||||
let generalEndIndex = content.length;
|
||||
|
||||
if (companySpecificStartIndex >= 0) {
|
||||
generalEndIndex =
|
||||
companySpecificStartIndex;
|
||||
} else if (
|
||||
actualTaxonomyStartIndex >= 0
|
||||
) {
|
||||
generalEndIndex =
|
||||
actualTaxonomyStartIndex;
|
||||
} else if (productDataStartIndex >= 0) {
|
||||
generalEndIndex = productDataStartIndex;
|
||||
}
|
||||
|
||||
// Determine where taxonomy starts
|
||||
let taxonomyEndIndex = content.length;
|
||||
if (productDataStartIndex >= 0) {
|
||||
taxonomyEndIndex =
|
||||
productDataStartIndex;
|
||||
}
|
||||
|
||||
// Segments to render with appropriate styling
|
||||
const segments = [];
|
||||
|
||||
// General section (beginning to company/taxonomy/product)
|
||||
if (generalEndIndex > 0) {
|
||||
segments.push(
|
||||
<div
|
||||
id="general-section"
|
||||
key="general"
|
||||
className="border-l-4 border-green-500 pl-4 py-0 my-1"
|
||||
>
|
||||
<div className="text-xs font-semibold text-green-700 mb-2">
|
||||
General Prompt
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{content.substring(
|
||||
0,
|
||||
generalEndIndex
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Company-specific section if present
|
||||
if (
|
||||
companySpecificStartIndex >= 0 &&
|
||||
companySpecificEndIndex >= 0
|
||||
) {
|
||||
segments.push(
|
||||
<div
|
||||
id="company-section"
|
||||
key="company"
|
||||
className="border-l-4 border-blue-500 pl-4 py-0 my-1"
|
||||
>
|
||||
<div className="text-xs font-semibold text-blue-700 mb-2">
|
||||
Company-Specific Instructions
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words break-all">
|
||||
{content.substring(
|
||||
companySpecificStartIndex,
|
||||
companySpecificEndIndex +
|
||||
"--- END COMPANY-SPECIFIC INSTRUCTIONS ---"
|
||||
.length
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Taxonomy section
|
||||
if (actualTaxonomyStartIndex >= 0) {
|
||||
const taxEnd = taxonomyEndIndex;
|
||||
segments.push(
|
||||
<div
|
||||
id="taxonomy-section"
|
||||
key="taxonomy"
|
||||
className="border-l-4 border-amber-500 pl-4 py-0 my-1"
|
||||
>
|
||||
<div className="text-xs font-semibold text-amber-700 mb-2">
|
||||
Taxonomy Data
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words break-all">
|
||||
{content.substring(
|
||||
actualTaxonomyStartIndex,
|
||||
taxEnd
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Product data section
|
||||
if (productDataStartIndex >= 0) {
|
||||
segments.push(
|
||||
<div
|
||||
id="product-section"
|
||||
key="product"
|
||||
className="border-l-4 border-pink-500 pl-4 py-0 my-1"
|
||||
>
|
||||
<div className="text-xs font-semibold text-pink-700 mb-2">
|
||||
Product Data
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words break-all">
|
||||
{content.substring(
|
||||
productDataStartIndex
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{segments}</>;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words break-all">
|
||||
{message.content}
|
||||
</pre>
|
||||
)}
|
||||
</Code>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<Code className="whitespace-pre-wrap break-words break-all p-4 max-w-full overflow-x-hidden">
|
||||
{currentPrompt.prompt}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* AI Validation Progress Dialog */}
|
||||
<Dialog
|
||||
open={aiValidationProgress.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Only allow closing if validation failed
|
||||
if (!open && aiValidationProgress.step === -1) {
|
||||
setAiValidationProgress((prev) => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Validation Progress</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{
|
||||
width: `${
|
||||
aiValidationProgress.progressPercent !== undefined
|
||||
? Math.round(aiValidationProgress.progressPercent)
|
||||
: Math.round((aiValidationProgress.step / 5) * 100)
|
||||
}%`,
|
||||
backgroundColor:
|
||||
aiValidationProgress.step === -1
|
||||
? "var(--destructive)"
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground w-12 text-right">
|
||||
{aiValidationProgress.step === -1
|
||||
? "❌"
|
||||
: `${
|
||||
aiValidationProgress.progressPercent !== undefined
|
||||
? Math.round(aiValidationProgress.progressPercent)
|
||||
: Math.round((aiValidationProgress.step / 5) * 100)
|
||||
}%`}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{aiValidationProgress.status}
|
||||
</p>
|
||||
{(() => {
|
||||
// Only show time remaining if we have an estimate and are in progress
|
||||
return (
|
||||
aiValidationProgress.estimatedSeconds &&
|
||||
aiValidationProgress.elapsedSeconds !== undefined &&
|
||||
aiValidationProgress.step > 0 &&
|
||||
aiValidationProgress.step < 5 && (() => {
|
||||
const elapsedSeconds = aiValidationProgress.elapsedSeconds;
|
||||
const totalEstimatedSeconds = aiValidationProgress.estimatedSeconds;
|
||||
const remainingSeconds = Math.max(
|
||||
0,
|
||||
totalEstimatedSeconds - elapsedSeconds
|
||||
);
|
||||
|
||||
if (remainingSeconds <= 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = remainingSeconds < 60
|
||||
? `Approximately ${Math.round(remainingSeconds)} seconds remaining`
|
||||
: (() => {
|
||||
const minutes = Math.floor(remainingSeconds / 60);
|
||||
const seconds = Math.round(remainingSeconds % 60);
|
||||
return `Approximately ${minutes}m ${seconds}s remaining`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="text-center text-sm">
|
||||
{message}
|
||||
{aiValidationProgress.promptLength && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Prompt length:{" "}
|
||||
{aiValidationProgress.promptLength.toLocaleString()}{" "}
|
||||
characters
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* AI Validation Results Dialog */}
|
||||
<Dialog
|
||||
open={aiValidationDetails.isOpen}
|
||||
onOpenChange={(open) =>
|
||||
setAiValidationDetails((prev) => ({ ...prev, isOpen: open }))
|
||||
}
|
||||
>
|
||||
<DialogContent className="max-w-6xl w-[90vw] max-h-[90vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Validation Results</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the changes and warnings suggested by the AI
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Protected permission="admin:debug">
|
||||
{(aiValidationDetails.model || tokenUsage || formattedReasoningEffort) && (
|
||||
<div className="mb-4 rounded-md border bg-muted/40 p-4 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{aiValidationDetails.model && (
|
||||
<Badge variant="outline">
|
||||
Model · {aiValidationDetails.model}
|
||||
</Badge>
|
||||
)}
|
||||
{formattedReasoningEffort && (
|
||||
<Badge variant="secondary">
|
||||
Reasoning {formattedReasoningEffort}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{tokenUsage && (
|
||||
<div className="mt-3 grid gap-4 text-xs sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div>
|
||||
<span className="block text-muted-foreground">
|
||||
Prompt tokens
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatNumber(tokenUsage.prompt)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-muted-foreground">
|
||||
Completion tokens
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatNumber(tokenUsage.completion)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-muted-foreground">
|
||||
Total tokens
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatNumber(tokenUsage.total)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-muted-foreground">
|
||||
Reasoning tokens
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatNumber(tokenUsage.reasoning)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-muted-foreground">
|
||||
Cached prompt tokens
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatNumber(tokenUsage.cachedPrompt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Protected>
|
||||
{(aiValidationDetails.summary ||
|
||||
(aiValidationDetails.changes && aiValidationDetails.changes.length > 0) ||
|
||||
(aiValidationDetails.warnings && aiValidationDetails.warnings.length > 0)) && (
|
||||
<Card className="mb-4 max-h-[25vh] overflow-auto">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Overall Assessment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
{aiValidationDetails.changes &&
|
||||
aiValidationDetails.changes.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground mb-1">
|
||||
Key Changes
|
||||
</h4>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{aiValidationDetails.changes.map((change, idx) => (
|
||||
<li key={`change-${idx}`}>{change}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{aiValidationDetails.warnings &&
|
||||
aiValidationDetails.warnings.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground mb-1">
|
||||
Warnings
|
||||
</h4>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{aiValidationDetails.warnings.map((warning, idx) => (
|
||||
<li key={`warning-${idx}`}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{aiValidationDetails.summary && (
|
||||
<p className="leading-relaxed">{aiValidationDetails.summary}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
{aiValidationDetails.changeDetails &&
|
||||
aiValidationDetails.changeDetails.length > 0 ? (
|
||||
<div className="mb-6 space-y-6">
|
||||
<h3 className="font-semibold text-lg">Detailed Changes:</h3>
|
||||
{aiValidationDetails.changeDetails.map((product, i) => {
|
||||
// Find the title change if it exists
|
||||
const titleChange = product.changes.find(
|
||||
(c) => c.field === "title"
|
||||
);
|
||||
const titleValue = titleChange
|
||||
? titleChange.corrected
|
||||
: product.title;
|
||||
|
||||
return (
|
||||
<div key={`product-${i}`} className="border rounded-md p-4">
|
||||
<h4 className="font-medium text-base mb-3">
|
||||
{titleValue || `Product ${product.productIndex + 1}`}
|
||||
</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="">Field</TableHead>
|
||||
<TableHead className="w-[35%]">
|
||||
Original Value
|
||||
</TableHead>
|
||||
<TableHead className="w-[35%]">
|
||||
Corrected Value
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
Accept Changes?
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{product.changes.map((change, j) => {
|
||||
const field = fields.find(
|
||||
(f) => f.key === change.field
|
||||
);
|
||||
const fieldLabel = field
|
||||
? field.label
|
||||
: change.field;
|
||||
const isReverted = isChangeLocallyReverted(
|
||||
product.productIndex,
|
||||
change.field
|
||||
);
|
||||
|
||||
// Get highlighted differences
|
||||
const { originalHtml, correctedHtml } =
|
||||
getFieldDisplayValueWithHighlight(
|
||||
change.field,
|
||||
change.original,
|
||||
change.corrected
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={`change-${j}`}>
|
||||
<TableCell className="font-medium">
|
||||
{fieldLabel}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: originalHtml,
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: correctedHtml,
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right align-top">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Toggle to Accepted state if currently rejected
|
||||
toggleChangeAcceptance(
|
||||
product.productIndex,
|
||||
change.field
|
||||
);
|
||||
}}
|
||||
className={
|
||||
!isReverted
|
||||
? "bg-green-100 text-green-600 border-green-300 flex items-center"
|
||||
: "border-gray-200 text-gray-600 hover:bg-green-50 hover:text-green-600 hover:border-green-200 flex items-center"
|
||||
}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Toggle to Rejected state if currently accepted
|
||||
toggleChangeAcceptance(
|
||||
product.productIndex,
|
||||
change.field
|
||||
);
|
||||
}}
|
||||
className={
|
||||
isReverted
|
||||
? "bg-red-100 text-red-600 border-red-300 flex items-center"
|
||||
: "border-gray-200 text-gray-600 hover:bg-red-50 hover:text-red-600 hover:border-red-200 flex items-center"
|
||||
}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>No field-level changes were suggested by the AI.</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
// Define MultiSelectCell component to fix the import issue
|
||||
type MultiSelectCellProps = {
|
||||
field: string;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
options: any[];
|
||||
hasErrors: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Using _ to indicate intentionally unused parameters
|
||||
const MultiSelectCell = (_: MultiSelectCellProps) => {
|
||||
// This is a placeholder implementation
|
||||
return null;
|
||||
};
|
||||
|
||||
const BaseCellContent = ({ fieldType, field, value, onChange, options, hasErrors, className }: {
|
||||
fieldType: string;
|
||||
field: string;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
options: any[];
|
||||
hasErrors: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
if (fieldType === 'multi-select' || fieldType === 'multi-input') {
|
||||
return (
|
||||
<MultiSelectCell
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default BaseCellContent;
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Loader2, Check, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface InitializationTask {
|
||||
label: string
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed'
|
||||
}
|
||||
|
||||
interface InitializingValidationProps {
|
||||
totalRows: number
|
||||
tasks: InitializationTask[]
|
||||
}
|
||||
|
||||
export const InitializingValidation: React.FC<InitializingValidationProps> = ({
|
||||
totalRows,
|
||||
tasks
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[calc(100vh-10rem)]">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Initializing Validation</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">Processing {totalRows} rows...</p>
|
||||
|
||||
{/* Task checklist */}
|
||||
<div className="w-80 space-y-2">
|
||||
{tasks.map((task, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-md transition-colors",
|
||||
task.status === 'completed' && "bg-green-50 border border-green-200",
|
||||
task.status === 'failed' && "bg-red-50 border border-red-200",
|
||||
task.status === 'in_progress' && "bg-blue-50 border border-blue-200",
|
||||
task.status === 'pending' && "bg-blue-50 border border-blue-200"
|
||||
)}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{task.status === 'completed' && (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
)}
|
||||
{task.status === 'failed' && (
|
||||
<X className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
{task.status === 'in_progress' && (
|
||||
<Loader2 className="h-4 w-4 text-blue-600 animate-spin" />
|
||||
)}
|
||||
{task.status === 'pending' && (
|
||||
<Loader2 className="h-4 w-4 text-blue-600 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task label */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
task.status === 'completed' && "text-green-700",
|
||||
task.status === 'failed' && "text-red-700",
|
||||
task.status === 'in_progress' && "text-blue-700",
|
||||
task.status === 'pending' && "text-blue-700"
|
||||
)}
|
||||
>
|
||||
{task.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InitializingValidation
|
||||
-328
@@ -1,328 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Template } from '../hooks/validationTypes'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
interface SearchableTemplateSelectProps {
|
||||
templates: Template[] | undefined;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
getTemplateDisplayText: (templateId: string | null) => string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
defaultBrand?: string;
|
||||
}
|
||||
|
||||
const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
||||
templates = [],
|
||||
value,
|
||||
onValueChange,
|
||||
getTemplateDisplayText,
|
||||
placeholder = "Select template",
|
||||
className,
|
||||
triggerClassName,
|
||||
defaultBrand,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Set default brand when component mounts or defaultBrand changes
|
||||
useEffect(() => {
|
||||
if (defaultBrand) {
|
||||
setSelectedBrand(defaultBrand);
|
||||
}
|
||||
}, [defaultBrand]);
|
||||
|
||||
// Force a re-render when templates change from empty to non-empty
|
||||
useEffect(() => {
|
||||
if (templates && templates.length > 0) {
|
||||
// Force a re-render by updating state
|
||||
setSearchTerm("");
|
||||
}
|
||||
}, [templates]);
|
||||
|
||||
// Handle wheel events for scrolling
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
const scrollArea = e.currentTarget;
|
||||
scrollArea.scrollTop += e.deltaY;
|
||||
};
|
||||
|
||||
// Extract unique brands from templates
|
||||
const brands = useMemo(() => {
|
||||
try {
|
||||
if (!Array.isArray(templates) || templates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const brandSet = new Set<string>();
|
||||
const brandNames: {id: string, name: string}[] = [];
|
||||
|
||||
templates.forEach(template => {
|
||||
if (!template?.company) return;
|
||||
|
||||
const companyId = template.company;
|
||||
if (!brandSet.has(companyId)) {
|
||||
brandSet.add(companyId);
|
||||
|
||||
// Try to get the company name from the template display text
|
||||
try {
|
||||
const displayText = getTemplateDisplayText(template.id.toString());
|
||||
const companyName = displayText.split(' - ')[0];
|
||||
brandNames.push({ id: companyId, name: companyName || companyId });
|
||||
} catch (err) {
|
||||
brandNames.push({ id: companyId, name: companyId });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return brandNames.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}, [templates, getTemplateDisplayText]);
|
||||
|
||||
// Group templates by company for better organization
|
||||
const groupedTemplates = useMemo(() => {
|
||||
try {
|
||||
if (!Array.isArray(templates) || templates.length === 0) return {};
|
||||
|
||||
const groups: Record<string, Template[]> = {};
|
||||
|
||||
templates.forEach(template => {
|
||||
if (!template?.company) return;
|
||||
|
||||
const companyId = template.company;
|
||||
if (!groups[companyId]) {
|
||||
groups[companyId] = [];
|
||||
}
|
||||
groups[companyId].push(template);
|
||||
});
|
||||
|
||||
return groups;
|
||||
} catch (err) {
|
||||
return {};
|
||||
}
|
||||
}, [templates]);
|
||||
|
||||
// Filter templates based on selected brand and search term
|
||||
const filteredTemplates = useMemo(() => {
|
||||
try {
|
||||
if (!Array.isArray(templates) || templates.length === 0) return [];
|
||||
|
||||
// First filter by brand if selected
|
||||
let brandFiltered = templates;
|
||||
if (selectedBrand) {
|
||||
// Check if the selected brand has any templates
|
||||
const brandTemplates = templates.filter(t => t?.company === selectedBrand);
|
||||
|
||||
// If the selected brand has templates, use them; otherwise, show all templates
|
||||
brandFiltered = brandTemplates.length > 0 ? brandTemplates : templates;
|
||||
}
|
||||
|
||||
// Then filter by search term if provided
|
||||
if (!searchTerm.trim()) return brandFiltered;
|
||||
|
||||
const lowerSearchTerm = searchTerm.toLowerCase();
|
||||
return brandFiltered.filter(template => {
|
||||
if (!template?.id) return false;
|
||||
try {
|
||||
const displayText = getTemplateDisplayText(template.id.toString());
|
||||
const productType = template.product_type?.toLowerCase() || '';
|
||||
|
||||
return displayText.toLowerCase().includes(lowerSearchTerm) ||
|
||||
productType.includes(lowerSearchTerm);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}, [templates, selectedBrand, searchTerm, getTemplateDisplayText]);
|
||||
|
||||
// Handle errors gracefully
|
||||
const getDisplayText = useCallback(() => {
|
||||
try {
|
||||
if (!value) return placeholder;
|
||||
const template = templates.find(t => t.id.toString() === value);
|
||||
if (!template) return placeholder;
|
||||
|
||||
// Get the original display text
|
||||
const originalText = getTemplateDisplayText(value);
|
||||
|
||||
// Check if it has the expected format "Brand - Product Type"
|
||||
if (originalText.includes(' - ')) {
|
||||
const [brand, productType] = originalText.split(' - ', 2);
|
||||
// Reverse the order to "Product Type - Brand"
|
||||
return `${productType} - ${brand}`;
|
||||
}
|
||||
|
||||
// If it doesn't match the expected format, return the original text
|
||||
return originalText;
|
||||
} catch (err) {
|
||||
console.error('Error getting display text:', err);
|
||||
return placeholder;
|
||||
}
|
||||
}, [getTemplateDisplayText, placeholder, value, templates]);
|
||||
|
||||
// Safe render function for CommandItem
|
||||
const renderCommandItem = useCallback((template: Template) => {
|
||||
if (!template?.id) return null;
|
||||
|
||||
try {
|
||||
const displayText = getTemplateDisplayText(template.id.toString());
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={template.id}
|
||||
value={template.id.toString()}
|
||||
onSelect={(currentValue) => {
|
||||
try {
|
||||
onValueChange(currentValue);
|
||||
setOpen(false);
|
||||
setSearchTerm("");
|
||||
} catch (err) {
|
||||
console.error('Error selecting template:', err);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{displayText}</span>
|
||||
{value === template.id.toString() && <Check className="h-4 w-4 ml-2" />}
|
||||
</CommandItem>
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error rendering template item:', err);
|
||||
return null;
|
||||
}
|
||||
}, [onValueChange, value, getTemplateDisplayText]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between overflow-hidden", triggerClassName)}
|
||||
>
|
||||
<span className="truncate overflow-hidden mr-1">{getDisplayText()}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={cn("w-[300px] p-0", className)}>
|
||||
<Command>
|
||||
<div className="flex flex-col p-2 gap-2">
|
||||
{brands.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedBrand || "all"}
|
||||
onValueChange={(value) => {
|
||||
setSelectedBrand(value === "all" ? null : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="All Brands" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Brands</SelectItem>
|
||||
{brands.map(brand => (
|
||||
<SelectItem key={brand.id} value={brand.id}>
|
||||
{brand.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandInput
|
||||
placeholder="Search by product type..."
|
||||
value={searchTerm}
|
||||
onValueChange={setSearchTerm}
|
||||
className="h-8 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommandEmpty>
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">No templates found.</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandList>
|
||||
<ScrollArea className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
|
||||
{!searchTerm ? (
|
||||
selectedBrand ? (
|
||||
groupedTemplates[selectedBrand]?.length > 0 ? (
|
||||
<CommandGroup heading={brands.find(b => b.id === selectedBrand)?.name || selectedBrand}>
|
||||
{groupedTemplates[selectedBrand]?.map(template => renderCommandItem(template))}
|
||||
</CommandGroup>
|
||||
) : (
|
||||
// If selected brand has no templates, show all brands
|
||||
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
|
||||
const brand = brands.find(b => b.id === companyId);
|
||||
const companyName = brand ? brand.name : companyId;
|
||||
|
||||
return (
|
||||
<CommandGroup key={companyId} heading={companyName}>
|
||||
{companyTemplates.map(template => renderCommandItem(template))}
|
||||
</CommandGroup>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : (
|
||||
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
|
||||
const brand = brands.find(b => b.id === companyId);
|
||||
const companyName = brand ? brand.name : companyId;
|
||||
|
||||
return (
|
||||
<CommandGroup key={companyId} heading={companyName}>
|
||||
{companyTemplates.map(template => renderCommandItem(template))}
|
||||
</CommandGroup>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : (
|
||||
<CommandGroup>
|
||||
{filteredTemplates.map(template => renderCommandItem(template))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchableTemplateSelect;
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import ValidationTable from './ValidationTable'
|
||||
import { RowSelectionState } from '@tanstack/react-table'
|
||||
import { Fields } from '../../../types'
|
||||
import { Template } from '../hooks/validationTypes'
|
||||
|
||||
interface UpcValidationTableAdapterProps<T extends string> {
|
||||
data: any[]
|
||||
fields: Fields<string>
|
||||
validationErrors: Map<number, Record<string, any[]>>
|
||||
rowSelection: RowSelectionState
|
||||
setRowSelection: React.Dispatch<React.SetStateAction<RowSelectionState>>
|
||||
updateRow: (rowIndex: number, key: T, value: any) => void
|
||||
filters: any
|
||||
templates: Template[]
|
||||
applyTemplate: (templateId: string, rowIndexes: number[]) => void
|
||||
getTemplateDisplayText: (templateId: string | null) => string
|
||||
isValidatingUpc: (rowIndex: number) => boolean
|
||||
validatingUpcRows: number[]
|
||||
copyDown: (rowIndex: number, fieldKey: string, endRowIndex?: number) => void
|
||||
validatingCells: Set<string>
|
||||
isLoadingTemplates: boolean
|
||||
editingCells: Set<string>
|
||||
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
rowProductLines: Record<string, any[]>
|
||||
rowSublines: Record<string, any[]>
|
||||
isLoadingLines: Record<string, boolean>
|
||||
isLoadingSublines: Record<string, boolean>
|
||||
upcValidation: {
|
||||
validatingRows: Set<number>
|
||||
getItemNumber: (rowIndex: number) => string | undefined
|
||||
}
|
||||
itemNumbers?: Map<number, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* UpcValidationTableAdapter component - connects UPC validation data to ValidationTable
|
||||
*
|
||||
* This component adapts UPC validation data and functionality to work with the core ValidationTable,
|
||||
* transforming item numbers and validation states into a format the table component can render.
|
||||
*/
|
||||
function UpcValidationTableAdapter<T extends string>({
|
||||
data,
|
||||
fields,
|
||||
validationErrors,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
updateRow,
|
||||
filters,
|
||||
templates,
|
||||
applyTemplate,
|
||||
getTemplateDisplayText,
|
||||
isValidatingUpc,
|
||||
validatingUpcRows,
|
||||
copyDown,
|
||||
validatingCells: externalValidatingCells,
|
||||
isLoadingTemplates,
|
||||
editingCells,
|
||||
setEditingCells,
|
||||
rowProductLines,
|
||||
rowSublines,
|
||||
isLoadingLines,
|
||||
isLoadingSublines,
|
||||
upcValidation,
|
||||
itemNumbers
|
||||
}: UpcValidationTableAdapterProps<T>) {
|
||||
// Prepare the validation table with UPC data
|
||||
|
||||
// Create combined validatingCells set from validating rows and external cells
|
||||
const combinedValidatingCells = useMemo(() => {
|
||||
const combined = new Set<string>();
|
||||
|
||||
// Add UPC validation cells
|
||||
upcValidation.validatingRows.forEach(rowIndex => {
|
||||
// Only mark the item_number cells as validating, NOT the UPC or supplier
|
||||
combined.add(`${rowIndex}-item_number`);
|
||||
});
|
||||
|
||||
// Add any other validating cells from state
|
||||
externalValidatingCells.forEach(cellKey => {
|
||||
combined.add(cellKey);
|
||||
});
|
||||
|
||||
return combined;
|
||||
}, [upcValidation.validatingRows, externalValidatingCells]);
|
||||
|
||||
// Create a consolidated item numbers map from all sources
|
||||
const consolidatedItemNumbers = useMemo(() => {
|
||||
const result = new Map<number, string>();
|
||||
|
||||
// First add from itemNumbers directly - this is the source of truth for template applications
|
||||
if (itemNumbers) {
|
||||
itemNumbers.forEach((itemNumber, rowIndex) => {
|
||||
result.set(rowIndex, itemNumber);
|
||||
});
|
||||
}
|
||||
|
||||
// For each row, ensure we have the most up-to-date item number
|
||||
data.forEach((_, index) => {
|
||||
// Check if upcValidation has an item number for this row
|
||||
const itemNumber = upcValidation.getItemNumber(index);
|
||||
if (itemNumber) {
|
||||
result.set(index, itemNumber);
|
||||
}
|
||||
|
||||
// Also check if it's directly in the data
|
||||
const dataItemNumber = data[index].item_number;
|
||||
if (dataItemNumber && !result.has(index)) {
|
||||
result.set(index, dataItemNumber);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [data, itemNumbers, upcValidation]);
|
||||
|
||||
// Create upcValidationResults map using the consolidated item numbers
|
||||
const upcValidationResults = useMemo(() => {
|
||||
const results = new Map<number, { itemNumber: string }>();
|
||||
|
||||
// Populate with our consolidated item numbers
|
||||
consolidatedItemNumbers.forEach((itemNumber, rowIndex) => {
|
||||
results.set(rowIndex, { itemNumber });
|
||||
});
|
||||
|
||||
return results;
|
||||
}, [consolidatedItemNumbers]);
|
||||
|
||||
// Render the validation table with the provided props and UPC data
|
||||
return (
|
||||
<ValidationTable
|
||||
data={data}
|
||||
fields={fields}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
updateRow={updateRow as unknown as (rowIndex: number, key: string, value: any) => void}
|
||||
validationErrors={validationErrors}
|
||||
isValidatingUpc={isValidatingUpc}
|
||||
validatingUpcRows={validatingUpcRows}
|
||||
filters={filters}
|
||||
templates={templates}
|
||||
applyTemplate={applyTemplate}
|
||||
getTemplateDisplayText={getTemplateDisplayText}
|
||||
validatingCells={combinedValidatingCells}
|
||||
itemNumbers={consolidatedItemNumbers}
|
||||
isLoadingTemplates={isLoadingTemplates}
|
||||
copyDown={copyDown}
|
||||
upcValidationResults={upcValidationResults}
|
||||
rowProductLines={rowProductLines}
|
||||
rowSublines={rowSublines}
|
||||
isLoadingLines={isLoadingLines}
|
||||
isLoadingSublines={isLoadingSublines}
|
||||
editingCells={editingCells}
|
||||
setEditingCells={setEditingCells}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpcValidationTableAdapter
|
||||
-661
@@ -1,661 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Field, ErrorType } from '../../../types'
|
||||
import { AlertCircle, ArrowDown, Wand2, X } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import InputCell from './cells/InputCell'
|
||||
import SelectCell from './cells/SelectCell'
|
||||
import MultiSelectCell from './cells/MultiSelectCell'
|
||||
import { TableCell } from '@/components/ui/table'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import config from '@/config'
|
||||
|
||||
// Context for copy down selection mode
|
||||
export const CopyDownContext = React.createContext<{
|
||||
isInCopyDownMode: boolean;
|
||||
sourceRowIndex: number | null;
|
||||
sourceFieldKey: string | null;
|
||||
targetRowIndex: number | null;
|
||||
setIsInCopyDownMode: (value: boolean) => void;
|
||||
setSourceRowIndex: (value: number | null) => void;
|
||||
setSourceFieldKey: (value: string | null) => void;
|
||||
setTargetRowIndex: (value: number | null) => void;
|
||||
handleCopyDownComplete: (sourceRowIndex: number, fieldKey: string, targetRowIndex: number) => void;
|
||||
}>({
|
||||
isInCopyDownMode: false,
|
||||
sourceRowIndex: null,
|
||||
sourceFieldKey: null,
|
||||
targetRowIndex: null,
|
||||
setIsInCopyDownMode: () => {},
|
||||
setSourceRowIndex: () => {},
|
||||
setSourceFieldKey: () => {},
|
||||
setTargetRowIndex: () => {},
|
||||
handleCopyDownComplete: () => {},
|
||||
});
|
||||
|
||||
// Define error object type
|
||||
type ErrorObject = {
|
||||
message: string;
|
||||
level: string;
|
||||
source?: string;
|
||||
type?: ErrorType;
|
||||
}
|
||||
|
||||
// Helper function to check if a value is empty - utility function shared by all components
|
||||
const isEmpty = (val: any): boolean =>
|
||||
val === undefined ||
|
||||
val === null ||
|
||||
val === '' ||
|
||||
(Array.isArray(val) && val.length === 0) ||
|
||||
(typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 0);
|
||||
|
||||
// Memoized validation icon component
|
||||
const ValidationIcon = React.memo(({ error }: { error: ErrorObject }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="cursor-help">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px] text-wrap break-words">
|
||||
<p>{error.message}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
));
|
||||
|
||||
ValidationIcon.displayName = 'ValidationIcon';
|
||||
|
||||
// Memoized base cell content component
|
||||
const BaseCellContent = React.memo(({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
hasErrors,
|
||||
options = [],
|
||||
className = '',
|
||||
fieldKey = '',
|
||||
onStartEdit,
|
||||
onEndEdit
|
||||
}: {
|
||||
field: Field<string>;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
hasErrors: boolean;
|
||||
options?: readonly any[];
|
||||
className?: string;
|
||||
fieldKey?: string;
|
||||
onStartEdit?: () => void;
|
||||
onEndEdit?: () => void;
|
||||
}) => {
|
||||
// Get field type information
|
||||
const fieldType = fieldKey === 'line' || fieldKey === 'subline'
|
||||
? 'select'
|
||||
: typeof field.fieldType === 'string'
|
||||
? field.fieldType
|
||||
: field.fieldType?.type || 'input';
|
||||
|
||||
// Check for multiline input
|
||||
const isMultiline = typeof field.fieldType === 'object' &&
|
||||
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
|
||||
field.fieldType.multiline === true;
|
||||
|
||||
// Check for price field
|
||||
const isPrice = typeof field.fieldType === 'object' &&
|
||||
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
|
||||
field.fieldType.price === true;
|
||||
|
||||
// Special case for line and subline - check this first, before any other field type checks
|
||||
if (fieldKey === 'line' || fieldKey === 'subline') {
|
||||
// Force these fields to always use SelectCell regardless of fieldType
|
||||
return (
|
||||
<SelectCell
|
||||
field={{...field, fieldType: { type: 'select', options }}}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={onStartEdit}
|
||||
onEndEdit={onEndEdit}
|
||||
options={options}
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'select') {
|
||||
return (
|
||||
<SelectCell
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={onStartEdit}
|
||||
onEndEdit={onEndEdit}
|
||||
options={options}
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldType === 'multi-select' || fieldType === 'multi-input') {
|
||||
return (
|
||||
<MultiSelectCell
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={onStartEdit}
|
||||
onEndEdit={onEndEdit}
|
||||
options={options}
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InputCell
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={onStartEdit}
|
||||
onEndEdit={onEndEdit}
|
||||
hasErrors={hasErrors}
|
||||
isMultiline={isMultiline}
|
||||
isPrice={isPrice}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// Shallow array comparison for options if arrays
|
||||
const optionsEqual = prev.options === next.options ||
|
||||
(Array.isArray(prev.options) && Array.isArray(next.options) &&
|
||||
prev.options.length === next.options.length &&
|
||||
prev.options.every((opt, idx) => opt === (next.options as any[])[idx]));
|
||||
|
||||
return (
|
||||
prev.value === next.value &&
|
||||
prev.hasErrors === next.hasErrors &&
|
||||
prev.field === next.field &&
|
||||
prev.className === next.className &&
|
||||
optionsEqual
|
||||
);
|
||||
});
|
||||
|
||||
BaseCellContent.displayName = 'BaseCellContent';
|
||||
|
||||
export interface ValidationCellProps {
|
||||
field: Field<string>
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
errors: ErrorObject[]
|
||||
isValidating?: boolean
|
||||
fieldKey: string
|
||||
options?: readonly any[]
|
||||
itemNumber?: string
|
||||
width: number
|
||||
rowIndex: number
|
||||
copyDown?: (endRowIndex?: number) => void
|
||||
totalRows?: number
|
||||
rowData: Record<string, any>
|
||||
editingCells: Set<string>
|
||||
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
}
|
||||
|
||||
// Add efficient error message extraction function
|
||||
|
||||
// Highly optimized error processing function with fast paths for common cases
|
||||
function processErrors(value: any, errors: ErrorObject[]): {
|
||||
hasError: boolean;
|
||||
isRequiredButEmpty: boolean;
|
||||
shouldShowErrorIcon: boolean;
|
||||
errorMessages: string;
|
||||
} {
|
||||
// Fast path - if no errors or empty error array, return immediately
|
||||
if (!errors || errors.length === 0) {
|
||||
return {
|
||||
hasError: false,
|
||||
isRequiredButEmpty: false,
|
||||
shouldShowErrorIcon: false,
|
||||
errorMessages: ''
|
||||
};
|
||||
}
|
||||
|
||||
// Use the shared isEmpty function for value checking
|
||||
const valueIsEmpty = isEmpty(value);
|
||||
|
||||
// Fast path for the most common case - required field with empty value
|
||||
if (valueIsEmpty && errors.length === 1 && errors[0].type === ErrorType.Required) {
|
||||
return {
|
||||
hasError: true,
|
||||
isRequiredButEmpty: true,
|
||||
shouldShowErrorIcon: false,
|
||||
errorMessages: ''
|
||||
};
|
||||
}
|
||||
|
||||
// For non-empty values with errors, we need to show error icons
|
||||
const hasError = errors.some(error => error.level === 'error' || error.level === 'warning');
|
||||
|
||||
// For empty values with required errors, show only a border
|
||||
const isRequiredButEmpty = valueIsEmpty && errors.some(error => error.type === ErrorType.Required);
|
||||
|
||||
// Show error icons for non-empty fields with errors, or for empty fields with non-required errors
|
||||
const shouldShowErrorIcon = hasError && (!valueIsEmpty || !errors.every(error => error.type === ErrorType.Required));
|
||||
|
||||
// Only compute error messages if we're going to show an icon
|
||||
const errorMessages = shouldShowErrorIcon
|
||||
? errors
|
||||
.filter(e => e.level === 'error' || e.level === 'warning')
|
||||
.map(e => e.message)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
return {
|
||||
hasError,
|
||||
isRequiredButEmpty,
|
||||
shouldShowErrorIcon,
|
||||
errorMessages
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to compare error arrays efficiently with a hash-based approach
|
||||
function compareErrorArrays(prevErrors: ErrorObject[], nextErrors: ErrorObject[]): boolean {
|
||||
// Fast path for referential equality
|
||||
if (prevErrors === nextErrors) return true;
|
||||
|
||||
// Fast path for length check
|
||||
if (!prevErrors || !nextErrors) return prevErrors === nextErrors;
|
||||
if (prevErrors.length !== nextErrors.length) return false;
|
||||
|
||||
// Generate simple hash from error properties
|
||||
const getErrorHash = (error: ErrorObject): string => {
|
||||
return `${error.message}|${error.level}|${error.type || ''}`;
|
||||
};
|
||||
|
||||
// Compare using hashes
|
||||
const prevHashes = prevErrors.map(getErrorHash);
|
||||
const nextHashes = nextErrors.map(getErrorHash);
|
||||
|
||||
// Sort hashes to ensure consistent order
|
||||
prevHashes.sort();
|
||||
nextHashes.sort();
|
||||
|
||||
// Compare sorted hash arrays
|
||||
return prevHashes.join(',') === nextHashes.join(',');
|
||||
}
|
||||
|
||||
const ValidationCell = React.memo(({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
isValidating,
|
||||
fieldKey,
|
||||
options = [],
|
||||
itemNumber,
|
||||
width,
|
||||
copyDown,
|
||||
rowIndex,
|
||||
totalRows = 0,
|
||||
rowData,
|
||||
editingCells,
|
||||
setEditingCells
|
||||
}: ValidationCellProps) => {
|
||||
// Use the CopyDown context
|
||||
const copyDownContext = React.useContext(CopyDownContext);
|
||||
const { toast } = useToast();
|
||||
const [isGeneratingUpc, setIsGeneratingUpc] = React.useState(false);
|
||||
|
||||
// CRITICAL FIX: For item_number fields, always prioritize the itemNumber prop over the value
|
||||
// This ensures that when the itemNumber changes, the display value changes
|
||||
let displayValue;
|
||||
if (fieldKey === 'item_number' && itemNumber) {
|
||||
// Prioritize itemNumber prop for item_number fields
|
||||
displayValue = itemNumber;
|
||||
} else {
|
||||
displayValue = value;
|
||||
}
|
||||
|
||||
// Use the optimized processErrors function to avoid redundant filtering
|
||||
const {
|
||||
hasError,
|
||||
isRequiredButEmpty,
|
||||
shouldShowErrorIcon,
|
||||
errorMessages
|
||||
} = React.useMemo(() => processErrors(displayValue, errors), [displayValue, errors]);
|
||||
|
||||
// Track whether this cell is the source of a copy-down operation
|
||||
const isSourceCell = copyDownContext.isInCopyDownMode &&
|
||||
rowIndex === copyDownContext.sourceRowIndex &&
|
||||
fieldKey === copyDownContext.sourceFieldKey;
|
||||
|
||||
// Add state for hover on copy down button
|
||||
const [isCopyDownHovered, setIsCopyDownHovered] = React.useState(false);
|
||||
// Add state for hover on target row
|
||||
const [isTargetRowHovered, setIsTargetRowHovered] = React.useState(false);
|
||||
|
||||
// PERFORMANCE FIX: Create cell key for editing state management
|
||||
const cellKey = `${rowIndex}-${fieldKey}`;
|
||||
const isEditingCell = editingCells.has(cellKey);
|
||||
|
||||
// SINGLE-CLICK EDITING FIX: Create editing state management functions
|
||||
const handleStartEdit = React.useCallback(() => {
|
||||
setEditingCells(prev => new Set([...prev, cellKey]));
|
||||
}, [setEditingCells, cellKey]);
|
||||
|
||||
const handleEndEdit = React.useCallback(() => {
|
||||
setEditingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
}, [setEditingCells, cellKey]);
|
||||
|
||||
// Handle copy down button click
|
||||
const handleCopyDownClick = React.useCallback(() => {
|
||||
if (copyDown && totalRows > rowIndex + 1) {
|
||||
// Enter copy down mode
|
||||
copyDownContext.setIsInCopyDownMode(true);
|
||||
copyDownContext.setSourceRowIndex(rowIndex);
|
||||
copyDownContext.setSourceFieldKey(fieldKey);
|
||||
}
|
||||
}, [copyDown, copyDownContext, fieldKey, rowIndex, totalRows]);
|
||||
|
||||
// Check if this cell is in a row that can be a target for copy down
|
||||
const isInTargetRow = copyDownContext.isInCopyDownMode &&
|
||||
copyDownContext.sourceFieldKey === fieldKey &&
|
||||
rowIndex > (copyDownContext.sourceRowIndex || 0);
|
||||
|
||||
// Check if this row is the currently selected target row
|
||||
const isSelectedTarget = isInTargetRow && rowIndex <= (copyDownContext.targetRowIndex || 0);
|
||||
|
||||
// Handle click on a potential target cell
|
||||
const handleTargetCellClick = React.useCallback(() => {
|
||||
if (isInTargetRow && copyDownContext.sourceRowIndex !== null && copyDownContext.sourceFieldKey !== null) {
|
||||
copyDownContext.handleCopyDownComplete(
|
||||
copyDownContext.sourceRowIndex,
|
||||
copyDownContext.sourceFieldKey,
|
||||
rowIndex
|
||||
);
|
||||
}
|
||||
}, [copyDownContext, isInTargetRow, rowIndex]);
|
||||
|
||||
// Memoize the cell style objects to avoid recreating them on every render
|
||||
const cellStyle = React.useMemo(() => ({
|
||||
width: `${width}px`,
|
||||
minWidth: `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
boxSizing: 'border-box' as const,
|
||||
cursor: isInTargetRow ? 'pointer' : undefined
|
||||
}), [width, isInTargetRow]);
|
||||
|
||||
// Memoize the cell class name to prevent re-calculating on every render
|
||||
const cellClassName = React.useMemo(() => {
|
||||
if (isSourceCell || isSelectedTarget || isInTargetRow) {
|
||||
return isSourceCell ? '!bg-blue-100 !border-blue-500 !rounded-md' :
|
||||
isSelectedTarget ? '!bg-blue-200 !border-blue-200 !rounded-md' :
|
||||
isInTargetRow ? 'hover:!bg-blue-100 !border-blue-200 !rounded-md' : '';
|
||||
}
|
||||
return '';
|
||||
}, [isSourceCell, isSelectedTarget, isInTargetRow]);
|
||||
|
||||
const isUpcField = fieldKey === 'upc';
|
||||
const baseIsLoading = isValidating === true;
|
||||
const showGeneratingSkeleton = isUpcField && isGeneratingUpc;
|
||||
const isLoading = baseIsLoading || showGeneratingSkeleton;
|
||||
|
||||
const supplierRaw = rowData?.supplier ?? rowData?.supplier_id ?? rowData?.supplierId;
|
||||
const supplierIdString = supplierRaw !== undefined && supplierRaw !== null
|
||||
? String(supplierRaw).trim()
|
||||
: '';
|
||||
const normalizedSupplierId = /^\d+$/.test(supplierIdString) ? supplierIdString : '';
|
||||
const canGenerateUpc = normalizedSupplierId !== '';
|
||||
const upcValueEmpty = isUpcField && isEmpty(displayValue);
|
||||
const showGenerateButton = upcValueEmpty && !isEditingCell && !copyDownContext.isInCopyDownMode && !isInTargetRow && !isLoading;
|
||||
const cellClassNameWithPadding = showGenerateButton ? `${cellClassName} pr-10`.trim() : cellClassName;
|
||||
const buttonDisabled = !canGenerateUpc || isGeneratingUpc;
|
||||
const tooltipMessage = canGenerateUpc ? 'Generate UPC' : 'Select a supplier before generating a UPC';
|
||||
|
||||
const handleGenerateUpc = React.useCallback(async () => {
|
||||
if (!normalizedSupplierId) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
description: 'Select a supplier before generating a UPC.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGeneratingUpc) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingUpc(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/import/generate-upc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ supplierId: normalizedSupplierId })
|
||||
});
|
||||
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (parseError) {
|
||||
// Ignore JSON parse errors and handle via status code
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || `Request failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (!payload || !payload.success || !payload.upc) {
|
||||
throw new Error(payload?.error || 'Unexpected response while generating UPC');
|
||||
}
|
||||
|
||||
onChange(payload.upc);
|
||||
} catch (error) {
|
||||
console.error('Error generating UPC:', error);
|
||||
const errorMessage =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error &&
|
||||
typeof (error as { message?: unknown }).message === 'string'
|
||||
? (error as { message: string }).message
|
||||
: 'Failed to generate UPC';
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
description: errorMessage,
|
||||
});
|
||||
} finally {
|
||||
setIsGeneratingUpc(false);
|
||||
}
|
||||
}, [normalizedSupplierId, isGeneratingUpc, onChange, toast]);
|
||||
|
||||
const handleGenerateButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!buttonDisabled) {
|
||||
handleGenerateUpc();
|
||||
}
|
||||
}, [buttonDisabled, handleGenerateUpc]);
|
||||
const containerClassName = `truncate overflow-hidden${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? ' bg-blue-50/50' : ''}${showGenerateButton ? ' relative group/upc' : ''}`.trim();
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
className="p-1 group relative"
|
||||
style={cellStyle}
|
||||
onClick={isInTargetRow ? handleTargetCellClick : undefined}
|
||||
onMouseEnter={isInTargetRow ? () => setIsTargetRowHovered(true) : undefined}
|
||||
onMouseLeave={isInTargetRow ? () => setIsTargetRowHovered(false) : undefined}
|
||||
>
|
||||
<div className={`relative ${hasError || isRequiredButEmpty ? 'border-red-500' : ''}`}>
|
||||
{shouldShowErrorIcon && !isInTargetRow && (
|
||||
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
|
||||
<ValidationIcon error={{
|
||||
message: errorMessages,
|
||||
level: 'error',
|
||||
type: ErrorType.Custom
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
{!shouldShowErrorIcon && copyDown && !isEmpty(displayValue) && !copyDownContext.isInCopyDownMode && (
|
||||
<div className="absolute right-0.5 top-1/2 -translate-y-1/2 z-20 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleCopyDownClick}
|
||||
onMouseEnter={() => setIsCopyDownHovered(true)}
|
||||
onMouseLeave={() => setIsCopyDownHovered(false)}
|
||||
className="p-1 rounded-full hover:bg-blue-100 text-blue-500/70 hover:text-blue-600 transition-colors"
|
||||
aria-label="Copy value to rows below"
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<div className="flex flex-col">
|
||||
<p className="font-medium">Copy value to rows below</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{isSourceCell && (
|
||||
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => copyDownContext.setIsInCopyDownMode(false)}
|
||||
className="p-1 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 transition-colors"
|
||||
aria-label="Cancel copy down"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Cancel copy down</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-md px-2 py-2`}>
|
||||
<Skeleton className="w-full h-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={containerClassName}
|
||||
style={{
|
||||
backgroundColor: isSourceCell ? '#dbeafe' :
|
||||
isSelectedTarget ? '#bfdbfe' :
|
||||
isInTargetRow && isTargetRowHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderRadius: (isSourceCell || isSelectedTarget || isInTargetRow) ? '0.375rem' : undefined,
|
||||
boxShadow: isSourceCell ? '0 0 0 2px #3b82f6' : undefined
|
||||
}}
|
||||
>
|
||||
<BaseCellContent
|
||||
field={field}
|
||||
value={displayValue}
|
||||
onChange={onChange}
|
||||
hasErrors={hasError || isRequiredButEmpty}
|
||||
options={options}
|
||||
className={cellClassNameWithPadding}
|
||||
fieldKey={fieldKey}
|
||||
onStartEdit={handleStartEdit}
|
||||
onEndEdit={handleEndEdit}
|
||||
/>
|
||||
{showGenerateButton && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateButtonClick}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-xs text-muted-foreground shadow-sm transition-opacity opacity-0 group-hover/upc:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={buttonDisabled}
|
||||
aria-label="Generate UPC"
|
||||
>
|
||||
<Wand2 className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>{tooltipMessage}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Fast path: if all props are the same object
|
||||
if (prevProps === nextProps) return true;
|
||||
|
||||
// Optimize the memo comparison function, checking most impactful props first
|
||||
// Check isValidating first as it's most likely to change frequently
|
||||
if (prevProps.isValidating !== nextProps.isValidating) return false;
|
||||
|
||||
// Then check value changes
|
||||
if (prevProps.value !== nextProps.value) return false;
|
||||
|
||||
// Item number is related to validation state
|
||||
if (prevProps.itemNumber !== nextProps.itemNumber) return false;
|
||||
|
||||
// Check errors with our optimized comparison function
|
||||
if (!compareErrorArrays(prevProps.errors, nextProps.errors)) return false;
|
||||
|
||||
// Check field identity
|
||||
if (prevProps.field !== nextProps.field) return false;
|
||||
|
||||
if (prevProps.rowData !== nextProps.rowData) return false;
|
||||
if (prevProps.editingCells !== nextProps.editingCells) return false;
|
||||
|
||||
// Shallow options comparison - only if field type is select or multi-select
|
||||
if (prevProps.field.fieldType?.type === 'select' || prevProps.field.fieldType?.type === 'multi-select') {
|
||||
const optionsEqual = prevProps.options === nextProps.options ||
|
||||
(Array.isArray(prevProps.options) &&
|
||||
Array.isArray(nextProps.options) &&
|
||||
prevProps.options.length === nextProps.options.length &&
|
||||
prevProps.options.every((opt, idx) => {
|
||||
const nextOptions = nextProps.options || [];
|
||||
return opt === nextOptions[idx];
|
||||
}));
|
||||
|
||||
if (!optionsEqual) return false;
|
||||
}
|
||||
|
||||
// Check copy down context changes
|
||||
const copyDownContextChanged =
|
||||
prevProps.rowIndex !== nextProps.rowIndex ||
|
||||
prevProps.fieldKey !== nextProps.fieldKey;
|
||||
|
||||
if (copyDownContextChanged) return false;
|
||||
|
||||
// All essential props are the same - we can skip re-rendering
|
||||
return true;
|
||||
});
|
||||
|
||||
ValidationCell.displayName = 'ValidationCell';
|
||||
|
||||
export default ValidationCell;
|
||||
-1078
File diff suppressed because it is too large
Load Diff
-657
@@ -1,657 +0,0 @@
|
||||
import React, { useMemo, useCallback, useState } from 'react'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
RowSelectionState,
|
||||
ColumnDef
|
||||
} from '@tanstack/react-table'
|
||||
import { Fields, Field } from '../../../types'
|
||||
import { RowData, Template } from '../hooks/validationTypes'
|
||||
import ValidationCell, { CopyDownContext } from './ValidationCell'
|
||||
import { useRsi } from '../../../hooks/useRsi'
|
||||
import SearchableTemplateSelect from './SearchableTemplateSelect'
|
||||
import { Table, TableBody, TableRow, TableCell } from '@/components/ui/table'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// Define a simple Error type locally to avoid import issues
|
||||
type ErrorType = {
|
||||
message: string;
|
||||
level: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// Stable empty errors array to prevent unnecessary re-renders
|
||||
// Use a mutable empty array to satisfy the ErrorType[] type
|
||||
const EMPTY_ERRORS: ErrorType[] = [];
|
||||
|
||||
interface ValidationTableProps<T extends string> {
|
||||
data: RowData<T>[]
|
||||
fields: Fields<T>
|
||||
rowSelection: RowSelectionState
|
||||
setRowSelection: React.Dispatch<React.SetStateAction<RowSelectionState>>
|
||||
updateRow: (rowIndex: number, key: T, value: any) => void
|
||||
validationErrors: Map<number, Record<string, ErrorType[]>>
|
||||
isValidatingUpc: (rowIndex: number) => boolean
|
||||
validatingUpcRows: number[]
|
||||
filters?: { showErrorsOnly?: boolean }
|
||||
templates: Template[]
|
||||
applyTemplate: (templateId: string, rowIndexes: number[]) => void
|
||||
getTemplateDisplayText: (templateId: string | null) => string
|
||||
rowProductLines?: Record<string, any[]>
|
||||
rowSublines?: Record<string, any[]>
|
||||
isLoadingLines?: Record<string, boolean>
|
||||
isLoadingSublines?: Record<string, boolean>
|
||||
upcValidationResults: Map<number, { itemNumber: string }>
|
||||
validatingCells: Set<string>
|
||||
itemNumbers: Map<number, string>
|
||||
isLoadingTemplates?: boolean
|
||||
copyDown: (rowIndex: number, key: string, endRowIndex?: number) => void
|
||||
editingCells: Set<string>
|
||||
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// Simple template select component - let React handle optimization
|
||||
const TemplateSelectWrapper = ({
|
||||
templates,
|
||||
value,
|
||||
onValueChange,
|
||||
getTemplateDisplayText,
|
||||
defaultBrand,
|
||||
isLoading
|
||||
}: {
|
||||
templates: Template[],
|
||||
value: string,
|
||||
onValueChange: (value: string) => void,
|
||||
getTemplateDisplayText: (value: string | null) => string,
|
||||
defaultBrand?: string,
|
||||
isLoading?: boolean
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 border border-input rounded-md px-2 py-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchableTemplateSelect
|
||||
templates={templates}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
getTemplateDisplayText={getTemplateDisplayText}
|
||||
defaultBrand={defaultBrand}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ValidationTable = <T extends string>({
|
||||
data,
|
||||
fields,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
updateRow,
|
||||
validationErrors,
|
||||
filters,
|
||||
templates,
|
||||
applyTemplate,
|
||||
getTemplateDisplayText,
|
||||
validatingCells,
|
||||
itemNumbers,
|
||||
isLoadingTemplates = false,
|
||||
copyDown,
|
||||
editingCells,
|
||||
setEditingCells,
|
||||
rowProductLines = {},
|
||||
rowSublines = {},
|
||||
isLoadingLines = {},
|
||||
isLoadingSublines = {},
|
||||
isValidatingUpc,
|
||||
validatingUpcRows = [],
|
||||
upcValidationResults
|
||||
}: ValidationTableProps<T>) => {
|
||||
const { translations } = useRsi<T>();
|
||||
|
||||
// Copy-down state combined into single object
|
||||
type CopyDownState = {
|
||||
sourceRowIndex: number;
|
||||
sourceFieldKey: string;
|
||||
targetRowIndex: number | null;
|
||||
};
|
||||
const [copyDownState, setCopyDownState] = useState<CopyDownState | null>(null);
|
||||
|
||||
// Handle copy down completion
|
||||
const handleCopyDownComplete = useCallback((sourceRowIndex: number, fieldKey: string, targetRowIndex: number) => {
|
||||
copyDown(sourceRowIndex, fieldKey, targetRowIndex);
|
||||
setCopyDownState(null);
|
||||
}, [copyDown]);
|
||||
|
||||
// Create copy down context value
|
||||
// Use a ref to track partial state during initialization
|
||||
const partialCopyDownRef = React.useRef<{ rowIndex?: number; fieldKey?: string }>({});
|
||||
|
||||
const copyDownContextValue = useMemo(() => ({
|
||||
isInCopyDownMode: copyDownState !== null,
|
||||
sourceRowIndex: copyDownState?.sourceRowIndex ?? null,
|
||||
sourceFieldKey: copyDownState?.sourceFieldKey ?? null,
|
||||
targetRowIndex: copyDownState?.targetRowIndex ?? null,
|
||||
setIsInCopyDownMode: (value: boolean) => {
|
||||
if (!value) {
|
||||
setCopyDownState(null);
|
||||
partialCopyDownRef.current = {};
|
||||
}
|
||||
},
|
||||
setSourceRowIndex: (rowIndex: number | null) => {
|
||||
if (rowIndex !== null) {
|
||||
partialCopyDownRef.current.rowIndex = rowIndex;
|
||||
// If we have both values, set the full state
|
||||
if (partialCopyDownRef.current.fieldKey !== undefined) {
|
||||
setCopyDownState({
|
||||
sourceRowIndex: rowIndex,
|
||||
sourceFieldKey: partialCopyDownRef.current.fieldKey,
|
||||
targetRowIndex: null
|
||||
});
|
||||
partialCopyDownRef.current = {};
|
||||
}
|
||||
}
|
||||
},
|
||||
setSourceFieldKey: (fieldKey: string | null) => {
|
||||
if (fieldKey !== null) {
|
||||
partialCopyDownRef.current.fieldKey = fieldKey;
|
||||
// If we have both values, set the full state
|
||||
if (partialCopyDownRef.current.rowIndex !== undefined) {
|
||||
setCopyDownState({
|
||||
sourceRowIndex: partialCopyDownRef.current.rowIndex,
|
||||
sourceFieldKey: fieldKey,
|
||||
targetRowIndex: null
|
||||
});
|
||||
partialCopyDownRef.current = {};
|
||||
}
|
||||
}
|
||||
},
|
||||
setTargetRowIndex: (rowIndex: number | null) => {
|
||||
if (copyDownState) {
|
||||
setCopyDownState({
|
||||
...copyDownState,
|
||||
targetRowIndex: rowIndex
|
||||
});
|
||||
}
|
||||
},
|
||||
handleCopyDownComplete
|
||||
}), [copyDownState, handleCopyDownComplete]);
|
||||
|
||||
// Update targetRowIndex when hovering over rows in copy down mode
|
||||
const handleRowMouseEnter = useCallback((rowIndex: number) => {
|
||||
if (copyDownState && copyDownState.sourceRowIndex < rowIndex) {
|
||||
setCopyDownState({
|
||||
...copyDownState,
|
||||
targetRowIndex: rowIndex
|
||||
});
|
||||
}
|
||||
}, [copyDownState]);
|
||||
|
||||
// Memoize the selection column with stable callback
|
||||
const handleSelectAll = useCallback((value: boolean, table: any) => {
|
||||
table.toggleAllPageRowsSelected(!!value);
|
||||
}, []);
|
||||
|
||||
const handleRowSelect = useCallback((value: boolean, row: any) => {
|
||||
row.toggleSelected(!!value);
|
||||
}, []);
|
||||
|
||||
const selectionColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<div className="flex h-full items-center justify-center py-2">
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => handleSelectAll(!!value, table)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center py-9">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => handleRowSelect(!!value, row)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 50,
|
||||
}), [handleSelectAll, handleRowSelect]);
|
||||
|
||||
// Memoize template selection handler
|
||||
const handleTemplateChange = useCallback((value: string, rowIndex: number) => {
|
||||
applyTemplate(value, [rowIndex]);
|
||||
}, [applyTemplate]);
|
||||
|
||||
// Memoize the template column with stable callback
|
||||
const templateColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
|
||||
accessorKey: '__template',
|
||||
header: 'Template',
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const templateValue = row.original.__template || null;
|
||||
const defaultBrand = row.original.company || undefined;
|
||||
const rowIndex = data.findIndex(r => r === row.original);
|
||||
|
||||
return (
|
||||
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px', maxWidth: '200px', overflow: 'hidden' }}>
|
||||
<div className="w-full overflow-hidden">
|
||||
<TemplateSelectWrapper
|
||||
templates={templates}
|
||||
value={templateValue || ''}
|
||||
onValueChange={(value) => handleTemplateChange(value, rowIndex)}
|
||||
getTemplateDisplayText={getTemplateDisplayText}
|
||||
defaultBrand={defaultBrand}
|
||||
isLoading={isLoadingTemplates}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
}), [templates, handleTemplateChange, getTemplateDisplayText, isLoadingTemplates, data]);
|
||||
|
||||
// Cache options by field key to avoid recreating arrays
|
||||
const optionsCache = useMemo(() => {
|
||||
const cache = new Map<string, readonly any[]>();
|
||||
|
||||
fields.forEach((field) => {
|
||||
// Get the field key
|
||||
const fieldKey = String(field.key);
|
||||
|
||||
// Handle all select and multi-select fields the same way
|
||||
if (field.fieldType &&
|
||||
(typeof field.fieldType === 'object') &&
|
||||
(field.fieldType.type === 'select' || field.fieldType.type === 'multi-select')) {
|
||||
cache.set(fieldKey, (field.fieldType as any).options || []);
|
||||
}
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [fields]);
|
||||
|
||||
// Memoize the field update handler
|
||||
const handleFieldUpdate = useCallback((rowIndex: number, fieldKey: T, value: any) => {
|
||||
updateRow(rowIndex, fieldKey, value);
|
||||
}, [updateRow]);
|
||||
|
||||
// Memoize the copyDown handler
|
||||
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => {
|
||||
copyDown(rowIndex, fieldKey, endRowIndex);
|
||||
}, [copyDown]);
|
||||
|
||||
// Use validatingUpcRows for calculation
|
||||
const isRowValidatingUpc = useCallback((rowIndex: number) => {
|
||||
return isValidatingUpc?.(rowIndex) || validatingUpcRows.includes(rowIndex);
|
||||
}, [isValidatingUpc, validatingUpcRows]);
|
||||
|
||||
// Use upcValidationResults for display, prioritizing the most recent values
|
||||
const getRowUpcResult = useCallback((rowIndex: number) => {
|
||||
// ALWAYS get from the data array directly - most authoritative source
|
||||
const rowData = data[rowIndex];
|
||||
if (rowData && rowData.item_number) {
|
||||
return rowData.item_number;
|
||||
}
|
||||
|
||||
// Maps are only backup sources when data doesn't have a value
|
||||
const itemNumberFromMap = itemNumbers.get(rowIndex);
|
||||
if (itemNumberFromMap) {
|
||||
return itemNumberFromMap;
|
||||
}
|
||||
|
||||
// Last resort - upcValidationResults
|
||||
const upcResult = upcValidationResults?.get(rowIndex)?.itemNumber;
|
||||
if (upcResult) {
|
||||
return upcResult;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [data, itemNumbers, upcValidationResults]);
|
||||
|
||||
// Memoize field columns with stable handlers
|
||||
const fieldColumns = useMemo(() => fields.map((field): ColumnDef<RowData<T>, any> | null => {
|
||||
// Don't filter out disabled fields, just pass the disabled state to the cell component
|
||||
|
||||
const fieldWidth = field.width || (
|
||||
field.fieldType.type === "checkbox" ? 80 :
|
||||
field.fieldType.type === "select" ? 150 :
|
||||
field.fieldType.type === "multi-select" ? 200 :
|
||||
(field.fieldType.type === "input" || field.fieldType.type === "multi-input") &&
|
||||
(field.fieldType as any).multiline ? 300 :
|
||||
150
|
||||
);
|
||||
|
||||
const fieldKey = String(field.key);
|
||||
// Get cached options for this field
|
||||
const fieldOptions = optionsCache.get(fieldKey) || [];
|
||||
|
||||
return {
|
||||
accessorKey: fieldKey,
|
||||
header: field.label || fieldKey,
|
||||
size: fieldWidth,
|
||||
cell: ({ row }) => {
|
||||
// Get row-specific options for line and subline fields
|
||||
let options = fieldOptions;
|
||||
const rowId = (row.original as any).__index;
|
||||
const lookupKey = (rowId !== undefined && rowId !== null) ? rowId : row.index;
|
||||
|
||||
if (fieldKey === 'line' && lookupKey !== undefined && rowProductLines[lookupKey]) {
|
||||
options = rowProductLines[lookupKey];
|
||||
} else if (fieldKey === 'subline' && lookupKey !== undefined && rowSublines[lookupKey]) {
|
||||
options = rowSublines[lookupKey];
|
||||
}
|
||||
|
||||
// Get the current cell value first
|
||||
const currentValue = fieldKey === 'item_number' && row.original[field.key]
|
||||
? row.original[field.key]
|
||||
: row.original[field.key as keyof typeof row.original];
|
||||
|
||||
// Determine if this cell is in loading state
|
||||
let isLoading = false;
|
||||
|
||||
const cellLoadingKey = `${row.index}-${fieldKey}`;
|
||||
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '' ||
|
||||
(Array.isArray(currentValue) && currentValue.length === 0);
|
||||
|
||||
// CRITICAL: Check validatingCells FIRST - this shows loading for item_number during UPC validation
|
||||
// even if the field already has a value (because we're fetching a new one)
|
||||
if (validatingCells.has(cellLoadingKey)) {
|
||||
isLoading = true;
|
||||
}
|
||||
// Only show loading for empty fields for these other cases
|
||||
else if (isEmpty) {
|
||||
// Check if UPC is validating for this row and field is item_number
|
||||
if (fieldKey === 'item_number' && isRowValidatingUpc(row.index)) {
|
||||
isLoading = true;
|
||||
}
|
||||
// Add loading state for line/subline fields
|
||||
else if (fieldKey === 'line' && lookupKey !== undefined && isLoadingLines[lookupKey]) {
|
||||
isLoading = true;
|
||||
}
|
||||
else if (fieldKey === 'subline' && lookupKey !== undefined && isLoadingSublines[lookupKey]) {
|
||||
isLoading = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get validation errors for this cell
|
||||
// Use stable EMPTY_ERRORS to avoid new array creation on every render
|
||||
const cellErrors = validationErrors.get(row.index)?.[fieldKey] || EMPTY_ERRORS;
|
||||
|
||||
// Create a copy of the field with guaranteed field type for line and subline fields
|
||||
let fieldWithType = field;
|
||||
|
||||
// Ensure line and subline fields always have the correct fieldType
|
||||
if (fieldKey === 'line' || fieldKey === 'subline') {
|
||||
// Create a deep clone of the field to prevent any reference issues
|
||||
fieldWithType = {
|
||||
...JSON.parse(JSON.stringify(field)), // Ensure deep clone
|
||||
fieldType: {
|
||||
type: 'select',
|
||||
options: options
|
||||
},
|
||||
// Explicitly mark as not disabled to ensure dropdown works
|
||||
disabled: false
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
// CRITICAL CHANGE: Get item number directly from row data first for item_number fields
|
||||
let itemNumber;
|
||||
if (fieldKey === 'item_number') {
|
||||
// Check directly in row data first - this is the most accurate source
|
||||
const directValue = row.original[fieldKey];
|
||||
if (directValue) {
|
||||
itemNumber = directValue;
|
||||
} else {
|
||||
// Fall back to centralized getter that checks all sources
|
||||
itemNumber = getRowUpcResult(row.index);
|
||||
}
|
||||
}
|
||||
|
||||
// Create stable keys that only change when actual content changes
|
||||
const cellKey = fieldKey === 'item_number'
|
||||
? `cell-${row.index}-${fieldKey}-${itemNumber || 'empty'}` // Only change when itemNumber actually changes
|
||||
: `cell-${row.index}-${fieldKey}`;
|
||||
|
||||
return (
|
||||
<ValidationCell
|
||||
key={cellKey}
|
||||
field={fieldWithType as Field<string>}
|
||||
value={currentValue}
|
||||
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
|
||||
errors={cellErrors}
|
||||
isValidating={isLoading}
|
||||
fieldKey={fieldKey}
|
||||
options={options}
|
||||
itemNumber={itemNumber}
|
||||
width={fieldWidth}
|
||||
rowIndex={row.index}
|
||||
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
|
||||
totalRows={data.length}
|
||||
rowData={row.original as Record<string, any>}
|
||||
editingCells={editingCells}
|
||||
setEditingCells={setEditingCells}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null),
|
||||
[fields, validationErrors, validatingCells, itemNumbers, handleFieldUpdate, handleCopyDown, optionsCache,
|
||||
data.length, rowProductLines, rowSublines, isLoadingLines, isLoadingSublines,
|
||||
isRowValidatingUpc, getRowUpcResult]);
|
||||
|
||||
// Combine columns
|
||||
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: useCallback((_row: RowData<T>, index: number) => String(index), []),
|
||||
});
|
||||
|
||||
// Calculate total table width for stable horizontal scrolling
|
||||
const totalWidth = useMemo(() => {
|
||||
return columns.reduce((total, col) => total + (col.size || 0), 0);
|
||||
}, [columns]);
|
||||
|
||||
// Don't render if no data
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">
|
||||
{filters?.showErrorsOnly
|
||||
? translations.validationStep.noRowsMessageWhenFiltered || "No rows with errors"
|
||||
: translations.validationStep.noRowsMessage || "No data to display"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyDownContext.Provider value={copyDownContextValue}>
|
||||
<div className="min-w-max relative">
|
||||
{/* Add global styles for copy down mode */}
|
||||
{copyDownState && (
|
||||
<style>
|
||||
{`
|
||||
.copy-down-target-row,
|
||||
.copy-down-target-row *,
|
||||
.copy-down-target-row input,
|
||||
.copy-down-target-row textarea,
|
||||
.copy-down-target-row div,
|
||||
.copy-down-target-row button,
|
||||
.target-row-cell,
|
||||
.target-row-cell * {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
)}
|
||||
{copyDownState && (
|
||||
<div className="sticky top-0 z-30 h-0 overflow-visible">
|
||||
<div
|
||||
className="absolute w-[240px] top-16 bg-blue-50 border rounded-2xl shadow-lg border-blue-200 p-3 text-sm text-blue-700 flex items-center justify-between"
|
||||
style={{
|
||||
left: (() => {
|
||||
// Find the column index
|
||||
const colIndex = columns.findIndex(col =>
|
||||
'accessorKey' in col && col.accessorKey === copyDownState.sourceFieldKey
|
||||
);
|
||||
|
||||
// If column not found, position at a default location
|
||||
if (colIndex === -1) return '50px';
|
||||
|
||||
// Calculate position based on column widths
|
||||
let position = 0;
|
||||
for (let i = 0; i < colIndex; i++) {
|
||||
position += columns[i].size || 0;
|
||||
}
|
||||
|
||||
// Add half of the current column width to center it
|
||||
position += (columns[colIndex].size || 0) / 2;
|
||||
|
||||
// Adjust to center the notification
|
||||
position -= 120; // Half of the notification width
|
||||
|
||||
return `${Math.max(50, position)}px`;
|
||||
})()
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">Click on the last row you want to copy to</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCopyDownState(null)}
|
||||
className="text-xs h-7 border-blue-200 text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
{/* Custom Table Header - Always Visible with GPU acceleration */}
|
||||
<div
|
||||
className="sticky top-0 z-20 bg-muted border-b shadow-sm will-change-transform"
|
||||
style={{
|
||||
width: `${totalWidth}px`,
|
||||
transform: 'translateZ(0)', // Force GPU acceleration
|
||||
}}
|
||||
>
|
||||
<div className="flex">
|
||||
{table.getFlatHeaders().map((header) => {
|
||||
const width = header.getSize();
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className="py-2 px-2 font-bold text-sm text-muted-foreground bg-muted flex items-center justify-center"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
minWidth: `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
boxSizing: 'border-box',
|
||||
height: '40px'
|
||||
}}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body - With optimized rendering */}
|
||||
<Table style={{
|
||||
width: `${totalWidth}px`,
|
||||
tableLayout: 'fixed',
|
||||
borderCollapse: 'separate',
|
||||
borderSpacing: 0,
|
||||
marginTop: '-1px',
|
||||
willChange: 'transform', // Help browser optimize
|
||||
contain: 'content', // Contain paint operations
|
||||
transform: 'translateZ(0)' // Force GPU acceleration
|
||||
}}>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
// Precompute validation error status for this row
|
||||
const hasErrors = validationErrors.has(parseInt(row.id)) &&
|
||||
Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0;
|
||||
|
||||
// Precompute copy down target status
|
||||
const isCopyDownTarget = copyDownState !== null &&
|
||||
parseInt(row.id) > copyDownState.sourceRowIndex;
|
||||
|
||||
// Using CSS variables for better performance on hover/state changes
|
||||
const rowStyle = {
|
||||
cursor: isCopyDownTarget ? 'pointer' : undefined,
|
||||
position: 'relative' as const,
|
||||
willChange: copyDownState ? 'background-color' : 'auto',
|
||||
contain: 'layout',
|
||||
transition: 'background-color 100ms ease-in-out'
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"hover:bg-muted/50",
|
||||
row.getIsSelected() ? "!bg-blue-50/50" : "",
|
||||
hasErrors ? "bg-red-50/40" : "",
|
||||
isCopyDownTarget ? "cursor-pointer copy-down-target-row" : ""
|
||||
)}
|
||||
style={rowStyle}
|
||||
onMouseEnter={() => handleRowMouseEnter(parseInt(row.id))}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: any) => (
|
||||
<React.Fragment key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CopyDownContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Memo comparator: re-render when any prop affecting visible state changes.
|
||||
// Keep this conservative to avoid skipping updates for loading/options states.
|
||||
const areEqual = (prev: ValidationTableProps<any>, next: ValidationTableProps<any>) => {
|
||||
return (
|
||||
// Core props
|
||||
prev.data === next.data &&
|
||||
prev.validationErrors === next.validationErrors &&
|
||||
prev.rowSelection === next.rowSelection &&
|
||||
// Loading + validation state that affects cell skeletons
|
||||
prev.validatingCells === next.validatingCells &&
|
||||
prev.isLoadingLines === next.isLoadingLines &&
|
||||
prev.isLoadingSublines === next.isLoadingSublines &&
|
||||
// Options sources used for line/subline selects
|
||||
prev.rowProductLines === next.rowProductLines &&
|
||||
prev.rowSublines === next.rowSublines
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ValidationTable, areEqual);
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Field } from '../../../../types'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { cn } from '@/lib/utils'
|
||||
import React from 'react'
|
||||
|
||||
interface CheckboxCellProps<T extends string> {
|
||||
field: Field<T>
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
hasErrors?: boolean
|
||||
booleanMatches?: Record<string, boolean>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CheckboxCell = <T extends string>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
hasErrors,
|
||||
booleanMatches = {},
|
||||
className = ''
|
||||
}: CheckboxCellProps<T>) => {
|
||||
const [checked, setChecked] = useState(false)
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
// Initialize checkbox state
|
||||
useEffect(() => {
|
||||
if (value === undefined || value === null) {
|
||||
setChecked(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
setChecked(value)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle string values using booleanMatches
|
||||
if (typeof value === 'string') {
|
||||
// First try the field's booleanMatches
|
||||
const fieldBooleanMatches = field.fieldType.type === 'checkbox'
|
||||
? field.fieldType.booleanMatches || {}
|
||||
: {}
|
||||
|
||||
// Merge with the provided booleanMatches, with the provided ones taking precedence
|
||||
const allMatches = { ...fieldBooleanMatches, ...booleanMatches }
|
||||
|
||||
// Try to find the value in the matches
|
||||
const matchEntry = Object.entries(allMatches).find(([k]) =>
|
||||
k.toLowerCase() === value.toLowerCase())
|
||||
|
||||
if (matchEntry) {
|
||||
setChecked(matchEntry[1])
|
||||
return
|
||||
}
|
||||
|
||||
// If no match found, use common true/false strings
|
||||
const trueStrings = ['yes', 'true', '1', 'y']
|
||||
const falseStrings = ['no', 'false', '0', 'n']
|
||||
|
||||
if (trueStrings.includes(value.toLowerCase())) {
|
||||
setChecked(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (falseStrings.includes(value.toLowerCase())) {
|
||||
setChecked(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For any other values, try to convert to boolean
|
||||
setChecked(!!value)
|
||||
}, [value, field.fieldType, booleanMatches])
|
||||
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = (className || '').split(' ');
|
||||
return classNames.includes(cls);
|
||||
};
|
||||
|
||||
// Handle checkbox change
|
||||
const handleChange = useCallback((checked: boolean) => {
|
||||
setChecked(checked)
|
||||
onChange(checked)
|
||||
}, [onChange])
|
||||
|
||||
// Add outline even when not in focus
|
||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center h-10 px-2 py-1 rounded-md",
|
||||
outlineClass,
|
||||
hasErrors ? "bg-red-50 border-destructive" : "border-input",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
className={cn(
|
||||
hasErrors ? "border-destructive" : ""
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CheckboxCell, (prev, next) => {
|
||||
if (prev.hasErrors !== next.hasErrors) return false;
|
||||
if (prev.field !== next.field) return false;
|
||||
if (prev.value !== next.value) return false;
|
||||
if (prev.className !== next.className) return false;
|
||||
|
||||
// Compare booleanMatches objects
|
||||
const prevMatches = prev.booleanMatches || {};
|
||||
const nextMatches = next.booleanMatches || {};
|
||||
const prevKeys = Object.keys(prevMatches);
|
||||
const nextKeys = Object.keys(nextMatches);
|
||||
|
||||
if (prevKeys.length !== nextKeys.length) return false;
|
||||
|
||||
for (const key of prevKeys) {
|
||||
if (prevMatches[key] !== nextMatches[key]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react'
|
||||
import { Field } from '../../../../types'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import MultilineInput from './MultilineInput'
|
||||
|
||||
interface InputCellProps<T extends string> {
|
||||
field: Field<T>
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
onStartEdit?: () => void
|
||||
onEndEdit?: () => void
|
||||
hasErrors?: boolean
|
||||
isMultiline?: boolean
|
||||
isPrice?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// (removed unused formatPrice helper)
|
||||
|
||||
const InputCell = <T extends string>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
onStartEdit,
|
||||
onEndEdit,
|
||||
hasErrors,
|
||||
isMultiline = false,
|
||||
isPrice = false,
|
||||
disabled = false,
|
||||
className = ''
|
||||
}: InputCellProps<T>) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Remove optimistic updates and rely on parent state
|
||||
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = className.split(' ');
|
||||
return classNames.includes(cls);
|
||||
};
|
||||
|
||||
// No complex initialization needed
|
||||
|
||||
// Handle focus event
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsEditing(true);
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
if (isPrice) {
|
||||
// Remove any non-numeric characters except decimal point for editing
|
||||
const numericValue = String(value).replace(/[^\d.]/g, '');
|
||||
setEditValue(numericValue);
|
||||
} else {
|
||||
setEditValue(String(value));
|
||||
}
|
||||
} else {
|
||||
setEditValue('');
|
||||
}
|
||||
|
||||
onStartEdit?.();
|
||||
}, [value, onStartEdit, isPrice]);
|
||||
|
||||
// Handle blur event - save to parent only
|
||||
const handleBlur = useCallback(() => {
|
||||
const finalValue = editValue.trim();
|
||||
|
||||
// Save to parent - parent must update immediately for this to work
|
||||
onChange(finalValue);
|
||||
|
||||
// Exit editing mode
|
||||
setIsEditing(false);
|
||||
onEndEdit?.();
|
||||
}, [editValue, onChange, onEndEdit]);
|
||||
|
||||
// Handle direct input change - optimized to be synchronous for typing
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const newValue = isPrice ? e.target.value.replace(/[$,]/g, '') : e.target.value;
|
||||
setEditValue(newValue);
|
||||
}, [isPrice]);
|
||||
|
||||
// Get the display value - use parent value directly
|
||||
const displayValue = useMemo(() => {
|
||||
const currentValue = value ?? '';
|
||||
|
||||
// Handle price formatting for display
|
||||
if (isPrice && currentValue !== '' && currentValue !== undefined && currentValue !== null) {
|
||||
if (typeof currentValue === 'number') {
|
||||
return currentValue.toFixed(2);
|
||||
} else if (typeof currentValue === 'string' && /^-?\d+(\.\d+)?$/.test(currentValue)) {
|
||||
return parseFloat(currentValue).toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// For non-price or invalid price values, return as-is
|
||||
return String(currentValue);
|
||||
}, [isPrice, value]);
|
||||
|
||||
// Add outline even when not in focus
|
||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
|
||||
|
||||
// If disabled, just render the value without any interactivity
|
||||
if (disabled) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2 h-10 rounded-md text-sm w-full",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "border-input"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
|
||||
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render multiline fields using the dedicated MultilineInput component
|
||||
if (isMultiline) {
|
||||
return (
|
||||
<MultilineInput
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasErrors={hasErrors}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Original component for non-multiline fields
|
||||
return (
|
||||
<div className="w-full">
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoFocus
|
||||
className={cn(
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
|
||||
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleFocus}
|
||||
className={cn(
|
||||
"px-3 py-2 h-10 rounded-md text-sm w-full cursor-text flex items-center",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "border-input"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
|
||||
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : 'text'
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simplified memo comparison
|
||||
export default React.memo(InputCell, (prev, next) => {
|
||||
// Only re-render if essential props change
|
||||
return prev.value === next.value &&
|
||||
prev.hasErrors === next.hasErrors &&
|
||||
prev.disabled === next.disabled &&
|
||||
prev.field === next.field;
|
||||
});
|
||||
-576
@@ -1,576 +0,0 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
||||
import { Field } from '../../../../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
// Define a type for field options
|
||||
interface FieldOption {
|
||||
label: string;
|
||||
value: string;
|
||||
hex?: string; // optional hex color for colors field
|
||||
}
|
||||
|
||||
interface MultiSelectCellProps<T extends string> {
|
||||
field: Field<T>
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
onStartEdit?: () => void
|
||||
onEndEdit?: () => void
|
||||
hasErrors?: boolean
|
||||
options?: readonly FieldOption[]
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Memoized option item to prevent unnecessary renders for large option lists
|
||||
const OptionItem = React.memo(({
|
||||
option,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
option: FieldOption,
|
||||
isSelected: boolean,
|
||||
onSelect: (value: string) => void
|
||||
}) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => onSelect(option.value)}
|
||||
className="flex w-full"
|
||||
>
|
||||
<div className="flex items-center w-full overflow-hidden">
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 flex-shrink-0",
|
||||
isSelected ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate w-full">{option.label}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
), (prev, next) => {
|
||||
return prev.option.value === next.option.value &&
|
||||
prev.isSelected === next.isSelected;
|
||||
});
|
||||
|
||||
OptionItem.displayName = 'OptionItem';
|
||||
|
||||
// Create a virtualized list component for large option lists
|
||||
const VirtualizedOptions = React.memo(({
|
||||
options,
|
||||
selectedValues,
|
||||
onSelect,
|
||||
maxHeight = 200
|
||||
}: {
|
||||
options: FieldOption[],
|
||||
selectedValues: Set<string>,
|
||||
onSelect: (value: string) => void,
|
||||
maxHeight?: number
|
||||
}) => {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Only render visible options for better performance with large lists
|
||||
const [visibleOptions, setVisibleOptions] = useState<FieldOption[]>([]);
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
|
||||
// Constants for virtualization
|
||||
const itemHeight = 32; // Height of each option item in pixels
|
||||
const visibleCount = Math.ceil(maxHeight / itemHeight) + 2; // Number of visible items + buffer
|
||||
|
||||
// Handle scroll events
|
||||
const handleScroll = useCallback(() => {
|
||||
if (listRef.current) {
|
||||
setScrollPosition(listRef.current.scrollTop);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update visible options based on scroll position
|
||||
useEffect(() => {
|
||||
if (options.length <= visibleCount) {
|
||||
// If fewer options than visible count, just show all
|
||||
setVisibleOptions(options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate start and end indices
|
||||
const startIndex = Math.floor(scrollPosition / itemHeight);
|
||||
const endIndex = Math.min(startIndex + visibleCount, options.length);
|
||||
|
||||
// Update visible options
|
||||
setVisibleOptions(options.slice(Math.max(0, startIndex), endIndex));
|
||||
}, [options, scrollPosition, visibleCount, itemHeight]);
|
||||
|
||||
// If fewer than the threshold, render all directly
|
||||
if (options.length <= 100) {
|
||||
return (
|
||||
<div ref={listRef} className="max-h-[200px] overflow-y-auto" onScroll={handleScroll}>
|
||||
{options.map(option => (
|
||||
<OptionItem
|
||||
key={option.value}
|
||||
option={option}
|
||||
isSelected={selectedValues.has(option.value)}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="max-h-[200px] overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
style={{ height: `${Math.min(maxHeight, options.length * itemHeight)}px` }}
|
||||
>
|
||||
<div style={{ height: `${options.length * itemHeight}px`, position: 'relative' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: `${Math.floor(scrollPosition / itemHeight) * itemHeight}px`,
|
||||
width: '100%'
|
||||
}}>
|
||||
{visibleOptions.map(option => (
|
||||
<OptionItem
|
||||
key={option.value}
|
||||
option={option}
|
||||
isSelected={selectedValues.has(option.value)}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VirtualizedOptions.displayName = 'VirtualizedOptions';
|
||||
|
||||
const MultiSelectCell = <T extends string>({
|
||||
field,
|
||||
value = [],
|
||||
onChange,
|
||||
onStartEdit,
|
||||
onEndEdit,
|
||||
hasErrors,
|
||||
options: providedOptions,
|
||||
disabled = false,
|
||||
className = ''
|
||||
}: MultiSelectCellProps<T>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
// Add internal state for tracking selections - ensure value is always an array
|
||||
const [internalValue, setInternalValue] = useState<string[]>(Array.isArray(value) ? value : [])
|
||||
// Ref for the command list to enable scrolling
|
||||
const commandListRef = useRef<HTMLDivElement>(null)
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
// Add ref to track if we need to sync internal state with external value
|
||||
const shouldSyncWithExternalValue = useRef(true)
|
||||
|
||||
// Create a memoized Set for fast lookups of selected values
|
||||
const selectedValueSet = useMemo(() => new Set(internalValue), [internalValue]);
|
||||
|
||||
// Sync internalValue with external value when component mounts or value changes externally
|
||||
// Modified to prevent infinite loop by checking if values are different before updating
|
||||
useEffect(() => {
|
||||
// Only sync if we should (not during internal edits) and if not open
|
||||
if (shouldSyncWithExternalValue.current && !open) {
|
||||
const externalValue = Array.isArray(value) ? value : [];
|
||||
|
||||
// Only update if values are actually different to prevent infinite loops
|
||||
if (internalValue.length !== externalValue.length ||
|
||||
!internalValue.every(v => externalValue.includes(v)) ||
|
||||
!externalValue.every(v => internalValue.includes(v))) {
|
||||
setInternalValue(externalValue);
|
||||
}
|
||||
}
|
||||
}, [value, open, internalValue]);
|
||||
|
||||
// Handle open state changes with improved responsiveness
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
if (open && !newOpen) {
|
||||
// Prevent syncing with external value during our internal update
|
||||
shouldSyncWithExternalValue.current = false;
|
||||
|
||||
// Only update parent state when dropdown closes
|
||||
// Make a defensive copy to avoid mutations
|
||||
const valuesToCommit = [...internalValue];
|
||||
|
||||
// Immediate UI update
|
||||
setOpen(false);
|
||||
|
||||
// Update parent with the value immediately
|
||||
onChange(valuesToCommit);
|
||||
if (onEndEdit) onEndEdit();
|
||||
|
||||
// Allow syncing with external value again after a short delay
|
||||
setTimeout(() => {
|
||||
shouldSyncWithExternalValue.current = true;
|
||||
}, 0);
|
||||
} else if (newOpen && !open) {
|
||||
// When opening the dropdown, sync with external value
|
||||
const externalValue = Array.isArray(value) ? value : [];
|
||||
setInternalValue(externalValue);
|
||||
setSearchQuery(""); // Reset search query on open
|
||||
setOpen(true);
|
||||
if (onStartEdit) onStartEdit();
|
||||
} else if (!newOpen) {
|
||||
// Handle case when dropdown is already closed but handleOpenChange is called
|
||||
setOpen(false);
|
||||
}
|
||||
}, [open, internalValue, value, onChange, onStartEdit, onEndEdit]);
|
||||
|
||||
// Memoize field options to prevent unnecessary recalculations
|
||||
const selectOptions = useMemo(() => {
|
||||
const fieldType = field.fieldType;
|
||||
const fieldOptions = fieldType &&
|
||||
(fieldType.type === 'select' || fieldType.type === 'multi-select') &&
|
||||
fieldType.options ?
|
||||
fieldType.options :
|
||||
[];
|
||||
|
||||
// Use provided options or field options, ensuring they have the correct shape
|
||||
// Skip this work if we have a large number of options and they didn't change
|
||||
if (providedOptions && providedOptions.length > 0) {
|
||||
// Check if options are already in the right format
|
||||
if (typeof providedOptions[0] === 'object' && 'label' in providedOptions[0] && 'value' in providedOptions[0]) {
|
||||
// Preserve optional hex if present (hex or hex_color without #)
|
||||
return (providedOptions as any[]).map(opt => ({
|
||||
label: opt.label,
|
||||
value: String(opt.value),
|
||||
hex: opt.hex
|
||||
|| (opt.hexColor ? `#${String(opt.hexColor).replace(/^#/, '')}` : undefined)
|
||||
|| (opt.hex_color ? `#${String(opt.hex_color).replace(/^#/, '')}` : undefined)
|
||||
})) as FieldOption[];
|
||||
}
|
||||
|
||||
return (providedOptions as any[]).map(option => ({
|
||||
label: option.label || String(option.value),
|
||||
value: String(option.value),
|
||||
hex: option.hex
|
||||
|| (option.hexColor ? `#${String(option.hexColor).replace(/^#/, '')}` : undefined)
|
||||
|| (option.hex_color ? `#${String(option.hex_color).replace(/^#/, '')}` : undefined)
|
||||
}));
|
||||
}
|
||||
|
||||
// Check field options format
|
||||
if (fieldOptions.length > 0) {
|
||||
if (typeof fieldOptions[0] === 'object' && 'label' in fieldOptions[0] && 'value' in fieldOptions[0]) {
|
||||
return (fieldOptions as any[]).map(opt => ({
|
||||
label: opt.label,
|
||||
value: String(opt.value),
|
||||
hex: opt.hex
|
||||
|| (opt.hexColor ? `#${String(opt.hexColor).replace(/^#/, '')}` : undefined)
|
||||
|| (opt.hex_color ? `#${String(opt.hex_color).replace(/^#/, '')}` : undefined)
|
||||
})) as FieldOption[];
|
||||
}
|
||||
|
||||
return (fieldOptions as any[]).map(option => ({
|
||||
label: option.label || String(option.value),
|
||||
value: String(option.value),
|
||||
hex: option.hex
|
||||
|| (option.hexColor ? `#${String(option.hexColor).replace(/^#/, '')}` : undefined)
|
||||
|| (option.hex_color ? `#${String(option.hex_color).replace(/^#/, '')}` : undefined)
|
||||
}));
|
||||
}
|
||||
|
||||
// Add default option if no options available
|
||||
return [{ label: 'No options available', value: '' }];
|
||||
}, [field.fieldType, providedOptions]);
|
||||
|
||||
// Use deferredValue for search to prevent UI blocking with large lists
|
||||
const deferredSearchQuery = React.useDeferredValue(searchQuery);
|
||||
|
||||
// Memoize filtered options based on search query - efficient filtering algorithm
|
||||
const filteredOptions = useMemo(() => {
|
||||
// If no search query, return all options
|
||||
if (!deferredSearchQuery.trim()) return selectOptions;
|
||||
|
||||
const query = deferredSearchQuery.toLowerCase();
|
||||
|
||||
// Use faster algorithm for large option lists
|
||||
if (selectOptions.length > 100) {
|
||||
return selectOptions.filter(option => {
|
||||
// First check starting with the query (most relevant)
|
||||
if (option.label.toLowerCase().startsWith(query)) return true;
|
||||
|
||||
// Then check includes for more general matches
|
||||
return option.label.toLowerCase().includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
// For smaller lists, do full text search
|
||||
return selectOptions.filter(option =>
|
||||
option.label.toLowerCase().includes(query)
|
||||
);
|
||||
}, [selectOptions, deferredSearchQuery]);
|
||||
|
||||
// Sort options with selected items at the top for the dropdown - only for smaller lists
|
||||
const sortedOptions = useMemo(() => {
|
||||
// Skip expensive sorting for large lists
|
||||
if (selectOptions.length > 100) return filteredOptions;
|
||||
|
||||
return [...filteredOptions].sort((a, b) => {
|
||||
const aSelected = selectedValueSet.has(a.value);
|
||||
const bSelected = selectedValueSet.has(b.value);
|
||||
|
||||
if (aSelected && !bSelected) return -1;
|
||||
if (!aSelected && bSelected) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}, [filteredOptions, selectedValueSet, selectOptions.length]);
|
||||
|
||||
// Memoize selected values display
|
||||
const selectedValues = useMemo(() => {
|
||||
// Use a map for looking up options by value for better performance
|
||||
const optionsMap = new Map(selectOptions.map(opt => [opt.value, opt]));
|
||||
|
||||
return internalValue.map(v => {
|
||||
const option = optionsMap.get(v);
|
||||
return {
|
||||
value: v,
|
||||
label: option ? option.label : String(v)
|
||||
};
|
||||
});
|
||||
}, [internalValue, selectOptions]);
|
||||
|
||||
// Update the handleSelect to operate on internalValue instead of directly calling onChange
|
||||
const handleSelect = useCallback((selectedValue: string) => {
|
||||
// Prevent syncing with external value during our internal update
|
||||
shouldSyncWithExternalValue.current = false;
|
||||
|
||||
setInternalValue(prev => {
|
||||
let newValue;
|
||||
if (prev.includes(selectedValue)) {
|
||||
// Remove the value
|
||||
newValue = prev.filter(v => v !== selectedValue);
|
||||
} else {
|
||||
// Add the value - make a new array to avoid mutations
|
||||
newValue = [...prev, selectedValue];
|
||||
}
|
||||
return newValue;
|
||||
});
|
||||
|
||||
// Allow syncing with external value again after a short delay
|
||||
setTimeout(() => {
|
||||
shouldSyncWithExternalValue.current = true;
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
// Handle wheel scroll in dropdown
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (commandListRef.current) {
|
||||
e.stopPropagation();
|
||||
commandListRef.current.scrollTop += e.deltaY;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = className.split(' ');
|
||||
return classNames.includes(cls);
|
||||
};
|
||||
|
||||
// If disabled, just render the value without any interactivity
|
||||
if (disabled) {
|
||||
const displayValue = internalValue.length > 0
|
||||
? internalValue.map(val => {
|
||||
const option = selectOptions.find(opt => opt.value === val);
|
||||
return option ? option.label : val;
|
||||
}).join(', ')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
|
||||
"border",
|
||||
hasErrors ? "border-destructive" : "border-input",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayValue || ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => {
|
||||
// Only open the popover if we're not in copy down mode
|
||||
if (!hasClass('!bg-blue-100') && !hasClass('!bg-blue-200') && !hasClass('hover:!bg-blue-100')) {
|
||||
setOpen(o);
|
||||
handleOpenChange(o);
|
||||
}
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"border",
|
||||
!internalValue.length && "text-muted-foreground",
|
||||
hasErrors ? "border-destructive" : "",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
|
||||
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Don't open the dropdown if we're in copy down mode
|
||||
if (hasClass('!bg-blue-100') || hasClass('!bg-blue-200') || hasClass('hover:!bg-blue-100')) {
|
||||
// Let the parent cell handle the click by NOT preventing default or stopping propagation
|
||||
return;
|
||||
}
|
||||
|
||||
// Only prevent default and stop propagation if not in copy down mode
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Toggle the open state and call handleOpenChange to ensure values are saved
|
||||
const newOpenState = !open;
|
||||
setOpen(newOpenState);
|
||||
handleOpenChange(newOpenState);
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex items-center w-full justify-between">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{internalValue.length === 0 ? (
|
||||
<span className="text-muted-foreground truncate w-full">Select...</span>
|
||||
) : internalValue.length === 1 ? (
|
||||
<span className="truncate w-full">{selectedValues[0].label}</span>
|
||||
) : (
|
||||
<>
|
||||
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
|
||||
{internalValue.length} selected
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{selectedValues.map(v => v.label).join(', ')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronsUpDown className="mx-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0 w-[var(--radix-popover-trigger-width)]"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search..."
|
||||
className="h-9"
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
/>
|
||||
<CommandList
|
||||
ref={commandListRef}
|
||||
onWheel={handleWheel}
|
||||
className="max-h-[200px]"
|
||||
>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sortedOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{field.key === 'colors' && option.hex && (
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full ${option.hex.toLowerCase() === '#ffffff' || option.hex.toLowerCase() === '#fff' ? 'border' : ''}`}
|
||||
style={{
|
||||
backgroundColor: option.hex,
|
||||
...(option.hex.toLowerCase() === '#ffffff' || option.hex.toLowerCase() === '#fff' ? { borderColor: '#000' } : {})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
{selectedValueSet.has(option.value) && (
|
||||
<Check className="ml-auto h-4 w-4" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
MultiSelectCell.displayName = 'MultiSelectCell';
|
||||
|
||||
export default React.memo(MultiSelectCell, (prev, next) => {
|
||||
// Check primitive props first (cheap comparisons)
|
||||
if (prev.hasErrors !== next.hasErrors) return false;
|
||||
if (prev.disabled !== next.disabled) return false;
|
||||
if (prev.className !== next.className) return false;
|
||||
|
||||
// Check field reference
|
||||
if (prev.field !== next.field) return false;
|
||||
|
||||
// Check value arrays (potentially expensive for large arrays)
|
||||
// Handle undefined or null values safely
|
||||
const prevValue = prev.value || [];
|
||||
const nextValue = next.value || [];
|
||||
|
||||
if (prevValue.length !== nextValue.length) return false;
|
||||
for (let i = 0; i < prevValue.length; i++) {
|
||||
if (prevValue[i] !== nextValue[i]) return false;
|
||||
}
|
||||
|
||||
// Check options (potentially expensive for large option lists)
|
||||
const prevOptions = prev.options || [];
|
||||
const nextOptions = next.options || [];
|
||||
if (prevOptions.length !== nextOptions.length) return false;
|
||||
|
||||
// For large option lists, just compare references
|
||||
if (prevOptions.length > 100) {
|
||||
return prevOptions === nextOptions;
|
||||
}
|
||||
|
||||
// For smaller lists, do a shallow comparison
|
||||
for (let i = 0; i < prevOptions.length; i++) {
|
||||
if (prevOptions[i] !== nextOptions[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
-238
@@ -1,238 +0,0 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Field } from '../../../../types'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface MultilineInputProps<T extends string> {
|
||||
field: Field<T>
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
hasErrors?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MultilineInput = <T extends string>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
hasErrors = false,
|
||||
disabled = false,
|
||||
className = ''
|
||||
}: MultilineInputProps<T>) => {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
||||
const cellRef = useRef<HTMLDivElement>(null);
|
||||
const preventReopenRef = useRef(false);
|
||||
const pendingChangeRef = useRef<string | null>(null);
|
||||
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = (className || '').split(' ');
|
||||
return classNames.includes(cls);
|
||||
};
|
||||
|
||||
// Initialize localDisplayValue on mount and when value changes externally
|
||||
useEffect(() => {
|
||||
if (localDisplayValue === null ||
|
||||
(typeof value === 'string' && typeof localDisplayValue === 'string' &&
|
||||
value.trim() !== localDisplayValue.trim())) {
|
||||
setLocalDisplayValue(value);
|
||||
}
|
||||
}, [value, localDisplayValue]);
|
||||
|
||||
// Process any pending changes in the background
|
||||
useEffect(() => {
|
||||
if (pendingChangeRef.current !== null && !popoverOpen) {
|
||||
const newValue = pendingChangeRef.current;
|
||||
pendingChangeRef.current = null;
|
||||
// Apply changes after the popover is closed
|
||||
if (newValue !== value) {
|
||||
onChange(newValue);
|
||||
}
|
||||
}
|
||||
}, [popoverOpen, onChange, value]);
|
||||
|
||||
// Handle trigger click to toggle the popover
|
||||
const handleTriggerClick = useCallback((e: React.MouseEvent) => {
|
||||
if (preventReopenRef.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
preventReopenRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process if not already open
|
||||
if (!popoverOpen) {
|
||||
setPopoverOpen(true);
|
||||
// Initialize edit value from the current display
|
||||
setEditValue(localDisplayValue || value || '');
|
||||
}
|
||||
}, [popoverOpen, value, localDisplayValue]);
|
||||
|
||||
// Handle immediate close of popover
|
||||
const handleClosePopover = useCallback(() => {
|
||||
// Only process if we have changes
|
||||
if (editValue !== value || editValue !== localDisplayValue) {
|
||||
// Store pending changes for async processing
|
||||
pendingChangeRef.current = editValue;
|
||||
|
||||
// Update local display immediately
|
||||
setLocalDisplayValue(editValue);
|
||||
|
||||
// Queue up the change to be processed in the background
|
||||
setTimeout(() => {
|
||||
onChange(editValue);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Immediately close popover
|
||||
setPopoverOpen(false);
|
||||
|
||||
// Prevent reopening
|
||||
preventReopenRef.current = true;
|
||||
setTimeout(() => {
|
||||
preventReopenRef.current = false;
|
||||
}, 100);
|
||||
}, [editValue, value, localDisplayValue, onChange]);
|
||||
|
||||
// Handle clicking outside the popover
|
||||
const handleInteractOutside = useCallback(() => {
|
||||
handleClosePopover();
|
||||
}, [handleClosePopover]);
|
||||
|
||||
// Handle popover open/close
|
||||
const handlePopoverOpenChange = useCallback((open: boolean) => {
|
||||
if (!open && popoverOpen) {
|
||||
// Just call the close handler
|
||||
handleClosePopover();
|
||||
} else if (open && !popoverOpen) {
|
||||
// When opening, set edit value from current display
|
||||
setEditValue(localDisplayValue || value || '');
|
||||
setPopoverOpen(true);
|
||||
}
|
||||
}, [value, popoverOpen, handleClosePopover, localDisplayValue]);
|
||||
|
||||
// Handle direct input change
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEditValue(e.target.value);
|
||||
}, []);
|
||||
|
||||
// Calculate display value
|
||||
const displayValue = localDisplayValue !== null ? localDisplayValue : (value ?? '');
|
||||
|
||||
// Add outline even when not in focus
|
||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
|
||||
|
||||
// If disabled, just render the value without any interactivity
|
||||
if (disabled) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "border-input"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
|
||||
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full" ref={cellRef}>
|
||||
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
onClick={handleTriggerClick}
|
||||
className={cn(
|
||||
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full cursor-pointer",
|
||||
"overflow-hidden whitespace-pre-wrap",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "border-input",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
|
||||
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : 'pointer'
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0 shadow-lg rounded-md"
|
||||
style={{ width: cellRef.current?.offsetWidth || 'auto' }}
|
||||
align="start"
|
||||
side="bottom"
|
||||
alignOffset={0}
|
||||
sideOffset={-80}
|
||||
avoidCollisions={false}
|
||||
onInteractOutside={handleInteractOutside}
|
||||
forceMount
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleClosePopover}
|
||||
className="h-6 w-6 text-muted-foreground absolute top-0.5 right-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<Textarea
|
||||
value={editValue}
|
||||
onChange={handleChange}
|
||||
className="min-h-[200px] border-none focus-visible:ring-0 rounded-none p-2"
|
||||
placeholder={`Enter ${field.label || 'text'}...`}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MultilineInput, (prev, next) => {
|
||||
if (prev.hasErrors !== next.hasErrors) return false;
|
||||
if (prev.disabled !== next.disabled) return false;
|
||||
if (prev.field !== next.field) return false;
|
||||
if (prev.value !== next.value) return false;
|
||||
if (prev.className !== next.className) return false;
|
||||
return true;
|
||||
});
|
||||
-295
@@ -1,295 +0,0 @@
|
||||
import { useState, useRef, useCallback, useMemo, useEffect } from 'react'
|
||||
import { Field } from '../../../../types'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import React from 'react'
|
||||
|
||||
export type SelectOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface SelectCellProps<T extends string> {
|
||||
field: Field<T>
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
onStartEdit?: () => void
|
||||
onEndEdit?: () => void
|
||||
hasErrors?: boolean
|
||||
options: readonly any[]
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Lightweight version of the select cell with minimal dependencies
|
||||
const SelectCell = <T extends string>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
onStartEdit,
|
||||
onEndEdit,
|
||||
hasErrors,
|
||||
options = [],
|
||||
disabled = false,
|
||||
className = ''
|
||||
}: SelectCellProps<T>) => {
|
||||
// State for the open/closed state of the dropdown
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Ref for the command list
|
||||
const commandListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Controlled state for the internal value - this is key to prevent reopening
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
|
||||
// State to track if the value is being processed/validated
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = className.split(' ');
|
||||
return classNames.includes(cls);
|
||||
};
|
||||
|
||||
// Update internal value when prop value changes
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
// When the value prop changes, it means validation is complete
|
||||
setIsProcessing(false);
|
||||
}, [value]);
|
||||
|
||||
// Memoize options processing to avoid recalculation on every render
|
||||
const selectOptions = useMemo(() => {
|
||||
// Fast path check - if we have raw options, just use those
|
||||
if (options && options.length > 0) {
|
||||
// Check if options already have the correct structure to avoid mapping
|
||||
if (typeof options[0] === 'object' && 'label' in options[0] && 'value' in options[0]) {
|
||||
return options as SelectOption[];
|
||||
}
|
||||
|
||||
// Optimize mapping to only convert what's needed
|
||||
return options.map((option: any) => ({
|
||||
label: option.label || String(option.value || option),
|
||||
value: String(option.value || option)
|
||||
}));
|
||||
}
|
||||
|
||||
// Fall back to field options if no direct options provided
|
||||
const fieldType = field.fieldType;
|
||||
if (fieldType &&
|
||||
(fieldType.type === 'select' || fieldType.type === 'multi-select') &&
|
||||
(fieldType as any).options) {
|
||||
const fieldOptions = (fieldType as any).options;
|
||||
|
||||
// Check if fieldOptions already have the correct structure
|
||||
if (fieldOptions.length > 0 && typeof fieldOptions[0] === 'object' &&
|
||||
'label' in fieldOptions[0] && 'value' in fieldOptions[0]) {
|
||||
return fieldOptions as SelectOption[];
|
||||
}
|
||||
|
||||
return fieldOptions.map((option: any) => ({
|
||||
label: option.label || String(option.value || option),
|
||||
value: String(option.value || option)
|
||||
}));
|
||||
}
|
||||
|
||||
// Return default empty option if no options available
|
||||
return [{ label: 'No options available', value: '' }];
|
||||
}, [field.fieldType, options]);
|
||||
|
||||
// Memoize display value to avoid recalculation on every render
|
||||
const displayValue = useMemo(() => {
|
||||
if (!internalValue) return 'Select...';
|
||||
|
||||
// Fast path: direct lookup by value using find
|
||||
const stringValue = String(internalValue);
|
||||
const found = selectOptions.find((option: SelectOption) => String(option.value) === stringValue);
|
||||
return found ? found.label : stringValue;
|
||||
}, [internalValue, selectOptions]);
|
||||
|
||||
// Handle wheel scroll in dropdown - optimized with passive event
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (commandListRef.current) {
|
||||
e.stopPropagation();
|
||||
commandListRef.current.scrollTop += e.deltaY;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle selection - UPDATE INTERNAL VALUE FIRST
|
||||
const handleSelect = useCallback((selectedValue: string) => {
|
||||
// Store the selected value to prevent it being lost in async operations
|
||||
const valueToCommit = selectedValue;
|
||||
|
||||
// 1. Update internal value immediately to prevent UI flicker
|
||||
setInternalValue(valueToCommit);
|
||||
|
||||
// 2. Close the dropdown immediately
|
||||
setOpen(false);
|
||||
|
||||
// 3. Set processing state to show visual indicator
|
||||
setIsProcessing(true);
|
||||
|
||||
// 4. Only then call the onChange callback
|
||||
// This prevents the parent component from re-rendering and causing dropdown to reopen
|
||||
if (onEndEdit) onEndEdit();
|
||||
|
||||
// 5. Call onChange synchronously to avoid race conditions with other cells
|
||||
onChange(valueToCommit);
|
||||
|
||||
// 6. Clear processing state after a short delay - reduced for responsiveness
|
||||
setTimeout(() => {
|
||||
setIsProcessing(false);
|
||||
}, 50);
|
||||
}, [onChange, onEndEdit]);
|
||||
|
||||
// If disabled, render a static view
|
||||
if (disabled && field.key !== 'line' && field.key !== 'subline') {
|
||||
const displayText = displayValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
|
||||
"border",
|
||||
hasErrors ? "border-destructive" : "border-input",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{displayText || ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
// Only open the popover if we're not in copy down mode
|
||||
if (!hasClass('!bg-blue-100') && !hasClass('!bg-blue-200') && !hasClass('hover:!bg-blue-100')) {
|
||||
setOpen(isOpen);
|
||||
if (isOpen && onStartEdit) onStartEdit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"border",
|
||||
!internalValue && "text-muted-foreground",
|
||||
isProcessing && "text-muted-foreground",
|
||||
hasErrors ? "border-destructive" : "",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
|
||||
hasClass('!bg-blue-200') ? '#bfdbfe' :
|
||||
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
|
||||
hasClass('!border-blue-200') ? '#bfdbfe' :
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
|
||||
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Don't open the dropdown if we're in copy down mode
|
||||
if (hasClass('!bg-blue-100') || hasClass('!bg-blue-200') || hasClass('hover:!bg-blue-100')) {
|
||||
// Let the parent cell handle the click by NOT preventing default or stopping propagation
|
||||
return;
|
||||
}
|
||||
|
||||
// Only prevent default and stop propagation if not in copy down mode
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
if (!open && onStartEdit) onStartEdit();
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span className={isProcessing ? "opacity-70" : ""}>
|
||||
{displayValue}
|
||||
</span>
|
||||
<ChevronsUpDown className="mr-1.5 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0 w-[var(--radix-popover-trigger-width)]"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<Command shouldFilter={true}>
|
||||
<CommandInput
|
||||
placeholder="Search..."
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList
|
||||
ref={commandListRef}
|
||||
onWheel={handleWheel}
|
||||
className="max-h-[200px]"
|
||||
>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{selectOptions.map((option: SelectOption) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
{String(option.value) === String(internalValue) && (
|
||||
<Check className="ml-auto h-4 w-4" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
// Optimize memo comparison to avoid unnecessary re-renders
|
||||
export default React.memo(SelectCell, (prev, next) => {
|
||||
// Only rerender when these critical props change
|
||||
if (prev.value !== next.value) return false;
|
||||
if (prev.hasErrors !== next.hasErrors) return false;
|
||||
if (prev.disabled !== next.disabled) return false;
|
||||
if (prev.className !== next.className) return false;
|
||||
|
||||
// Only check options array for reference equality - we're handling deep comparison internally
|
||||
if (prev.options !== next.options &&
|
||||
(prev.options.length !== next.options.length)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
-1088
File diff suppressed because it is too large
Load Diff
-171
@@ -1,171 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { Field, Fields, RowHook } from '../../../types';
|
||||
import type { Meta } from '../types';
|
||||
import { ErrorType, ValidationError } from '../../../types';
|
||||
import { RowData, isEmpty } from './validationTypes';
|
||||
|
||||
// Create a cache for validation results to avoid repeated validation of the same data
|
||||
const validationResultCache = new Map();
|
||||
|
||||
// Optimize cache clearing - only clear when necessary
|
||||
export const clearValidationCacheForField = (fieldKey: string, specificValue?: any) => {
|
||||
if (specificValue !== undefined) {
|
||||
// Only clear specific field-value combinations
|
||||
const specificKey = `${fieldKey}-${String(specificValue)}`;
|
||||
validationResultCache.forEach((_, key) => {
|
||||
if (key.startsWith(specificKey)) {
|
||||
validationResultCache.delete(key);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Clear all entries for the field
|
||||
validationResultCache.forEach((_, key) => {
|
||||
if (key.startsWith(`${fieldKey}-`)) {
|
||||
validationResultCache.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add a special function to clear all uniqueness validation caches
|
||||
export const clearAllUniquenessCaches = () => {
|
||||
// Clear cache for common unique fields
|
||||
['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'].forEach(fieldKey => {
|
||||
clearValidationCacheForField(fieldKey);
|
||||
});
|
||||
|
||||
// Also clear any cache entries that might involve uniqueness validation
|
||||
validationResultCache.forEach((_, key) => {
|
||||
if (key.includes('unique')) {
|
||||
validationResultCache.delete(key);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useFieldValidation = <T extends string>(
|
||||
fields: Fields<T>,
|
||||
rowHook?: RowHook<T>
|
||||
) => {
|
||||
// Validate a single field
|
||||
const validateField = useCallback((
|
||||
value: any,
|
||||
field: Field<T>
|
||||
): ValidationError[] => {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
if (!field.validations) return errors;
|
||||
|
||||
// Create a cache key using field key, value, and validation rules
|
||||
const cacheKey = `${field.key}-${String(value)}-${JSON.stringify(field.validations)}`;
|
||||
|
||||
// Check cache first to avoid redundant validation
|
||||
if (validationResultCache.has(cacheKey)) {
|
||||
return validationResultCache.get(cacheKey) || [];
|
||||
}
|
||||
|
||||
field.validations.forEach(validation => {
|
||||
switch (validation.rule) {
|
||||
case 'required':
|
||||
// Use the shared isEmpty function
|
||||
if (isEmpty(value)) {
|
||||
errors.push({
|
||||
message: validation.errorMessage || 'This field is required',
|
||||
level: validation.level || 'error',
|
||||
type: ErrorType.Required
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'unique':
|
||||
// Unique validation happens at table level, not here
|
||||
break;
|
||||
|
||||
case 'regex':
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
try {
|
||||
const regex = new RegExp(validation.value, validation.flags);
|
||||
if (!regex.test(String(value))) {
|
||||
errors.push({
|
||||
message: validation.errorMessage,
|
||||
level: validation.level || 'error',
|
||||
type: ErrorType.Regex
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Invalid regex in validation:', error);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Store results in cache to speed up future validations
|
||||
validationResultCache.set(cacheKey, errors);
|
||||
|
||||
return errors;
|
||||
}, []);
|
||||
|
||||
// Validate a single row
|
||||
const validateRow = useCallback(async (
|
||||
row: RowData<T>,
|
||||
rowIndex: number,
|
||||
allRows: RowData<T>[]
|
||||
): Promise<Meta> => {
|
||||
// Run field-level validations
|
||||
const fieldErrors: Record<string, ValidationError[]> = {};
|
||||
|
||||
fields.forEach(field => {
|
||||
const value = row[String(field.key) as keyof typeof row];
|
||||
const errors = validateField(value, field as Field<T>);
|
||||
|
||||
if (errors.length > 0) {
|
||||
fieldErrors[String(field.key)] = errors;
|
||||
}
|
||||
});
|
||||
|
||||
// Special validation for supplier and company fields - only apply if the field exists in fields
|
||||
if (fields.some(field => String(field.key) === 'supplier') && isEmpty(row.supplier)) {
|
||||
fieldErrors['supplier'] = [{
|
||||
message: 'Supplier is required',
|
||||
level: 'error',
|
||||
type: ErrorType.Required
|
||||
}];
|
||||
}
|
||||
|
||||
if (fields.some(field => String(field.key) === 'company') && isEmpty(row.company)) {
|
||||
fieldErrors['company'] = [{
|
||||
message: 'Company is required',
|
||||
level: 'error',
|
||||
type: ErrorType.Required
|
||||
}];
|
||||
}
|
||||
|
||||
// Run row hook if provided
|
||||
let rowHookResult: Meta = {
|
||||
__index: row.__index || String(rowIndex)
|
||||
};
|
||||
if (rowHook) {
|
||||
try {
|
||||
// Call the row hook and extract only the __index property
|
||||
const result = await rowHook(row, rowIndex, allRows);
|
||||
rowHookResult.__index = result.__index || rowHookResult.__index;
|
||||
} catch (error) {
|
||||
console.error('Error in row hook:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// We no longer need to merge errors since we're not storing them in the row data
|
||||
// The calling code should handle storing errors in the validationErrors Map
|
||||
|
||||
return {
|
||||
__index: row.__index || String(rowIndex)
|
||||
};
|
||||
}, [fields, validateField, rowHook]);
|
||||
|
||||
return {
|
||||
validateField,
|
||||
validateRow,
|
||||
clearValidationCacheForField,
|
||||
clearAllUniquenessCaches
|
||||
};
|
||||
};
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { FilterState, RowData } from './validationTypes';
|
||||
import type { Fields } from '../../../types';
|
||||
import { ValidationError } from '../../../types';
|
||||
|
||||
export const useFilterManagement = <T extends string>(
|
||||
data: RowData<T>[],
|
||||
fields: Fields<T>,
|
||||
validationErrors: Map<number, Record<string, ValidationError[]>>
|
||||
) => {
|
||||
// Filter state
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
searchText: "",
|
||||
showErrorsOnly: false,
|
||||
filterField: null,
|
||||
filterValue: null,
|
||||
});
|
||||
|
||||
// Filter data based on current filter state
|
||||
const filteredData = useMemo(() => {
|
||||
// Fast path: no filters active, return original data reference to avoid re-renders
|
||||
const noSearch = !filters.searchText || filters.searchText.trim() === '';
|
||||
const noErrorsOnly = !filters.showErrorsOnly;
|
||||
const noFieldFilter = !filters.filterField || !filters.filterValue || filters.filterValue.trim() === '';
|
||||
|
||||
if (noSearch && noErrorsOnly && noFieldFilter) {
|
||||
return data; // preserve reference; prevents full table rerender on error map changes
|
||||
}
|
||||
|
||||
return data.filter((row, index) => {
|
||||
// Filter by search text
|
||||
if (filters.searchText) {
|
||||
const searchLower = filters.searchText.toLowerCase();
|
||||
const matchesSearch = fields.some((field) => {
|
||||
const value = row[field.key as keyof typeof row];
|
||||
if (value === undefined || value === null) return false;
|
||||
return String(value).toLowerCase().includes(searchLower);
|
||||
});
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
|
||||
// Filter by errors
|
||||
if (filters.showErrorsOnly) {
|
||||
const hasErrors =
|
||||
validationErrors.has(index) &&
|
||||
Object.keys(validationErrors.get(index) || {}).length > 0;
|
||||
if (!hasErrors) return false;
|
||||
}
|
||||
|
||||
// Filter by field value
|
||||
if (filters.filterField && filters.filterValue) {
|
||||
const fieldValue = row[filters.filterField as keyof typeof row];
|
||||
if (fieldValue === undefined) return false;
|
||||
|
||||
const valueStr = String(fieldValue).toLowerCase();
|
||||
const filterStr = filters.filterValue.toLowerCase();
|
||||
|
||||
if (!valueStr.includes(filterStr)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [data, fields, filters, validationErrors]);
|
||||
|
||||
// Get filter fields
|
||||
const filterFields = useMemo(() => {
|
||||
return fields.map((field) => ({
|
||||
key: String(field.key),
|
||||
label: field.label,
|
||||
}));
|
||||
}, [fields]);
|
||||
|
||||
// Get filter values for the selected field
|
||||
const filterValues = useMemo(() => {
|
||||
if (!filters.filterField) return [];
|
||||
|
||||
// Get unique values for the selected field
|
||||
const uniqueValues = new Set<string>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = row[filters.filterField as keyof typeof row];
|
||||
if (value !== undefined && value !== null) {
|
||||
uniqueValues.add(String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueValues).map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
}));
|
||||
}, [data, filters.filterField]);
|
||||
|
||||
// Update filters
|
||||
const updateFilters = useCallback((newFilters: Partial<FilterState>) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
...newFilters,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Reset filters
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilters({
|
||||
searchText: "",
|
||||
showErrorsOnly: false,
|
||||
filterField: null,
|
||||
filterValue: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
filters,
|
||||
filteredData,
|
||||
filterFields,
|
||||
filterValues,
|
||||
updateFilters,
|
||||
resetFilters
|
||||
};
|
||||
};
|
||||
-268
@@ -1,268 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { Fields } from '@/components/product-import/types';
|
||||
import type { RowData } from './validationTypes';
|
||||
import { cleanPriceField } from '../utils/priceUtils';
|
||||
|
||||
/**
|
||||
* Custom hook for handling initial data validation
|
||||
*
|
||||
* Performs comprehensive validation on import data including:
|
||||
* - Price field cleaning and formatting
|
||||
* - Required field validation
|
||||
* - Regex pattern validation
|
||||
* - Batch processing for performance
|
||||
*
|
||||
* @param data - Array of row data to validate
|
||||
* @param fields - Field configuration with validation rules
|
||||
* @param setData - Function to update data after cleaning
|
||||
* @param setValidationErrors - Function to set validation errors
|
||||
* @param validateUniqueItemNumbers - Async function to validate uniqueness
|
||||
* @param upcValidationComplete - Flag indicating UPC validation is done
|
||||
* @param onComplete - Callback when validation is complete
|
||||
*/
|
||||
export function useInitialValidation<T extends string>({
|
||||
data,
|
||||
fields,
|
||||
setData,
|
||||
setValidationErrors,
|
||||
validateUniqueItemNumbers,
|
||||
upcValidationComplete,
|
||||
onComplete,
|
||||
}: {
|
||||
data: RowData<T>[];
|
||||
fields: Fields<T>;
|
||||
setData: (data: RowData<T>[]) => void;
|
||||
setValidationErrors: Dispatch<SetStateAction<Map<number, Record<string, any[]>>>>;
|
||||
validateUniqueItemNumbers: () => Promise<void>;
|
||||
upcValidationComplete: boolean;
|
||||
onComplete?: () => void;
|
||||
}) {
|
||||
const hasRunRef = useRef(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only run once
|
||||
if (hasRunRef.current) return;
|
||||
|
||||
// Wait for UPC validation to complete first
|
||||
if (!upcValidationComplete) return;
|
||||
|
||||
// Handle empty dataset immediately
|
||||
if (!data || data.length === 0) {
|
||||
hasRunRef.current = true;
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hasRunRef.current = true;
|
||||
setIsValidating(true);
|
||||
|
||||
const runValidation = async () => {
|
||||
console.log('Running initial validation...');
|
||||
|
||||
// Extract field groups for validation
|
||||
const requiredFields = fields.filter((field) =>
|
||||
field.validations?.some((v) => v.rule === 'required')
|
||||
);
|
||||
const regexFields = fields.filter((field) =>
|
||||
field.validations?.some((v) => v.rule === 'regex')
|
||||
);
|
||||
|
||||
console.log(`Validating ${requiredFields.length} required fields, ${regexFields.length} regex fields`);
|
||||
|
||||
// Dynamic batch size based on dataset size
|
||||
const BATCH_SIZE = data.length <= 50 ? data.length : 25;
|
||||
const totalBatches = Math.ceil(data.length / BATCH_SIZE);
|
||||
|
||||
// Initialize containers
|
||||
const newData = [...data];
|
||||
const validationErrorsTemp = new Map<number, Record<string, any[]>>();
|
||||
|
||||
// Process batches
|
||||
for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
|
||||
const startIdx = batchNum * BATCH_SIZE;
|
||||
const endIdx = Math.min(startIdx + BATCH_SIZE, data.length);
|
||||
|
||||
console.log(`Processing batch ${batchNum + 1}/${totalBatches} (rows ${startIdx}-${endIdx - 1})`);
|
||||
|
||||
// Process all rows in this batch
|
||||
const batchPromises = [];
|
||||
|
||||
for (let rowIndex = startIdx; rowIndex < endIdx; rowIndex++) {
|
||||
batchPromises.push(
|
||||
processRow(
|
||||
rowIndex,
|
||||
data[rowIndex],
|
||||
newData,
|
||||
requiredFields,
|
||||
regexFields,
|
||||
validationErrorsTemp
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(batchPromises);
|
||||
|
||||
// Yield to UI thread for large datasets
|
||||
if (batchNum % 2 === 1 || data.length > 500) {
|
||||
await new Promise((resolve) => setTimeout(resolve, data.length > 1000 ? 10 : 5));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Batch validation complete, applying results...');
|
||||
|
||||
// Apply validation errors
|
||||
setValidationErrors(validationErrorsTemp);
|
||||
|
||||
// Apply data changes (price formatting)
|
||||
if (JSON.stringify(data) !== JSON.stringify(newData)) {
|
||||
setData(newData);
|
||||
}
|
||||
|
||||
// Run uniqueness validation
|
||||
console.log('Running uniqueness validation...');
|
||||
await validateUniqueItemNumbers();
|
||||
|
||||
console.log('Initial validation complete');
|
||||
setIsValidating(false);
|
||||
|
||||
// Notify completion
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
runValidation().catch((error) => {
|
||||
console.error('Error during initial validation:', error);
|
||||
setIsValidating(false);
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
});
|
||||
}, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers, upcValidationComplete, onComplete]);
|
||||
|
||||
return { isValidating };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single row: clean data and validate
|
||||
*/
|
||||
function processRow<T extends string>(
|
||||
rowIndex: number,
|
||||
row: RowData<T>,
|
||||
newData: RowData<T>[],
|
||||
requiredFields: Fields<T>,
|
||||
regexFields: Fields<T>,
|
||||
validationErrorsTemp: Map<number, Record<string, any[]>>
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// Skip empty rows
|
||||
if (!row) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldErrors: Record<string, any[]> = {};
|
||||
let hasErrors = false;
|
||||
const rowAsRecord = row as Record<string, any>;
|
||||
|
||||
// Clean price fields if needed
|
||||
let needsUpdate = false;
|
||||
let cleanedRow = row;
|
||||
|
||||
if (
|
||||
rowAsRecord.msrp &&
|
||||
typeof rowAsRecord.msrp === 'string' &&
|
||||
(rowAsRecord.msrp.includes('$') || rowAsRecord.msrp.includes(','))
|
||||
) {
|
||||
if (!needsUpdate) {
|
||||
cleanedRow = { ...row } as RowData<T>;
|
||||
needsUpdate = true;
|
||||
}
|
||||
(cleanedRow as Record<string, any>).msrp = cleanPriceField(rowAsRecord.msrp);
|
||||
}
|
||||
|
||||
if (
|
||||
rowAsRecord.cost_each &&
|
||||
typeof rowAsRecord.cost_each === 'string' &&
|
||||
(rowAsRecord.cost_each.includes('$') || rowAsRecord.cost_each.includes(','))
|
||||
) {
|
||||
if (!needsUpdate) {
|
||||
cleanedRow = { ...row } as RowData<T>;
|
||||
needsUpdate = true;
|
||||
}
|
||||
(cleanedRow as Record<string, any>).cost_each = cleanPriceField(rowAsRecord.cost_each);
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
newData[rowIndex] = cleanedRow;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
for (const field of requiredFields) {
|
||||
const key = String(field.key);
|
||||
const value = row[key as keyof typeof row];
|
||||
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === 'object' && value !== null && Object.keys(value).length === 0)
|
||||
) {
|
||||
fieldErrors[key] = [
|
||||
{
|
||||
message:
|
||||
field.validations?.find((v) => v.rule === 'required')?.errorMessage ||
|
||||
'This field is required',
|
||||
level: 'error',
|
||||
source: 'row',
|
||||
type: 'required',
|
||||
},
|
||||
];
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate regex fields
|
||||
for (const field of regexFields) {
|
||||
const key = String(field.key);
|
||||
const value = row[key as keyof typeof row];
|
||||
|
||||
// Skip empty values (handled by required validation)
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const regexValidation = field.validations?.find((v) => v.rule === 'regex');
|
||||
if (regexValidation) {
|
||||
try {
|
||||
const regex = new RegExp(regexValidation.value, regexValidation.flags);
|
||||
if (!regex.test(String(value))) {
|
||||
fieldErrors[key] = [
|
||||
{
|
||||
message: regexValidation.errorMessage,
|
||||
level: regexValidation.level || 'error',
|
||||
source: 'row',
|
||||
type: 'regex',
|
||||
},
|
||||
];
|
||||
hasErrors = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Invalid regex in validation:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store errors if any
|
||||
if (hasErrors) {
|
||||
validationErrorsTemp.set(rowIndex, fieldErrors);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
-543
@@ -1,543 +0,0 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/**
|
||||
* Custom hook for managing product lines and sublines fetching with caching
|
||||
*/
|
||||
export const useProductLinesFetching = (data: Record<string, any>[]) => {
|
||||
// State for tracking product lines and sublines per row
|
||||
const [rowProductLines, setRowProductLines] = useState<Record<string, any[]>>({});
|
||||
const [rowSublines, setRowSublines] = useState<Record<string, any[]>>({});
|
||||
|
||||
// State for tracking loading states
|
||||
const [isLoadingLines, setIsLoadingLines] = useState<Record<string, boolean>>({});
|
||||
const [isLoadingSublines, setIsLoadingSublines] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Add caches for product lines and sublines by company/line ID
|
||||
const [companyLinesCache, setCompanyLinesCache] = useState<Record<string, any[]>>({});
|
||||
const [lineSublineCache, setLineSublineCache] = useState<Record<string, any[]>>({});
|
||||
|
||||
// Track in-flight requests to prevent duplicate fetches (especially in StrictMode/dev)
|
||||
const pendingCompanyRequests = useRef<Set<string>>(new Set());
|
||||
const pendingLineRequests = useRef<Set<string>>(new Set());
|
||||
|
||||
// Function to fetch product lines for a specific company - memoized
|
||||
const fetchProductLines = useCallback(async (rowIndex: string | number | undefined | null, companyId: string) => {
|
||||
try {
|
||||
// Only fetch if we have a valid company ID
|
||||
if (!companyId) return;
|
||||
|
||||
const logRowKey = (rowIndex !== undefined && rowIndex !== null) ? rowIndex : 'all-matching-rows';
|
||||
console.log(`Fetching product lines for row ${logRowKey}, company ${companyId}`);
|
||||
|
||||
// Check if we already have this company's lines in the cache
|
||||
if (companyLinesCache[companyId]) {
|
||||
console.log(`Using cached product lines for company ${companyId}`);
|
||||
// Use cached data
|
||||
const cached = companyLinesCache[companyId];
|
||||
// Update the specific row if provided
|
||||
if (rowIndex !== undefined && rowIndex !== null) {
|
||||
setRowProductLines(prev => ({ ...prev, [rowIndex]: cached }));
|
||||
}
|
||||
// Also update all rows that currently have this company set
|
||||
const updates: Record<string, any[]> = {};
|
||||
data.forEach((row, idx) => {
|
||||
if (row.company && String(row.company) === String(companyId)) {
|
||||
const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx;
|
||||
updates[key] = cached;
|
||||
}
|
||||
});
|
||||
if (Object.keys(updates).length > 0) {
|
||||
setRowProductLines(prev => ({ ...prev, ...updates }));
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// If a request for this company is already in flight, skip duplicate fetch
|
||||
if (pendingCompanyRequests.current.has(companyId)) {
|
||||
console.log(`Skipping fetch for company ${companyId} - request already pending`);
|
||||
return;
|
||||
}
|
||||
pendingCompanyRequests.current.add(companyId);
|
||||
|
||||
// Set loading state for this row
|
||||
if (rowIndex !== undefined && rowIndex !== null) {
|
||||
setIsLoadingLines(prev => ({ ...prev, [rowIndex]: true }));
|
||||
}
|
||||
|
||||
// Fetch product lines from API
|
||||
const productLinesUrl = `/api/import/product-lines/${companyId}`;
|
||||
const response = await axios.get(productLinesUrl);
|
||||
|
||||
const lines = response.data;
|
||||
console.log(`Received ${lines.length} product lines for company ${companyId}`);
|
||||
|
||||
// Format the data properly for dropdown display
|
||||
const formattedLines = lines.map((line: any) => ({
|
||||
label: line.name || line.label || String(line.value || line.id),
|
||||
value: String(line.value || line.id)
|
||||
}));
|
||||
|
||||
// Store in company cache
|
||||
setCompanyLinesCache(prev => ({ ...prev, [companyId]: formattedLines }));
|
||||
|
||||
// Update specific row if provided
|
||||
if (rowIndex !== undefined && rowIndex !== null) {
|
||||
setRowProductLines(prev => ({ ...prev, [rowIndex]: formattedLines }));
|
||||
}
|
||||
|
||||
// Also update all rows that currently have this company set
|
||||
const updates: Record<string, any[]> = {};
|
||||
data.forEach((row, idx) => {
|
||||
if (row.company && String(row.company) === String(companyId)) {
|
||||
const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx;
|
||||
updates[key] = formattedLines;
|
||||
}
|
||||
});
|
||||
if (Object.keys(updates).length > 0) {
|
||||
setRowProductLines(prev => ({ ...prev, ...updates }));
|
||||
}
|
||||
|
||||
return formattedLines;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching product lines for company ${companyId}:`, error);
|
||||
toast.error(`Failed to load product lines for company ${companyId}`);
|
||||
|
||||
// Set empty array for this company to prevent repeated failed requests
|
||||
setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] }));
|
||||
|
||||
// Store empty array for this specific row
|
||||
if (rowIndex !== undefined && rowIndex !== null) {
|
||||
setRowProductLines(prev => ({ ...prev, [rowIndex]: [] }));
|
||||
}
|
||||
|
||||
return [];
|
||||
} finally {
|
||||
// Clear pending flag
|
||||
pendingCompanyRequests.current.delete(companyId);
|
||||
// Clear loading state
|
||||
if (rowIndex !== undefined && rowIndex !== null) {
|
||||
setIsLoadingLines(prev => ({ ...prev, [rowIndex]: false }));
|
||||
}
|
||||
}
|
||||
}, [companyLinesCache, data]);
|
||||
|
||||
// Function to fetch sublines for a specific line - memoized
|
||||
const fetchSublines = useCallback(async (rowIndex: string | number | undefined | null, lineId: string) => {
|
||||
try {
|
||||
// Only fetch if we have a valid line ID
|
||||
if (!lineId) return;
|
||||
|
||||
const logRowKey = (rowIndex !== undefined && rowIndex !== null) ? rowIndex : 'all-matching-rows';
|
||||
console.log(`Fetching sublines for row ${logRowKey}, line ${lineId}`);
|
||||
|
||||
// Check if we already have this line's sublines in the cache
|
||||
if (lineSublineCache[lineId]) {
|
||||
console.log(`Using cached sublines for line ${lineId}`);
|
||||
// Use cached data
|
||||
const cached = lineSublineCache[lineId];
|
||||
if (rowIndex !== undefined && rowIndex !== null) {
|
||||
setRowSublines(prev => ({ ...prev, [rowIndex]: cached }));
|
||||
}
|
||||
// Also update all rows with this line
|
||||
const updates: Record<string, any[]> = {};
|
||||
data.forEach((row, idx) => {
|
||||
if (row.line && String(row.line) === String(lineId)) {
|
||||
const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx;
|
||||
updates[key] = cached;
|
||||
}
|
||||
});
|
||||
if (Object.keys(updates).length > 0) {
|
||||
setRowSublines(prev => ({ ...prev, ...updates }));
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// If a request for this line is already in flight, skip duplicate fetch
|
||||
if (pendingLineRequests.current.has(lineId)) {
|
||||
console.log(`Skipping fetch for line ${lineId} - request already pending`);
|
||||
return;
|
||||
}
|
||||
pendingLineRequests.current.add(lineId);
|
||||
|
||||
// Set loading state for this row
|
||||
if (rowIndex !== undefined && rowIndex !== null) {
|
||||
setIsLoadingSublines(prev => ({ ...prev, [rowIndex]: true }));
|
||||
}
|
||||
|
||||
// Fetch sublines from API
|
||||
const sublinesUrl = `/api/import/sublines/${lineId}`;
|
||||
const response = await axios.get(sublinesUrl);
|
||||
|
||||
const sublines = response.data;
|
||||
console.log(`Received ${sublines.length} sublines for line ${lineId}`);
|
||||
|
||||
// Format the data properly for dropdown display
|
||||
const formattedSublines = sublines.map((subline: any) => ({
|
||||
label: subline.name || subline.label || String(subline.value || subline.id),
|
||||
value: String(subline.value || subline.id)
|
||||
}));
|
||||
|
||||
// Store in line cache
|
||||
setLineSublineCache(prev => ({ ...prev, [lineId]: formattedSublines }));
|
||||
|
||||
// Update specific row if provided
|
||||
if (rowIndex !== undefined && rowIndex !== null) {
|
||||
setRowSublines(prev => ({ ...prev, [rowIndex]: formattedSublines }));
|
||||
}
|
||||
|
||||
// Also update all rows with this line
|
||||
const updates: Record<string, any[]> = {};
|
||||
data.forEach((row, idx) => {
|
||||
if (row.line && String(row.line) === String(lineId)) {
|
||||
const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx;
|
||||
updates[key] = formattedSublines;
|
||||
}
|
||||
});
|
||||
if (Object.keys(updates).length > 0) {
|
||||
setRowSublines(prev => ({ ...prev, ...updates }));
|
||||
}
|
||||
|
||||
return formattedSublines;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching sublines for line ${lineId}:`, error);
|
||||
|
||||
// Set empty array for this line to prevent repeated failed requests
|
||||
setLineSublineCache(prev => ({ ...prev, [lineId]: [] }));
|
||||
|
||||
// Store empty array for this specific row
|
||||
if (rowIndex !== undefined && rowIndex !== null) {
|
||||
setRowSublines(prev => ({ ...prev, [rowIndex]: [] }));
|
||||
}
|
||||
|
||||
return [];
|
||||
} finally {
|
||||
// Clear pending flag
|
||||
pendingLineRequests.current.delete(lineId);
|
||||
// Clear loading state
|
||||
if (rowIndex !== undefined && rowIndex !== null) {
|
||||
setIsLoadingSublines(prev => ({ ...prev, [rowIndex]: false }));
|
||||
}
|
||||
}
|
||||
}, [lineSublineCache, data]);
|
||||
|
||||
// When data changes, fetch product lines and sublines for rows that have company/line values
|
||||
useEffect(() => {
|
||||
// Skip if there's no data
|
||||
if (!data.length) return;
|
||||
|
||||
// First check if we need to do anything at all
|
||||
let needsFetching = false;
|
||||
|
||||
// Quick check for any rows that would need fetching
|
||||
for (const row of data) {
|
||||
const rowId = row.__index;
|
||||
if (!rowId) continue;
|
||||
|
||||
if ((row.company && !rowProductLines[rowId]) || (row.line && !rowSublines[rowId])) {
|
||||
needsFetching = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing needs fetching, exit early
|
||||
if (!needsFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Starting to fetch product lines and sublines");
|
||||
|
||||
// Group rows by company and line to minimize API calls
|
||||
const companiesNeeded = new Map<string, string[]>(); // company ID -> row IDs
|
||||
const linesNeeded = new Map<string, string[]>(); // line ID -> row IDs
|
||||
|
||||
data.forEach(row => {
|
||||
const rowId = row.__index;
|
||||
if (!rowId) return; // Skip rows without an index
|
||||
|
||||
// If row has company but no product lines fetched yet
|
||||
if (row.company && !rowProductLines[rowId]) {
|
||||
const companyId = row.company.toString();
|
||||
if (!companiesNeeded.has(companyId)) {
|
||||
companiesNeeded.set(companyId, []);
|
||||
}
|
||||
companiesNeeded.get(companyId)?.push(rowId);
|
||||
}
|
||||
|
||||
// If row has line but no sublines fetched yet
|
||||
if (row.line && !rowSublines[rowId]) {
|
||||
const lineId = row.line.toString();
|
||||
if (!linesNeeded.has(lineId)) {
|
||||
linesNeeded.set(lineId, []);
|
||||
}
|
||||
linesNeeded.get(lineId)?.push(rowId);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Need to fetch product lines for ${companiesNeeded.size} companies and sublines for ${linesNeeded.size} lines`);
|
||||
|
||||
// If nothing to fetch, exit early to prevent unnecessary processing
|
||||
if (companiesNeeded.size === 0 && linesNeeded.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create arrays to hold all fetch promises
|
||||
const fetchPromises: Promise<void>[] = [];
|
||||
|
||||
// Set initial loading states for all affected rows
|
||||
const lineLoadingUpdates: Record<string, boolean> = {};
|
||||
const sublineLoadingUpdates: Record<string, boolean> = {};
|
||||
|
||||
// Process companies that need product lines
|
||||
companiesNeeded.forEach((rowIds, companyId) => {
|
||||
// If this company is already being fetched, skip creating another request
|
||||
if (pendingCompanyRequests.current.has(companyId)) {
|
||||
console.log(`Skipping batch fetch for company ${companyId} - request already pending`);
|
||||
return;
|
||||
}
|
||||
// Skip if already in cache
|
||||
if (companyLinesCache[companyId]) {
|
||||
console.log(`Using cached product lines for company ${companyId}`);
|
||||
// Use cached data for all rows with this company
|
||||
const lines = companyLinesCache[companyId];
|
||||
const updates: Record<string, any[]> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
updates[rowId] = lines;
|
||||
});
|
||||
setRowProductLines(prev => ({ ...prev, ...updates }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading state for all affected rows
|
||||
rowIds.forEach(rowId => {
|
||||
lineLoadingUpdates[rowId] = true;
|
||||
});
|
||||
|
||||
// Create fetch promise
|
||||
const fetchPromise = (async () => {
|
||||
// Mark this company as pending
|
||||
pendingCompanyRequests.current.add(companyId);
|
||||
// Safety timeout to ensure loading state is cleared after 10 seconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.log(`Safety timeout triggered for company ${companyId}`);
|
||||
const clearLoadingUpdates: Record<string, boolean> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
clearLoadingUpdates[rowId] = false;
|
||||
});
|
||||
setIsLoadingLines(prev => ({ ...prev, ...clearLoadingUpdates }));
|
||||
|
||||
// Set empty cache to prevent repeated requests
|
||||
setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] }));
|
||||
|
||||
// Update rows with empty array
|
||||
const updates: Record<string, any[]> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
updates[rowId] = [];
|
||||
});
|
||||
setRowProductLines(prev => ({ ...prev, ...updates }));
|
||||
|
||||
toast.error(`Timeout loading product lines for company ${companyId}`);
|
||||
}, 10000);
|
||||
|
||||
try {
|
||||
console.log(`Fetching product lines for company ${companyId} (affecting ${rowIds.length} rows)`);
|
||||
|
||||
// Fetch product lines from API
|
||||
const productLinesUrl = `/api/import/product-lines/${companyId}`;
|
||||
console.log(`Fetching from URL: ${productLinesUrl}`);
|
||||
|
||||
const response = await axios.get(productLinesUrl);
|
||||
console.log(`Product lines API response status for company ${companyId}:`, response.status);
|
||||
|
||||
const productLines = response.data;
|
||||
console.log(`Received ${productLines.length} product lines for company ${companyId}`);
|
||||
|
||||
// Format the data for dropdown display (consistent with single-row fetch)
|
||||
const formattedLines = productLines.map((line: any) => ({
|
||||
label: line.name || line.label || String(line.value || line.id),
|
||||
value: String(line.value || line.id)
|
||||
}));
|
||||
|
||||
// Store in company cache
|
||||
setCompanyLinesCache(prev => ({ ...prev, [companyId]: formattedLines }));
|
||||
|
||||
// Update all rows with this company
|
||||
const updates: Record<string, any[]> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
updates[rowId] = formattedLines;
|
||||
});
|
||||
setRowProductLines(prev => ({ ...prev, ...updates }));
|
||||
} catch (error) {
|
||||
console.error(`Error fetching product lines for company ${companyId}:`, error);
|
||||
|
||||
// Set empty array for this company to prevent repeated failed requests
|
||||
setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] }));
|
||||
|
||||
// Update rows with empty array
|
||||
const updates: Record<string, any[]> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
updates[rowId] = [];
|
||||
});
|
||||
setRowProductLines(prev => ({ ...prev, ...updates }));
|
||||
|
||||
// Show error toast
|
||||
toast.error(`Failed to load product lines for company ${companyId}`);
|
||||
} finally {
|
||||
// Clear pending flag for company
|
||||
pendingCompanyRequests.current.delete(companyId);
|
||||
// Clear the safety timeout
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Clear loading state for all affected rows
|
||||
const clearLoadingUpdates: Record<string, boolean> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
clearLoadingUpdates[rowId] = false;
|
||||
});
|
||||
setIsLoadingLines(prev => ({ ...prev, ...clearLoadingUpdates }));
|
||||
}
|
||||
})();
|
||||
|
||||
fetchPromises.push(fetchPromise);
|
||||
});
|
||||
|
||||
// Process lines that need sublines
|
||||
linesNeeded.forEach((rowIds, lineId) => {
|
||||
// If this line is already being fetched, skip creating another request
|
||||
if (pendingLineRequests.current.has(lineId)) {
|
||||
console.log(`Skipping batch fetch for line ${lineId} - request already pending`);
|
||||
return;
|
||||
}
|
||||
// Skip if already in cache
|
||||
if (lineSublineCache[lineId]) {
|
||||
console.log(`Using cached sublines for line ${lineId}`);
|
||||
// Use cached data for all rows with this line
|
||||
const sublines = lineSublineCache[lineId];
|
||||
const updates: Record<string, any[]> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
updates[rowId] = sublines;
|
||||
});
|
||||
setRowSublines(prev => ({ ...prev, ...updates }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading state for all affected rows
|
||||
rowIds.forEach(rowId => {
|
||||
sublineLoadingUpdates[rowId] = true;
|
||||
});
|
||||
|
||||
// Create fetch promise
|
||||
const fetchPromise = (async () => {
|
||||
// Mark this line as pending
|
||||
pendingLineRequests.current.add(lineId);
|
||||
// Safety timeout to ensure loading state is cleared after 10 seconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.log(`Safety timeout triggered for line ${lineId}`);
|
||||
const clearLoadingUpdates: Record<string, boolean> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
clearLoadingUpdates[rowId] = false;
|
||||
});
|
||||
setIsLoadingSublines(prev => ({ ...prev, ...clearLoadingUpdates }));
|
||||
|
||||
// Set empty cache to prevent repeated requests
|
||||
setLineSublineCache(prev => ({ ...prev, [lineId]: [] }));
|
||||
|
||||
// Update rows with empty array
|
||||
const updates: Record<string, any[]> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
updates[rowId] = [];
|
||||
});
|
||||
setRowSublines(prev => ({ ...prev, ...updates }));
|
||||
|
||||
toast.error(`Timeout loading sublines for line ${lineId}`);
|
||||
}, 10000);
|
||||
|
||||
try {
|
||||
console.log(`Fetching sublines for line ${lineId} (affecting ${rowIds.length} rows)`);
|
||||
|
||||
// Fetch sublines from API
|
||||
const sublinesUrl = `/api/import/sublines/${lineId}`;
|
||||
console.log(`Fetching from URL: ${sublinesUrl}`);
|
||||
|
||||
const response = await axios.get(sublinesUrl);
|
||||
console.log(`Sublines API response status for line ${lineId}:`, response.status);
|
||||
|
||||
const sublines = response.data;
|
||||
console.log(`Received ${sublines.length} sublines for line ${lineId}`);
|
||||
|
||||
// Format the data for dropdown display (consistent with single-row fetch)
|
||||
const formattedSublines = sublines.map((subline: any) => ({
|
||||
label: subline.name || subline.label || String(subline.value || subline.id),
|
||||
value: String(subline.value || subline.id)
|
||||
}));
|
||||
|
||||
// Store in line cache
|
||||
setLineSublineCache(prev => ({ ...prev, [lineId]: formattedSublines }));
|
||||
|
||||
// Update all rows with this line
|
||||
const updates: Record<string, any[]> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
updates[rowId] = formattedSublines;
|
||||
});
|
||||
setRowSublines(prev => ({ ...prev, ...updates }));
|
||||
} catch (error) {
|
||||
console.error(`Error fetching sublines for line ${lineId}:`, error);
|
||||
|
||||
// Set empty array for this line to prevent repeated failed requests
|
||||
setLineSublineCache(prev => ({ ...prev, [lineId]: [] }));
|
||||
|
||||
// Update rows with empty array
|
||||
const updates: Record<string, any[]> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
updates[rowId] = [];
|
||||
});
|
||||
setRowSublines(prev => ({ ...prev, ...updates }));
|
||||
|
||||
// Show error toast
|
||||
toast.error(`Failed to load sublines for line ${lineId}`);
|
||||
} finally {
|
||||
// Clear pending flag for line
|
||||
pendingLineRequests.current.delete(lineId);
|
||||
// Clear the safety timeout
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Clear loading state for all affected rows
|
||||
const clearLoadingUpdates: Record<string, boolean> = {};
|
||||
rowIds.forEach(rowId => {
|
||||
clearLoadingUpdates[rowId] = false;
|
||||
});
|
||||
setIsLoadingSublines(prev => ({ ...prev, ...clearLoadingUpdates }));
|
||||
}
|
||||
})();
|
||||
|
||||
fetchPromises.push(fetchPromise);
|
||||
});
|
||||
|
||||
// Set initial loading states
|
||||
if (Object.keys(lineLoadingUpdates).length > 0) {
|
||||
console.log(`Setting loading state for ${Object.keys(lineLoadingUpdates).length} rows (product lines)`);
|
||||
setIsLoadingLines(prev => ({ ...prev, ...lineLoadingUpdates }));
|
||||
}
|
||||
if (Object.keys(sublineLoadingUpdates).length > 0) {
|
||||
console.log(`Setting loading state for ${Object.keys(sublineLoadingUpdates).length} rows (sublines)`);
|
||||
setIsLoadingSublines(prev => ({ ...prev, ...sublineLoadingUpdates }));
|
||||
}
|
||||
|
||||
// Run all fetch operations in parallel
|
||||
Promise.all(fetchPromises).then(() => {
|
||||
console.log("All product lines and sublines fetch operations completed");
|
||||
}).catch(error => {
|
||||
console.error('Error in fetch operations:', error);
|
||||
});
|
||||
|
||||
}, [data, rowProductLines, rowSublines, companyLinesCache, lineSublineCache]);
|
||||
|
||||
return {
|
||||
rowProductLines,
|
||||
rowSublines,
|
||||
isLoadingLines,
|
||||
isLoadingSublines,
|
||||
fetchProductLines,
|
||||
fetchSublines
|
||||
};
|
||||
};
|
||||
-500
@@ -1,500 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { RowData } from './validationTypes';
|
||||
import type { Field, Fields } from '../../../types';
|
||||
import { ErrorSources, ErrorType, ValidationError } from '../../../types';
|
||||
import { useUniqueValidation } from './useUniqueValidation';
|
||||
import { isEmpty } from './validationTypes';
|
||||
|
||||
export const useRowOperations = <T extends string>(
|
||||
data: RowData<T>[],
|
||||
fields: Fields<T>,
|
||||
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
|
||||
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
|
||||
validateFieldFromHook: (value: any, field: Field<T>) => ValidationError[]
|
||||
) => {
|
||||
// Uniqueness validation utilities
|
||||
const { validateUniqueField } = useUniqueValidation<T>(fields);
|
||||
|
||||
// Determine which field keys are considered uniqueness-constrained
|
||||
const uniquenessFieldKeys = useMemo(() => {
|
||||
const keys = new Set<string>([
|
||||
'item_number',
|
||||
'upc',
|
||||
'barcode',
|
||||
'supplier_no',
|
||||
'notions_no',
|
||||
'name'
|
||||
]);
|
||||
fields.forEach((f) => {
|
||||
if (f.validations?.some((v) => v.rule === 'unique')) {
|
||||
keys.add(String(f.key));
|
||||
}
|
||||
});
|
||||
return keys;
|
||||
}, [fields]);
|
||||
|
||||
// Merge per-field uniqueness errors into the validation error map
|
||||
const mergeUniqueErrorsForFields = useCallback(
|
||||
(
|
||||
baseErrors: Map<number, Record<string, ValidationError[]>>,
|
||||
dataForCalc: RowData<T>[],
|
||||
fieldKeysToCheck: string[]
|
||||
) => {
|
||||
if (!fieldKeysToCheck.length) return baseErrors;
|
||||
|
||||
const newErrors = new Map(baseErrors);
|
||||
|
||||
// For each field, compute duplicates and merge
|
||||
fieldKeysToCheck.forEach((fieldKey) => {
|
||||
if (!uniquenessFieldKeys.has(fieldKey)) return;
|
||||
|
||||
// Compute unique errors for this single field
|
||||
const uniqueMap = validateUniqueField(dataForCalc, fieldKey);
|
||||
|
||||
// Rows that currently have uniqueness errors for this field
|
||||
const rowsWithUniqueErrors = new Set<number>();
|
||||
uniqueMap.forEach((_, rowIdx) => rowsWithUniqueErrors.add(rowIdx));
|
||||
|
||||
// First, apply/overwrite unique errors for rows that have duplicates
|
||||
uniqueMap.forEach((errorsForRow, rowIdx) => {
|
||||
const existing = { ...(newErrors.get(rowIdx) || {}) };
|
||||
|
||||
// Convert InfoWithSource to ValidationError[] for this field
|
||||
const info = errorsForRow[fieldKey];
|
||||
// Only apply uniqueness error when the value is non-empty
|
||||
const currentValue = (dataForCalc[rowIdx] as any)?.[fieldKey];
|
||||
if (info && !isEmpty(currentValue)) {
|
||||
existing[fieldKey] = [
|
||||
{
|
||||
message: info.message,
|
||||
level: info.level,
|
||||
source: info.source ?? ErrorSources.Table,
|
||||
type: info.type ?? ErrorType.Unique
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (Object.keys(existing).length > 0) newErrors.set(rowIdx, existing);
|
||||
else newErrors.delete(rowIdx);
|
||||
});
|
||||
|
||||
// Then, remove any stale unique errors for this field where duplicates are resolved
|
||||
newErrors.forEach((rowErrs, rowIdx) => {
|
||||
// Skip rows that still have unique errors for this field
|
||||
if (rowsWithUniqueErrors.has(rowIdx)) return;
|
||||
|
||||
if ((rowErrs as any)[fieldKey]) {
|
||||
const filtered = (rowErrs as any)[fieldKey].filter((e: ValidationError) => e.type !== ErrorType.Unique);
|
||||
if (filtered.length > 0) (rowErrs as any)[fieldKey] = filtered;
|
||||
else delete (rowErrs as any)[fieldKey];
|
||||
|
||||
if (Object.keys(rowErrs).length > 0) newErrors.set(rowIdx, rowErrs);
|
||||
else newErrors.delete(rowIdx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return newErrors;
|
||||
},
|
||||
[uniquenessFieldKeys, validateUniqueField]
|
||||
);
|
||||
|
||||
// Helper function to validate a field value
|
||||
const fieldValidationHelper = useCallback(
|
||||
(rowIndex: number, specificField?: string) => {
|
||||
// Skip validation if row doesn't exist
|
||||
if (rowIndex < 0 || rowIndex >= data.length) return;
|
||||
|
||||
// Get the row data
|
||||
const row = data[rowIndex];
|
||||
|
||||
// If validating a specific field, only check that field
|
||||
if (specificField) {
|
||||
const field = fields.find((f) => String(f.key) === specificField);
|
||||
if (field) {
|
||||
const value = row[specificField as keyof typeof row];
|
||||
|
||||
// Use state setter instead of direct mutation
|
||||
setValidationErrors((prev) => {
|
||||
let newErrors = new Map(prev);
|
||||
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
|
||||
|
||||
// Quick check for required fields - this prevents flashing errors
|
||||
const isRequired = field.validations?.some(
|
||||
(v) => v.rule === "required"
|
||||
);
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === "" ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === "object" &&
|
||||
value !== null &&
|
||||
Object.keys(value).length === 0);
|
||||
|
||||
// For non-empty values, remove required errors immediately
|
||||
if (isRequired && !isEmpty && existingErrors[specificField]) {
|
||||
const nonRequiredErrors = existingErrors[specificField].filter(
|
||||
(e) => e.type !== ErrorType.Required
|
||||
);
|
||||
if (nonRequiredErrors.length === 0) {
|
||||
// If no other errors, remove the field entirely from errors
|
||||
delete existingErrors[specificField];
|
||||
} else {
|
||||
existingErrors[specificField] = nonRequiredErrors;
|
||||
}
|
||||
}
|
||||
|
||||
// Run full validation for the field
|
||||
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||
|
||||
// Update validation errors for this field
|
||||
if (errors.length > 0) {
|
||||
existingErrors[specificField] = errors;
|
||||
} else {
|
||||
delete existingErrors[specificField];
|
||||
}
|
||||
|
||||
// Update validation errors map
|
||||
if (Object.keys(existingErrors).length > 0) {
|
||||
newErrors.set(rowIndex, existingErrors);
|
||||
} else {
|
||||
newErrors.delete(rowIndex);
|
||||
}
|
||||
|
||||
// If field is uniqueness-constrained, also re-validate uniqueness for the column
|
||||
if (uniquenessFieldKeys.has(specificField)) {
|
||||
const dataForCalc = data; // latest data
|
||||
newErrors = mergeUniqueErrorsForFields(newErrors, dataForCalc, [specificField]);
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Validate all fields in the row
|
||||
setValidationErrors((prev) => {
|
||||
const newErrors = new Map(prev);
|
||||
const rowErrors: Record<string, ValidationError[]> = {};
|
||||
|
||||
fields.forEach((field) => {
|
||||
const fieldKey = String(field.key);
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||
|
||||
if (errors.length > 0) {
|
||||
rowErrors[fieldKey] = errors;
|
||||
}
|
||||
});
|
||||
|
||||
// Update validation errors map
|
||||
if (Object.keys(rowErrors).length > 0) {
|
||||
newErrors.set(rowIndex, rowErrors);
|
||||
} else {
|
||||
newErrors.delete(rowIndex);
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
},
|
||||
[data, fields, validateFieldFromHook, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
|
||||
);
|
||||
|
||||
// Use validateRow as an alias for fieldValidationHelper for compatibility
|
||||
const validateRow = fieldValidationHelper;
|
||||
|
||||
// Modified updateRow function that properly handles field-specific validation
|
||||
const updateRow = useCallback(
|
||||
(rowIndex: number, key: T, value: any) => {
|
||||
// Process value before updating data
|
||||
let processedValue = value;
|
||||
|
||||
// Strip dollar signs from price fields
|
||||
if (
|
||||
(key === "msrp" || key === "cost_each") &&
|
||||
typeof value === "string"
|
||||
) {
|
||||
processedValue = value.replace(/[$,]/g, "");
|
||||
|
||||
// Also ensure it's a valid number
|
||||
const numValue = parseFloat(processedValue);
|
||||
if (!isNaN(numValue)) {
|
||||
processedValue = numValue.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the row data first
|
||||
const rowData = data[rowIndex];
|
||||
if (!rowData) {
|
||||
console.error(`No row data found for index ${rowIndex}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a copy of the row to avoid mutation
|
||||
const updatedRow = { ...rowData, [key]: processedValue };
|
||||
|
||||
// Update the data immediately - this sets the value
|
||||
setData((prevData) => {
|
||||
const newData = [...prevData];
|
||||
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||
newData[rowIndex] = updatedRow;
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Find the field definition
|
||||
const field = fields.find((f) => String(f.key) === key);
|
||||
if (!field) return;
|
||||
|
||||
// CRITICAL FIX: Combine both validation operations into a single state update
|
||||
// to prevent intermediate rendering that causes error icon flashing
|
||||
setValidationErrors((prev) => {
|
||||
// Start with previous errors
|
||||
let newMap = new Map(prev);
|
||||
const existingErrors = newMap.get(rowIndex) || {};
|
||||
const newRowErrors = { ...existingErrors };
|
||||
|
||||
// Check for required field first
|
||||
const isRequired = field.validations?.some(
|
||||
(v) => v.rule === "required"
|
||||
);
|
||||
const isEmpty =
|
||||
processedValue === undefined ||
|
||||
processedValue === null ||
|
||||
processedValue === "" ||
|
||||
(Array.isArray(processedValue) && processedValue.length === 0) ||
|
||||
(typeof processedValue === "object" &&
|
||||
processedValue !== null &&
|
||||
Object.keys(processedValue).length === 0);
|
||||
|
||||
// For required fields with values, remove required errors
|
||||
if (isRequired && !isEmpty && newRowErrors[key as string]) {
|
||||
const hasRequiredError = newRowErrors[key as string].some(
|
||||
(e) => e.type === ErrorType.Required
|
||||
);
|
||||
|
||||
if (hasRequiredError) {
|
||||
// Remove required errors but keep other types of errors
|
||||
const nonRequiredErrors = newRowErrors[key as string].filter(
|
||||
(e) => e.type !== ErrorType.Required
|
||||
);
|
||||
|
||||
if (nonRequiredErrors.length === 0) {
|
||||
// If no other errors, delete the field's errors entirely
|
||||
delete newRowErrors[key as string];
|
||||
} else {
|
||||
// Otherwise keep non-required errors
|
||||
newRowErrors[key as string] = nonRequiredErrors;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now run full validation for the field (except for required which we already handled)
|
||||
const errors = validateFieldFromHook(
|
||||
processedValue,
|
||||
field as unknown as Field<T>
|
||||
).filter((e) => e.type !== ErrorType.Required || isEmpty);
|
||||
|
||||
// Update with new validation results
|
||||
if (errors.length > 0) {
|
||||
newRowErrors[key as string] = errors;
|
||||
} else {
|
||||
// Clear any existing errors for this field
|
||||
delete newRowErrors[key as string];
|
||||
}
|
||||
|
||||
// Update the map
|
||||
if (Object.keys(newRowErrors).length > 0) {
|
||||
newMap.set(rowIndex, newRowErrors);
|
||||
} else {
|
||||
newMap.delete(rowIndex);
|
||||
}
|
||||
|
||||
// If uniqueness applies, validate affected columns
|
||||
const fieldsToCheck: string[] = [];
|
||||
if (uniquenessFieldKeys.has(String(key))) fieldsToCheck.push(String(key));
|
||||
if (key === ('upc' as T) || key === ('barcode' as T) || key === ('supplier' as T)) {
|
||||
if (uniquenessFieldKeys.has('item_number')) fieldsToCheck.push('item_number');
|
||||
}
|
||||
|
||||
if (fieldsToCheck.length > 0) {
|
||||
const dataForCalc = (() => {
|
||||
const copy = [...data];
|
||||
if (rowIndex >= 0 && rowIndex < copy.length) {
|
||||
copy[rowIndex] = { ...(copy[rowIndex] || {}), [key]: processedValue } as RowData<T>;
|
||||
}
|
||||
return copy;
|
||||
})();
|
||||
newMap = mergeUniqueErrorsForFields(newMap, dataForCalc, fieldsToCheck);
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
|
||||
// Handle simple secondary effects here
|
||||
setTimeout(() => {
|
||||
// Use __index to find the actual row in the full data array
|
||||
const rowId = rowData.__index;
|
||||
|
||||
// Handle company change - clear line/subline
|
||||
if (key === "company" && processedValue) {
|
||||
// Clear any existing line/subline values
|
||||
setData((prevData) => {
|
||||
const newData = [...prevData];
|
||||
const idx = newData.findIndex((item) => item.__index === rowId);
|
||||
if (idx >= 0) {
|
||||
newData[idx] = {
|
||||
...newData[idx],
|
||||
line: undefined,
|
||||
subline: undefined,
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
|
||||
// Handle line change - clear subline
|
||||
if (key === "line" && processedValue) {
|
||||
// Clear any existing subline value
|
||||
setData((prevData) => {
|
||||
const newData = [...prevData];
|
||||
const idx = newData.findIndex((item) => item.__index === rowId);
|
||||
if (idx >= 0) {
|
||||
newData[idx] = {
|
||||
...newData[idx],
|
||||
subline: undefined,
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
}, 5); // Reduced delay for faster secondary effects
|
||||
},
|
||||
[data, fields, validateFieldFromHook, setData, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
|
||||
);
|
||||
|
||||
// Improved revalidateRows function
|
||||
const revalidateRows = useCallback(
|
||||
async (
|
||||
rowIndexes: number[],
|
||||
updatedFields?: { [rowIndex: number]: string[] },
|
||||
dataOverride?: RowData<T>[]
|
||||
) => {
|
||||
// Process all specified rows using a single state update to avoid race conditions
|
||||
setValidationErrors((prev) => {
|
||||
let newErrors = new Map(prev);
|
||||
const workingData = dataOverride ?? data;
|
||||
|
||||
// Track which uniqueness fields need to be revalidated across the dataset
|
||||
const uniqueFieldsToCheck = new Set<string>();
|
||||
|
||||
// Process each row
|
||||
for (const rowIndex of rowIndexes) {
|
||||
if (rowIndex < 0 || rowIndex >= workingData.length) continue;
|
||||
|
||||
const row = workingData[rowIndex];
|
||||
if (!row) continue;
|
||||
|
||||
// If we have specific fields to update for this row
|
||||
const fieldsToValidate = updatedFields?.[rowIndex] || [];
|
||||
|
||||
if (fieldsToValidate.length > 0) {
|
||||
// Get existing errors for this row
|
||||
const existingRowErrors = { ...(newErrors.get(rowIndex) || {}) };
|
||||
|
||||
// Validate each specified field
|
||||
for (const fieldKey of fieldsToValidate) {
|
||||
const field = fields.find((f) => String(f.key) === fieldKey);
|
||||
if (!field) continue;
|
||||
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
|
||||
// Run validation for this field
|
||||
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||
|
||||
// Update errors for this field
|
||||
if (errors.length > 0) {
|
||||
existingRowErrors[fieldKey] = errors;
|
||||
} else {
|
||||
delete existingRowErrors[fieldKey];
|
||||
}
|
||||
|
||||
// If field is uniqueness-constrained, mark for uniqueness pass
|
||||
if (uniquenessFieldKeys.has(fieldKey)) {
|
||||
uniqueFieldsToCheck.add(fieldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the row's errors
|
||||
if (Object.keys(existingRowErrors).length > 0) {
|
||||
newErrors.set(rowIndex, existingRowErrors);
|
||||
} else {
|
||||
newErrors.delete(rowIndex);
|
||||
}
|
||||
} else {
|
||||
// No specific fields provided - validate the entire row
|
||||
const rowErrors: Record<string, ValidationError[]> = {};
|
||||
|
||||
// Validate all fields in the row
|
||||
for (const field of fields) {
|
||||
const fieldKey = String(field.key);
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
|
||||
// Run validation for this field
|
||||
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||
|
||||
// Update errors for this field
|
||||
if (errors.length > 0) {
|
||||
rowErrors[fieldKey] = errors;
|
||||
}
|
||||
|
||||
// If field is uniqueness-constrained and we validated it, include for uniqueness pass
|
||||
if (uniquenessFieldKeys.has(fieldKey)) {
|
||||
uniqueFieldsToCheck.add(fieldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the row's errors
|
||||
if (Object.keys(rowErrors).length > 0) {
|
||||
newErrors.set(rowIndex, rowErrors);
|
||||
} else {
|
||||
newErrors.delete(rowIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run per-field uniqueness checks and merge results
|
||||
if (uniqueFieldsToCheck.size > 0) {
|
||||
newErrors = mergeUniqueErrorsForFields(newErrors, workingData, Array.from(uniqueFieldsToCheck));
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
});
|
||||
},
|
||||
[data, fields, validateFieldFromHook, mergeUniqueErrorsForFields, uniquenessFieldKeys]
|
||||
);
|
||||
|
||||
// Copy a cell value to all cells below it in the same column
|
||||
const copyDown = useCallback(
|
||||
(rowIndex: number, key: T) => {
|
||||
// Get the source value to copy
|
||||
const sourceValue = data[rowIndex][key];
|
||||
|
||||
// Update all rows below with the same value using the existing updateRow function
|
||||
// This ensures all validation logic runs consistently
|
||||
for (let i = rowIndex + 1; i < data.length; i++) {
|
||||
// Just use updateRow which will handle validation with proper timing
|
||||
updateRow(i, key, sourceValue);
|
||||
}
|
||||
},
|
||||
[data, updateRow]
|
||||
);
|
||||
|
||||
return {
|
||||
validateRow,
|
||||
updateRow,
|
||||
revalidateRows,
|
||||
copyDown
|
||||
};
|
||||
};
|
||||
-516
@@ -1,516 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Template, RowData, TemplateState, getApiUrl } from './validationTypes';
|
||||
import { RowSelectionState } from '@tanstack/react-table';
|
||||
import { ValidationError } from '../../../types';
|
||||
|
||||
export const useTemplateManagement = <T extends string>(
|
||||
data: RowData<T>[],
|
||||
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
|
||||
rowSelection: RowSelectionState,
|
||||
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
|
||||
setRowValidationStatus: React.Dispatch<React.SetStateAction<Map<number, "pending" | "validating" | "validated" | "error">>>,
|
||||
validateRow: (rowIndex: number, specificField?: string) => void,
|
||||
isApplyingTemplateRef: React.MutableRefObject<boolean>,
|
||||
upcValidation: {
|
||||
validateUpc: (rowIndex: number, supplierId: string, upcValue: string) => Promise<{success: boolean, itemNumber?: string}>,
|
||||
applyItemNumbersToData: (onApplied?: (updatedRowIds: number[]) => void) => void
|
||||
},
|
||||
setValidatingCells?: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
) => {
|
||||
// Template state
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
|
||||
const [templateState, setTemplateState] = useState<TemplateState>({
|
||||
selectedTemplateId: null,
|
||||
showSaveTemplateDialog: false,
|
||||
newTemplateName: "",
|
||||
newTemplateType: "",
|
||||
});
|
||||
|
||||
// Load templates
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
setIsLoadingTemplates(true);
|
||||
console.log("Fetching templates from:", `${getApiUrl()}/templates`);
|
||||
const response = await fetch(`${getApiUrl()}/templates`);
|
||||
if (!response.ok) throw new Error("Failed to fetch templates");
|
||||
const templateData = await response.json();
|
||||
const validTemplates = templateData.filter(
|
||||
(t: any) =>
|
||||
t && typeof t === "object" && t.id && t.company && t.product_type
|
||||
);
|
||||
setTemplates(validTemplates);
|
||||
} catch (error) {
|
||||
console.error("Error fetching templates:", error);
|
||||
toast.error("Failed to load templates");
|
||||
} finally {
|
||||
setIsLoadingTemplates(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Refresh templates
|
||||
const refreshTemplates = useCallback(() => {
|
||||
loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
// Save a new template
|
||||
const saveTemplate = useCallback(
|
||||
async (name: string, type: string) => {
|
||||
try {
|
||||
// Get selected rows
|
||||
const selectedRowIndex = Number(Object.keys(rowSelection)[0]);
|
||||
const selectedRow = data[selectedRowIndex];
|
||||
|
||||
if (!selectedRow) {
|
||||
toast.error("Please select a row to create a template");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract data for template, removing metadata fields
|
||||
const {
|
||||
__index,
|
||||
__template,
|
||||
__original,
|
||||
__corrected,
|
||||
__changes,
|
||||
...templateData
|
||||
} = selectedRow as any;
|
||||
|
||||
// Clean numeric values (remove $ from price fields)
|
||||
const cleanedData: Record<string, any> = {};
|
||||
|
||||
// Process each key-value pair
|
||||
Object.entries(templateData).forEach(([key, value]) => {
|
||||
// Handle numeric values with dollar signs
|
||||
if (typeof value === "string" && value.includes("$")) {
|
||||
cleanedData[key] = value.replace(/[$,\s]/g, "").trim();
|
||||
}
|
||||
// Handle array values (like categories or ship_restrictions)
|
||||
else if (Array.isArray(value)) {
|
||||
cleanedData[key] = value;
|
||||
}
|
||||
// Handle other values
|
||||
else {
|
||||
cleanedData[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Send the template to the API
|
||||
const response = await fetch(`${getApiUrl()}/templates`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...cleanedData,
|
||||
company: name,
|
||||
product_type: type,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.error || errorData.details || "Failed to save template"
|
||||
);
|
||||
}
|
||||
|
||||
// Get the new template from the response
|
||||
const newTemplate = await response.json();
|
||||
|
||||
// Update the templates list with the new template
|
||||
setTemplates((prev) => [...prev, newTemplate]);
|
||||
|
||||
// Update the row to show it's using this template
|
||||
setData((prev) => {
|
||||
const newData = [...prev];
|
||||
if (newData[selectedRowIndex]) {
|
||||
newData[selectedRowIndex] = {
|
||||
...newData[selectedRowIndex],
|
||||
__template: newTemplate.id.toString(),
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
toast.success(`Template "${name}" saved successfully`);
|
||||
|
||||
// Reset dialog state
|
||||
setTemplateState((prev) => ({
|
||||
...prev,
|
||||
showSaveTemplateDialog: false,
|
||||
newTemplateName: "",
|
||||
newTemplateType: "",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error saving template:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save template"
|
||||
);
|
||||
}
|
||||
},
|
||||
[data, rowSelection, setData]
|
||||
);
|
||||
|
||||
// Apply template to rows - optimized version
|
||||
const applyTemplate = useCallback(
|
||||
(templateId: string, rowIndexes: number[]) => {
|
||||
const template = templates.find((t) => t.id.toString() === templateId);
|
||||
|
||||
if (!template) {
|
||||
toast.error("Template not found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Applying template ${templateId} to rows:`, rowIndexes);
|
||||
|
||||
// Validate row indexes
|
||||
const validRowIndexes = rowIndexes.filter(
|
||||
(index) => index >= 0 && index < data.length && Number.isInteger(index)
|
||||
);
|
||||
|
||||
if (validRowIndexes.length === 0) {
|
||||
toast.error("No valid rows to update");
|
||||
console.error("Invalid row indexes:", rowIndexes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the template application flag
|
||||
isApplyingTemplateRef.current = true;
|
||||
|
||||
// Save scroll position
|
||||
const scrollPosition = {
|
||||
left: window.scrollX,
|
||||
top: window.scrollY,
|
||||
};
|
||||
|
||||
// Create a copy of data and process all rows at once to minimize state updates
|
||||
const newData = [...data];
|
||||
const batchErrors = new Map<number, Record<string, ValidationError[]>>();
|
||||
const batchStatuses = new Map<
|
||||
number,
|
||||
"pending" | "validating" | "validated" | "error"
|
||||
>();
|
||||
|
||||
// Extract template fields once outside the loop
|
||||
const templateFields = Object.entries(template).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"id",
|
||||
"__meta",
|
||||
"__template",
|
||||
"__original",
|
||||
"__corrected",
|
||||
"__changes",
|
||||
].includes(key)
|
||||
);
|
||||
|
||||
// Apply template to each valid row
|
||||
validRowIndexes.forEach((index) => {
|
||||
// Create a new row with template values
|
||||
const originalRow = newData[index];
|
||||
const updatedRow = { ...originalRow } as Record<string, any>;
|
||||
|
||||
// Apply template fields (excluding metadata fields)
|
||||
for (const [key, value] of templateFields) {
|
||||
updatedRow[key] = value;
|
||||
}
|
||||
|
||||
// Mark the row as using this template
|
||||
updatedRow.__template = templateId;
|
||||
|
||||
// Update the row in the data array
|
||||
newData[index] = updatedRow as RowData<T>;
|
||||
|
||||
// Clear validation errors and mark as validated
|
||||
batchErrors.set(index, {});
|
||||
batchStatuses.set(index, "validated");
|
||||
});
|
||||
|
||||
// Check which rows need UPC validation
|
||||
const upcValidationRows = validRowIndexes.filter((rowIndex) => {
|
||||
const row = newData[rowIndex];
|
||||
return row && row.upc && row.supplier;
|
||||
});
|
||||
|
||||
// Perform a single update for all rows
|
||||
setData(newData);
|
||||
|
||||
// Update all validation errors and statuses at once
|
||||
setValidationErrors((prev) => {
|
||||
const newErrors = new Map(prev);
|
||||
for (const [rowIndex, errors] of batchErrors.entries()) {
|
||||
newErrors.set(rowIndex, errors);
|
||||
}
|
||||
return newErrors;
|
||||
});
|
||||
|
||||
setRowValidationStatus((prev) => {
|
||||
const newStatus = new Map(prev);
|
||||
for (const [rowIndex, status] of batchStatuses.entries()) {
|
||||
newStatus.set(rowIndex, status);
|
||||
}
|
||||
return newStatus;
|
||||
});
|
||||
|
||||
// Restore scroll position
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(scrollPosition.left, scrollPosition.top);
|
||||
});
|
||||
|
||||
// Show success toast
|
||||
if (validRowIndexes.length === 1) {
|
||||
toast.success("Template applied");
|
||||
} else if (validRowIndexes.length > 1) {
|
||||
toast.success(`Template applied to ${validRowIndexes.length} rows`);
|
||||
}
|
||||
|
||||
// Reset template application flag to allow validation
|
||||
isApplyingTemplateRef.current = false;
|
||||
|
||||
// If there are rows with both UPC and supplier, validate them
|
||||
if (upcValidationRows.length > 0) {
|
||||
console.log(`Validating UPCs for ${upcValidationRows.length} rows after template application`);
|
||||
|
||||
// Process each row sequentially - this mimics the exact manual edit behavior
|
||||
const processNextValidation = (index = 0) => {
|
||||
if (index >= upcValidationRows.length) {
|
||||
return; // All rows processed
|
||||
}
|
||||
|
||||
const rowIndex = upcValidationRows[index];
|
||||
const row = newData[rowIndex];
|
||||
|
||||
if (row && row.supplier && row.upc) {
|
||||
// The EXACT implementation from handleUpdateRow when supplier is edited manually:
|
||||
|
||||
// 1. Mark the item_number cell as being validated - THIS IS CRITICAL FOR LOADING STATE
|
||||
const cellKey = `${rowIndex}-item_number`;
|
||||
|
||||
// Clear validation errors for this field
|
||||
setValidationErrors(prev => {
|
||||
const newErrors = new Map(prev);
|
||||
if (newErrors.has(rowIndex)) {
|
||||
const rowErrors = { ...newErrors.get(rowIndex) };
|
||||
if (rowErrors.item_number) {
|
||||
delete rowErrors.item_number;
|
||||
}
|
||||
newErrors.set(rowIndex, rowErrors);
|
||||
}
|
||||
return newErrors;
|
||||
});
|
||||
|
||||
// Set loading state - using setValidatingCells from props
|
||||
if (setValidatingCells) {
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
// Validate UPC for this row
|
||||
upcValidation.validateUpc(rowIndex, row.supplier.toString(), row.upc.toString())
|
||||
.then(result => {
|
||||
if (result.success && result.itemNumber) {
|
||||
// CRITICAL FIX: Directly update data with the item number to ensure immediate UI update
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
|
||||
// Update this specific row with the item number
|
||||
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
item_number: result.itemNumber
|
||||
};
|
||||
}
|
||||
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Also trigger other relevant updates
|
||||
upcValidation.applyItemNumbersToData();
|
||||
|
||||
// Mark for revalidation after item numbers are updated
|
||||
setTimeout(() => {
|
||||
// Validate the row EXACTLY like in manual edit
|
||||
validateRow(rowIndex, 'item_number');
|
||||
|
||||
// CRITICAL FIX: Make one final check to ensure data is correct
|
||||
setTimeout(() => {
|
||||
// Get the current item number from the data
|
||||
const currentItemNumber = (() => {
|
||||
try {
|
||||
const dataAtThisPointInTime = data[rowIndex];
|
||||
return dataAtThisPointInTime?.item_number;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
// If the data is wrong at this point, fix it directly
|
||||
if (currentItemNumber !== result.itemNumber) {
|
||||
// Directly update the data to fix the issue
|
||||
setData(dataRightNow => {
|
||||
const fixedData = [...dataRightNow];
|
||||
if (rowIndex >= 0 && rowIndex < fixedData.length) {
|
||||
fixedData[rowIndex] = {
|
||||
...fixedData[rowIndex],
|
||||
item_number: result.itemNumber
|
||||
};
|
||||
}
|
||||
return fixedData;
|
||||
});
|
||||
|
||||
// Then do a force update after a brief delay
|
||||
setTimeout(() => {
|
||||
setData(currentData => {
|
||||
// Critical fix: ensure the item number is correct
|
||||
if (currentData[rowIndex] && currentData[rowIndex].item_number !== result.itemNumber) {
|
||||
// Create a completely new array with the correct item number
|
||||
const fixedData = [...currentData];
|
||||
fixedData[rowIndex] = {
|
||||
...fixedData[rowIndex],
|
||||
item_number: result.itemNumber
|
||||
};
|
||||
return fixedData;
|
||||
}
|
||||
|
||||
// Create a completely new array
|
||||
return [...currentData];
|
||||
});
|
||||
}, 20);
|
||||
} else {
|
||||
// Item number is already correct, just do the force update
|
||||
setData(currentData => {
|
||||
// Create a completely new array
|
||||
return [...currentData];
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Clear loading state
|
||||
if (setValidatingCells) {
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
// Continue to next row after validation is complete
|
||||
setTimeout(() => processNextValidation(index + 1), 100);
|
||||
}, 100);
|
||||
} else {
|
||||
// Clear loading state on failure
|
||||
if (setValidatingCells) {
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
// Continue to next row if validation fails
|
||||
setTimeout(() => processNextValidation(index + 1), 100);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error validating UPC for row ${rowIndex}:`, err);
|
||||
|
||||
// Clear loading state on error
|
||||
if (setValidatingCells) {
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
// Continue to next row despite error
|
||||
setTimeout(() => processNextValidation(index + 1), 100);
|
||||
});
|
||||
} else {
|
||||
// Skip this row and continue to the next
|
||||
processNextValidation(index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Start processing validations
|
||||
processNextValidation();
|
||||
}
|
||||
},
|
||||
[
|
||||
data,
|
||||
templates,
|
||||
setData,
|
||||
setValidationErrors,
|
||||
setRowValidationStatus,
|
||||
validateRow,
|
||||
upcValidation,
|
||||
setValidatingCells
|
||||
]
|
||||
);
|
||||
|
||||
// Apply template to selected rows
|
||||
const applyTemplateToSelected = useCallback(
|
||||
(templateId: string) => {
|
||||
if (!templateId) return;
|
||||
|
||||
// Update the selected template ID
|
||||
setTemplateState((prev) => ({
|
||||
...prev,
|
||||
selectedTemplateId: templateId,
|
||||
}));
|
||||
|
||||
// Get selected row keys (which may be UUIDs)
|
||||
const selectedKeys = Object.entries(rowSelection)
|
||||
.filter(([_, selected]) => selected === true)
|
||||
.map(([key, _]) => key);
|
||||
|
||||
console.log("Selected row keys:", selectedKeys);
|
||||
|
||||
if (selectedKeys.length === 0) {
|
||||
toast.error("No rows selected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Map UUID keys to array indices
|
||||
const selectedIndexes = selectedKeys
|
||||
.map((key) => {
|
||||
// Find the matching row index in the data array
|
||||
const index = data.findIndex(
|
||||
(row) =>
|
||||
(row.__index && row.__index === key) || // Match by __index
|
||||
String(data.indexOf(row)) === key // Or by numeric index
|
||||
);
|
||||
return index;
|
||||
})
|
||||
.filter((index) => index !== -1); // Filter out any not found
|
||||
|
||||
console.log("Mapped row indices:", selectedIndexes);
|
||||
|
||||
if (selectedIndexes.length === 0) {
|
||||
toast.error("Could not find selected rows");
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply template to selected rows
|
||||
applyTemplate(templateId, selectedIndexes);
|
||||
},
|
||||
[rowSelection, applyTemplate, setTemplateState, data]
|
||||
);
|
||||
|
||||
return {
|
||||
templates,
|
||||
isLoadingTemplates,
|
||||
templateState,
|
||||
setTemplateState,
|
||||
loadTemplates,
|
||||
refreshTemplates,
|
||||
saveTemplate,
|
||||
applyTemplate,
|
||||
applyTemplateToSelected
|
||||
};
|
||||
};
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { RowData } from './validationTypes';
|
||||
import type { Fields } from '../../../types';
|
||||
import { ErrorSources, ErrorType, ValidationError } from '../../../types';
|
||||
|
||||
export const useUniqueItemNumbersValidation = <T extends string>(
|
||||
data: RowData<T>[],
|
||||
fields: Fields<T>,
|
||||
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>
|
||||
) => {
|
||||
// Update validateUniqueItemNumbers to also check for uniqueness of UPC/barcode
|
||||
const validateUniqueItemNumbers = useCallback(async () => {
|
||||
// Skip if no data
|
||||
if (!data.length) return;
|
||||
|
||||
// Track unique identifiers in maps
|
||||
const uniqueFieldsMap = new Map<string, Map<string, number[]>>();
|
||||
|
||||
// Find fields that need uniqueness validation
|
||||
const uniqueFields = fields
|
||||
.filter((field) => field.validations?.some((v) => v.rule === "unique"))
|
||||
.map((field) => String(field.key));
|
||||
|
||||
// Always check item_number uniqueness even if not explicitly defined
|
||||
if (!uniqueFields.includes("item_number")) {
|
||||
uniqueFields.push("item_number");
|
||||
}
|
||||
|
||||
// Initialize maps for each unique field
|
||||
uniqueFields.forEach((fieldKey) => {
|
||||
uniqueFieldsMap.set(fieldKey, new Map<string, number[]>());
|
||||
});
|
||||
|
||||
// Initialize batch updates
|
||||
const errors = new Map<number, Record<string, ValidationError[]>>();
|
||||
|
||||
// ASYNC: Single pass through data to identify all unique values in batches
|
||||
const BATCH_SIZE = 20;
|
||||
for (let batchStart = 0; batchStart < data.length; batchStart += BATCH_SIZE) {
|
||||
const batchEnd = Math.min(batchStart + BATCH_SIZE, data.length);
|
||||
|
||||
for (let index = batchStart; index < batchEnd; index++) {
|
||||
const row = data[index];
|
||||
uniqueFields.forEach((fieldKey) => {
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
|
||||
// Skip empty values
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const valueStr = String(value);
|
||||
const fieldMap = uniqueFieldsMap.get(fieldKey);
|
||||
|
||||
if (fieldMap) {
|
||||
// Get or initialize the array of indices for this value
|
||||
const indices = fieldMap.get(valueStr) || [];
|
||||
indices.push(index);
|
||||
fieldMap.set(valueStr, indices);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Yield control back to UI thread after each batch
|
||||
if (batchEnd < data.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// ASYNC: Process duplicates in batches to prevent UI blocking
|
||||
let processedFields = 0;
|
||||
for (const fieldKey of uniqueFields) {
|
||||
const fieldMap = uniqueFieldsMap.get(fieldKey);
|
||||
if (!fieldMap) continue;
|
||||
|
||||
fieldMap.forEach((indices, value) => {
|
||||
// Only process if there are duplicates
|
||||
if (indices.length > 1) {
|
||||
// Get the validation rule for this field
|
||||
const field = fields.find((f) => String(f.key) === fieldKey);
|
||||
const validationRule = field?.validations?.find(
|
||||
(v) => v.rule === "unique"
|
||||
);
|
||||
|
||||
const errorObj = {
|
||||
message:
|
||||
validationRule?.errorMessage || `Duplicate ${fieldKey}: ${value}`,
|
||||
level: validationRule?.level || ("error" as "error"),
|
||||
source: ErrorSources.Table,
|
||||
type: ErrorType.Unique,
|
||||
};
|
||||
|
||||
// Add error to each row with this value
|
||||
indices.forEach((rowIndex) => {
|
||||
const rowErrors = errors.get(rowIndex) || {};
|
||||
rowErrors[fieldKey] = [errorObj];
|
||||
errors.set(rowIndex, rowErrors);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
processedFields++;
|
||||
// Yield control after every few fields to prevent UI blocking
|
||||
if (processedFields % 2 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// Merge uniqueness errors with existing validation errors
|
||||
setValidationErrors((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
|
||||
// Add uniqueness errors
|
||||
errors.forEach((rowErrors, rowIndex) => {
|
||||
const existingErrors = newMap.get(rowIndex) || {};
|
||||
const updatedErrors = { ...existingErrors };
|
||||
|
||||
// Add uniqueness errors to existing errors
|
||||
Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => {
|
||||
updatedErrors[fieldKey] = fieldErrors;
|
||||
});
|
||||
|
||||
newMap.set(rowIndex, updatedErrors);
|
||||
});
|
||||
|
||||
// Clean up rows that have no uniqueness errors anymore
|
||||
// by removing only uniqueness error types from rows not in the errors map
|
||||
newMap.forEach((rowErrors, rowIndex) => {
|
||||
if (!errors.has(rowIndex)) {
|
||||
// Remove uniqueness errors from this row
|
||||
const cleanedErrors: Record<string, ValidationError[]> = {};
|
||||
Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => {
|
||||
// Keep non-uniqueness errors
|
||||
const nonUniqueErrors = fieldErrors.filter(error => error.type !== ErrorType.Unique);
|
||||
if (nonUniqueErrors.length > 0) {
|
||||
cleanedErrors[fieldKey] = nonUniqueErrors;
|
||||
}
|
||||
});
|
||||
|
||||
// Update the row or remove it if no errors remain
|
||||
if (Object.keys(cleanedErrors).length > 0) {
|
||||
newMap.set(rowIndex, cleanedErrors);
|
||||
} else {
|
||||
newMap.delete(rowIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return newMap;
|
||||
});
|
||||
|
||||
console.log("Uniqueness validation complete");
|
||||
}, [data, fields, setValidationErrors]);
|
||||
|
||||
return {
|
||||
validateUniqueItemNumbers
|
||||
};
|
||||
};
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { Fields } from '../../../types';
|
||||
import { ErrorSources, ErrorType } from '../../../types';
|
||||
import { RowData, InfoWithSource, isEmpty } from './validationTypes';
|
||||
|
||||
export const useUniqueValidation = <T extends string>(
|
||||
fields: Fields<T>
|
||||
) => {
|
||||
// Additional function to explicitly validate uniqueness for specified fields
|
||||
const validateUniqueField = useCallback((data: RowData<T>[], fieldKey: string) => {
|
||||
// Field keys that need special handling for uniqueness
|
||||
const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
||||
|
||||
// If the field doesn't need uniqueness validation, return empty errors
|
||||
if (!uniquenessFields.includes(fieldKey)) {
|
||||
const field = fields.find(f => String(f.key) === fieldKey);
|
||||
if (!field || !field.validations?.some(v => v.rule === 'unique')) {
|
||||
return new Map<number, Record<string, InfoWithSource>>();
|
||||
}
|
||||
}
|
||||
|
||||
// Create map to track errors
|
||||
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
|
||||
|
||||
// Find the field definition
|
||||
const field = fields.find(f => String(f.key) === fieldKey);
|
||||
if (!field) return uniqueErrors;
|
||||
|
||||
// Get validation properties
|
||||
const validation = field.validations?.find(v => v.rule === 'unique');
|
||||
const allowEmpty = validation?.allowEmpty ?? false;
|
||||
const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
|
||||
const level = validation?.level || 'error';
|
||||
|
||||
// Track values for uniqueness check
|
||||
const valueMap = new Map<string, number[]>();
|
||||
|
||||
// Build value map
|
||||
data.forEach((row, rowIndex) => {
|
||||
const value = String(row[fieldKey as keyof typeof row] || '');
|
||||
|
||||
// Skip empty values if allowed
|
||||
if (allowEmpty && isEmpty(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!valueMap.has(value)) {
|
||||
valueMap.set(value, [rowIndex]);
|
||||
} else {
|
||||
valueMap.get(value)?.push(rowIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Add errors for duplicate values
|
||||
valueMap.forEach((rowIndexes, value) => {
|
||||
if (rowIndexes.length > 1) {
|
||||
// Skip empty values
|
||||
if (!value || value.trim() === '') return;
|
||||
|
||||
// Add error to all duplicate rows
|
||||
rowIndexes.forEach(rowIndex => {
|
||||
// Create errors object if needed
|
||||
if (!uniqueErrors.has(rowIndex)) {
|
||||
uniqueErrors.set(rowIndex, {});
|
||||
}
|
||||
|
||||
// Add error for this field
|
||||
uniqueErrors.get(rowIndex)![fieldKey] = {
|
||||
message: errorMessage,
|
||||
level: level as 'info' | 'warning' | 'error',
|
||||
source: ErrorSources.Table,
|
||||
type: ErrorType.Unique
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return uniqueErrors;
|
||||
}, [fields]);
|
||||
|
||||
// Validate uniqueness for multiple fields
|
||||
const validateUniqueFields = useCallback((data: RowData<T>[], fieldKeys: string[]) => {
|
||||
// Process each field and merge results
|
||||
const allErrors = new Map<number, Record<string, InfoWithSource>>();
|
||||
|
||||
fieldKeys.forEach(fieldKey => {
|
||||
const fieldErrors = validateUniqueField(data, fieldKey);
|
||||
|
||||
// Merge errors
|
||||
fieldErrors.forEach((errors, rowIdx) => {
|
||||
if (!allErrors.has(rowIdx)) {
|
||||
allErrors.set(rowIdx, {});
|
||||
}
|
||||
Object.assign(allErrors.get(rowIdx)!, errors);
|
||||
});
|
||||
});
|
||||
|
||||
return allErrors;
|
||||
}, [validateUniqueField]);
|
||||
|
||||
// Run complete validation for uniqueness
|
||||
const validateAllUniqueFields = useCallback((data: RowData<T>[]) => {
|
||||
// Get fields requiring uniqueness validation
|
||||
const uniqueFields = fields
|
||||
.filter(field => field.validations?.some(v => v.rule === 'unique'))
|
||||
.map(field => String(field.key));
|
||||
|
||||
// Also add standard unique fields that might not be explicitly marked as unique
|
||||
const standardUniqueFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
||||
|
||||
// Combine all fields that need uniqueness validation
|
||||
const allUniqueFieldKeys = [...new Set([
|
||||
...uniqueFields,
|
||||
...standardUniqueFields
|
||||
])];
|
||||
|
||||
// Filter to only fields that exist in the data
|
||||
const existingFields = allUniqueFieldKeys.filter(fieldKey =>
|
||||
data.some(row => fieldKey in row)
|
||||
);
|
||||
|
||||
// Validate all fields at once
|
||||
return validateUniqueFields(data, existingFields);
|
||||
}, [fields, validateUniqueFields]);
|
||||
|
||||
return {
|
||||
validateUniqueField,
|
||||
validateUniqueFields,
|
||||
validateAllUniqueFields
|
||||
};
|
||||
};
|
||||
-595
@@ -1,595 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react'
|
||||
import config from '@/config'
|
||||
import { ErrorSources, ErrorType, ValidationError } from '../../../types'
|
||||
|
||||
interface ValidationState {
|
||||
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
|
||||
itemNumbers: Map<number, string>; // Using rowIndex as key
|
||||
validatingRows: Set<number>; // Rows currently being validated
|
||||
activeValidations: Set<string>; // Active validations
|
||||
}
|
||||
|
||||
export const useUpcValidation = (
|
||||
data: any[],
|
||||
setData: (updater: any[] | ((prevData: any[]) => any[])) => void,
|
||||
setValidationErrors: Dispatch<SetStateAction<Map<number, Record<string, ValidationError[]>>>>
|
||||
) => {
|
||||
// Use a ref for validation state to avoid triggering re-renders
|
||||
const validationStateRef = useRef<ValidationState>({
|
||||
validatingCells: new Set(),
|
||||
itemNumbers: new Map(),
|
||||
validatingRows: new Set(),
|
||||
activeValidations: new Set()
|
||||
});
|
||||
|
||||
// Use state only for forcing re-renders of specific cells
|
||||
const [, setValidatingCellKeys] = useState<Set<string>>(new Set());
|
||||
const [, setItemNumberUpdates] = useState<Map<number, string>>(new Map());
|
||||
const [validatingRows, setValidatingRows] = useState<Set<number>>(new Set());
|
||||
const [, setIsValidatingUpc] = useState(false);
|
||||
|
||||
// Cache for UPC validation results
|
||||
const processedUpcMapRef = useRef(new Map<string, string>());
|
||||
const initialUpcValidationStartedRef = useRef(false);
|
||||
const initialUpcValidationDoneRef = useRef(false);
|
||||
|
||||
// Helper to create cell key
|
||||
const getCellKey = (rowIndex: number, fieldKey: string) => `${rowIndex}-${fieldKey}`;
|
||||
|
||||
// Start validating a cell
|
||||
const startValidatingCell = useCallback((rowIndex: number, fieldKey: string) => {
|
||||
const cellKey = getCellKey(rowIndex, fieldKey);
|
||||
validationStateRef.current.validatingCells.add(cellKey);
|
||||
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
|
||||
}, []);
|
||||
|
||||
// Stop validating a cell
|
||||
const stopValidatingCell = useCallback((rowIndex: number, fieldKey: string) => {
|
||||
const cellKey = getCellKey(rowIndex, fieldKey);
|
||||
validationStateRef.current.validatingCells.delete(cellKey);
|
||||
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
|
||||
}, []);
|
||||
|
||||
// Update item number
|
||||
const updateItemNumber = useCallback((rowIndex: number, itemNumber: string) => {
|
||||
// CRITICAL: Update BOTH the data state and the ref
|
||||
// First, update the data directly to ensure UI consistency
|
||||
setData(prevData => {
|
||||
// Create a new copy of the data
|
||||
const newData = [...prevData];
|
||||
|
||||
// Only update if the row exists
|
||||
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||
// First, we need a new object reference for the row to force a re-render
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
item_number: itemNumber
|
||||
};
|
||||
}
|
||||
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Also update the itemNumbers map AFTER the data is updated
|
||||
// This ensures the map reflects the current state of the data
|
||||
setTimeout(() => {
|
||||
// Update the ref with the same value
|
||||
validationStateRef.current.itemNumbers.set(rowIndex, itemNumber);
|
||||
|
||||
// CRITICAL: Force a React state update to ensure all components re-render
|
||||
// Created a brand new Map object to ensure React detects the change
|
||||
const newItemNumbersMap = new Map(validationStateRef.current.itemNumbers);
|
||||
setItemNumberUpdates(newItemNumbersMap);
|
||||
|
||||
// Force an immediate React render cycle by triggering state updates
|
||||
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
|
||||
setValidatingRows(new Set(validationStateRef.current.validatingRows));
|
||||
}, 0);
|
||||
}, [setData]);
|
||||
|
||||
const applyUpcUniqueError = useCallback((rowIndex: number, message?: string) => {
|
||||
const error: ValidationError = {
|
||||
message: message || 'Must be unique',
|
||||
level: 'error',
|
||||
source: ErrorSources.Table,
|
||||
type: ErrorType.Unique
|
||||
};
|
||||
|
||||
setValidationErrors(prev => {
|
||||
const newErrors = new Map(prev);
|
||||
const existing = { ...(newErrors.get(rowIndex) || {}) };
|
||||
existing.upc = [error];
|
||||
newErrors.set(rowIndex, existing);
|
||||
return newErrors;
|
||||
});
|
||||
}, [setValidationErrors]);
|
||||
|
||||
const clearUpcUniqueError = useCallback((rowIndex: number) => {
|
||||
setValidationErrors(prev => {
|
||||
const existing = prev.get(rowIndex);
|
||||
if (!existing || !existing.upc) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const filtered = existing.upc.filter(err => err.type !== ErrorType.Unique);
|
||||
if (filtered.length === existing.upc.length) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const newErrors = new Map(prev);
|
||||
const updated = { ...existing } as Record<string, ValidationError[]>;
|
||||
if (filtered.length > 0) {
|
||||
updated.upc = filtered;
|
||||
} else {
|
||||
delete updated.upc;
|
||||
}
|
||||
|
||||
if (Object.keys(updated).length > 0) {
|
||||
newErrors.set(rowIndex, updated);
|
||||
} else {
|
||||
newErrors.delete(rowIndex);
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
});
|
||||
}, [setValidationErrors]);
|
||||
|
||||
// Mark a row as no longer being validated
|
||||
const stopValidatingRow = useCallback((rowIndex: number) => {
|
||||
validationStateRef.current.validatingRows.delete(rowIndex);
|
||||
setValidatingRows(new Set(validationStateRef.current.validatingRows));
|
||||
|
||||
// If no more rows are being validated, set global validation state to false
|
||||
if (validationStateRef.current.validatingRows.size === 0) {
|
||||
setIsValidatingUpc(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check if a specific cell is being validated
|
||||
const isValidatingCell = useCallback((rowIndex: number, fieldKey: string): boolean => {
|
||||
return validationStateRef.current.validatingCells.has(getCellKey(rowIndex, fieldKey));
|
||||
}, []);
|
||||
|
||||
// Check if a specific row is being validated
|
||||
const isRowValidatingUpc = useCallback((rowIndex: number): boolean => {
|
||||
return validationStateRef.current.validatingRows.has(rowIndex);
|
||||
}, []);
|
||||
|
||||
// Get item number for a row
|
||||
const getItemNumber = useCallback((rowIndex: number): string | undefined => {
|
||||
return validationStateRef.current.itemNumbers.get(rowIndex);
|
||||
}, []);
|
||||
|
||||
// Fetch product by UPC from API
|
||||
const fetchProductByUpc = useCallback(async (supplierId: string, upcValue: string) => {
|
||||
try {
|
||||
console.log(`Fetching product for UPC ${upcValue} with supplier ${supplierId}`);
|
||||
const response = await fetch(`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`);
|
||||
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (parseError) {
|
||||
// Non-JSON responses are treated generically below
|
||||
}
|
||||
|
||||
if (response.status === 409) {
|
||||
console.log(`UPC ${upcValue} already exists`);
|
||||
return {
|
||||
error: true,
|
||||
code: 'conflict',
|
||||
message: payload?.error || 'UPC already exists',
|
||||
data: payload || undefined
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`API error: ${response.status}`);
|
||||
return {
|
||||
error: true,
|
||||
code: 'http_error',
|
||||
message: payload?.error || `API error (${response.status})`,
|
||||
data: payload || undefined
|
||||
};
|
||||
}
|
||||
|
||||
const data = payload;
|
||||
|
||||
if (!data?.success) {
|
||||
return {
|
||||
error: true,
|
||||
code: 'invalid_response',
|
||||
message: data?.message || 'Unknown error'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: false,
|
||||
data: {
|
||||
itemNumber: data.itemNumber || '',
|
||||
...data
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Network error:', error);
|
||||
return { error: true, message: 'Network error' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validate a UPC for a row - returns a promise that resolves when complete
|
||||
const validateUpc = useCallback(async (rowIndex: number, supplierId: string, upcValue: string) => {
|
||||
// Clear any previous validation keys for this row to avoid cancellations
|
||||
const previousKeys = Array.from(validationStateRef.current.activeValidations).filter(key =>
|
||||
key.startsWith(`${rowIndex}-`)
|
||||
);
|
||||
previousKeys.forEach(key => validationStateRef.current.activeValidations.delete(key));
|
||||
|
||||
// Log validation start to help debug template issues
|
||||
console.log(`[UPC-DEBUG] Starting UPC validation for row ${rowIndex} with supplier ${supplierId}, upc ${upcValue}`);
|
||||
|
||||
// IMPORTANT: Set validation state using setState to FORCE UI updates
|
||||
validationStateRef.current.validatingRows.add(rowIndex);
|
||||
setValidatingRows(new Set(validationStateRef.current.validatingRows));
|
||||
setIsValidatingUpc(true);
|
||||
|
||||
// Start cell validation and explicitly update UI via setState
|
||||
const cellKey = getCellKey(rowIndex, 'item_number');
|
||||
validationStateRef.current.validatingCells.add(cellKey);
|
||||
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
|
||||
|
||||
console.log(`[UPC-DEBUG] Set loading state for row ${rowIndex}, cell key ${cellKey}`);
|
||||
console.log(`[UPC-DEBUG] Current validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
|
||||
console.log(`[UPC-DEBUG] Current validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
|
||||
|
||||
try {
|
||||
// Create a unique key for this validation to track it
|
||||
const validationKey = `${rowIndex}-${supplierId}-${upcValue}`;
|
||||
validationStateRef.current.activeValidations.add(validationKey);
|
||||
|
||||
// IMPORTANT: First update the data with the new UPC value to prevent UI flicker
|
||||
// This ensures the UPC field keeps showing the new value while validation runs
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
if (newData[rowIndex]) {
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
upc: upcValue
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Fetch the product by UPC
|
||||
console.log(`[UPC-DEBUG] Fetching product data for UPC ${upcValue} with supplier ${supplierId}`);
|
||||
const product = await fetchProductByUpc(supplierId, upcValue);
|
||||
console.log(`[UPC-DEBUG] Fetch complete for row ${rowIndex}, success: ${!product.error}`);
|
||||
|
||||
// Check if this validation is still relevant (hasn't been superseded by another)
|
||||
if (!validationStateRef.current.activeValidations.has(validationKey)) {
|
||||
console.log(`[UPC-DEBUG] Validation ${validationKey} was cancelled`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
// Extract the item number from the API response - check for !error since API returns { error: boolean, data: any }
|
||||
if (product && !product.error && product.data?.itemNumber) {
|
||||
console.log(`[UPC-DEBUG] Got item number for row ${rowIndex}: ${product.data.itemNumber}`);
|
||||
updateItemNumber(rowIndex, product.data.itemNumber);
|
||||
|
||||
clearUpcUniqueError(rowIndex);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
itemNumber: product.data.itemNumber
|
||||
};
|
||||
} else if (product && product.error) {
|
||||
console.log(`[UPC-DEBUG] UPC validation error for row ${rowIndex}: ${product.message}`);
|
||||
|
||||
// Clear any existing item number value in data and internal state
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
item_number: ''
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
|
||||
validationStateRef.current.itemNumbers.delete(rowIndex);
|
||||
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
||||
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
|
||||
}
|
||||
|
||||
if (product.code === 'conflict') {
|
||||
applyUpcUniqueError(rowIndex, 'Must be unique');
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
} else {
|
||||
// No item number found but validation was still attempted
|
||||
console.log(`[UPC-DEBUG] No item number found for UPC ${upcValue}`);
|
||||
|
||||
// Clear any existing item number to show validation was attempted and failed
|
||||
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
|
||||
validationStateRef.current.itemNumbers.delete(rowIndex);
|
||||
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
||||
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[UPC-DEBUG] Error validating UPC:', error);
|
||||
return { success: false };
|
||||
} finally {
|
||||
// End validation - FORCE UI update by using setState directly
|
||||
console.log(`[UPC-DEBUG] Ending validation for row ${rowIndex}`);
|
||||
|
||||
validationStateRef.current.validatingRows.delete(rowIndex);
|
||||
setValidatingRows(new Set(validationStateRef.current.validatingRows));
|
||||
|
||||
if (validationStateRef.current.validatingRows.size === 0) {
|
||||
setIsValidatingUpc(false);
|
||||
}
|
||||
|
||||
validationStateRef.current.validatingCells.delete(cellKey);
|
||||
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
|
||||
|
||||
console.log(`[UPC-DEBUG] Cleared loading state for row ${rowIndex}`);
|
||||
console.log(`[UPC-DEBUG] Updated validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
|
||||
console.log(`[UPC-DEBUG] Updated validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
|
||||
}
|
||||
}, [fetchProductByUpc, updateItemNumber, setData, applyUpcUniqueError, clearUpcUniqueError]);
|
||||
|
||||
// Apply all pending item numbers to the data state
|
||||
const applyItemNumbersToData = useCallback((callback?: (updatedRows: number[]) => void) => {
|
||||
// Skip if we have nothing to apply
|
||||
if (validationStateRef.current.itemNumbers.size === 0) {
|
||||
if (callback) callback([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gather all row IDs that will be updated
|
||||
const rowIds: number[] = [];
|
||||
|
||||
// Update the data state with all item numbers
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
|
||||
// Apply each item number to the data
|
||||
validationStateRef.current.itemNumbers.forEach((itemNumber, rowIndex) => {
|
||||
// Ensure row exists and value has actually changed
|
||||
if (rowIndex >= 0 && rowIndex < newData.length &&
|
||||
newData[rowIndex]?.item_number !== itemNumber) {
|
||||
|
||||
// Create a new row object to force re-rendering
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
item_number: itemNumber
|
||||
};
|
||||
|
||||
// Track which row was updated for the callback
|
||||
rowIds.push(rowIndex);
|
||||
}
|
||||
});
|
||||
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Force a re-render by updating React state
|
||||
setTimeout(() => {
|
||||
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
||||
}, 0);
|
||||
|
||||
// Call the callback with the updated row IDs
|
||||
if (callback) {
|
||||
callback(rowIds);
|
||||
}
|
||||
}, [setData]);
|
||||
|
||||
// Batch validate all UPCs in the data
|
||||
const validateAllUPCs = useCallback(async () => {
|
||||
// Skip if we've already done the initial validation
|
||||
if (initialUpcValidationStartedRef.current) {
|
||||
console.log('Initial UPC validation already in progress or complete, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark that we've started the initial validation
|
||||
initialUpcValidationStartedRef.current = true;
|
||||
|
||||
console.log('Starting initial UPC validation...');
|
||||
|
||||
// Set validation state
|
||||
setIsValidatingUpc(true);
|
||||
|
||||
// Find all rows that have both supplier and UPC/barcode
|
||||
const rowsToValidate = data
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(({ row }) => {
|
||||
const rowAny = row as Record<string, any>;
|
||||
const hasSupplier = rowAny.supplier;
|
||||
const hasUpc = rowAny.upc || rowAny.barcode;
|
||||
return hasSupplier && hasUpc;
|
||||
});
|
||||
|
||||
const totalRows = rowsToValidate.length;
|
||||
console.log(`Found ${totalRows} rows with both supplier and UPC for initial validation`);
|
||||
|
||||
if (totalRows === 0) {
|
||||
setIsValidatingUpc(false);
|
||||
initialUpcValidationDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark all rows as being validated
|
||||
const newValidatingRows = new Set(rowsToValidate.map(({ index }) => index));
|
||||
validationStateRef.current.validatingRows = newValidatingRows;
|
||||
setValidatingRows(newValidatingRows);
|
||||
|
||||
try {
|
||||
// Process rows in batches for better UX
|
||||
const BATCH_SIZE = 100;
|
||||
const batches = [];
|
||||
|
||||
// Split rows into batches
|
||||
for (let i = 0; i < rowsToValidate.length; i += BATCH_SIZE) {
|
||||
batches.push(rowsToValidate.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
console.log(`Processing ${batches.length} batches for ${totalRows} rows`);
|
||||
|
||||
// Process each batch sequentially
|
||||
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
|
||||
const batch = batches[batchIndex];
|
||||
console.log(`Processing batch ${batchIndex + 1}/${batches.length} with ${batch.length} rows`);
|
||||
|
||||
// Track updated rows in this batch
|
||||
const batchUpdatedRows: number[] = [];
|
||||
|
||||
// Process all rows in current batch in parallel
|
||||
await Promise.all(
|
||||
batch.map(async ({ row, index }) => {
|
||||
try {
|
||||
const rowAny = row as Record<string, any>;
|
||||
const supplierId = rowAny.supplier.toString();
|
||||
const upcValue = (rowAny.upc || rowAny.barcode).toString();
|
||||
|
||||
console.log(`Validating UPC in initial batch: row=${index}, supplier=${supplierId}, upc=${upcValue}`);
|
||||
|
||||
// Mark the item_number cell as validating
|
||||
startValidatingCell(index, 'item_number');
|
||||
|
||||
// Validate the UPC directly (don't use validateUpc to avoid duplicate UI updates)
|
||||
const cacheKey = `${supplierId}-${upcValue}`;
|
||||
|
||||
// Check cache first
|
||||
if (processedUpcMapRef.current.has(cacheKey)) {
|
||||
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
|
||||
if (cachedItemNumber) {
|
||||
console.log(`Using cached item number for row ${index}: ${cachedItemNumber}`);
|
||||
updateItemNumber(index, cachedItemNumber);
|
||||
batchUpdatedRows.push(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Make API call
|
||||
const result = await fetchProductByUpc(supplierId, upcValue);
|
||||
|
||||
if (!result.error && result.data?.itemNumber) {
|
||||
const itemNumber = result.data.itemNumber;
|
||||
console.log(`Got item number from API for row ${index}: ${itemNumber}`);
|
||||
|
||||
// Cache the result
|
||||
processedUpcMapRef.current.set(cacheKey, itemNumber);
|
||||
|
||||
// Update item number
|
||||
updateItemNumber(index, itemNumber);
|
||||
batchUpdatedRows.push(index);
|
||||
clearUpcUniqueError(index);
|
||||
} else {
|
||||
console.warn(`No item number found for row ${index} UPC ${upcValue}`);
|
||||
|
||||
// Clear any previous item numbers for the row
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
if (index >= 0 && index < newData.length) {
|
||||
newData[index] = {
|
||||
...newData[index],
|
||||
item_number: ''
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
if (validationStateRef.current.itemNumbers.has(index)) {
|
||||
validationStateRef.current.itemNumbers.delete(index);
|
||||
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
||||
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
|
||||
}
|
||||
|
||||
if (result.error && result.code === 'conflict') {
|
||||
applyUpcUniqueError(index, 'Must be unique');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error validating row ${index}:`, error);
|
||||
} finally {
|
||||
// Clear validation state
|
||||
stopValidatingCell(index, 'item_number');
|
||||
stopValidatingRow(index);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Apply updates for this batch
|
||||
if (validationStateRef.current.itemNumbers.size > 0) {
|
||||
console.log(`Applying item numbers after batch ${batchIndex + 1}`);
|
||||
applyItemNumbersToData(updatedRowIds => {
|
||||
console.log(`Processed initial UPC validation batch ${batchIndex + 1} for rows: ${updatedRowIds.join(', ')}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Small delay between batches to update UI
|
||||
if (batchIndex < batches.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in batch validation:', error);
|
||||
} finally {
|
||||
initialUpcValidationDoneRef.current = true;
|
||||
// Make sure all validation states are cleared
|
||||
validationStateRef.current.validatingRows.clear();
|
||||
setValidatingRows(new Set());
|
||||
setIsValidatingUpc(false);
|
||||
|
||||
console.log('Completed initial UPC validation');
|
||||
}
|
||||
}, [
|
||||
data,
|
||||
fetchProductByUpc,
|
||||
updateItemNumber,
|
||||
startValidatingCell,
|
||||
stopValidatingCell,
|
||||
stopValidatingRow,
|
||||
applyItemNumbersToData,
|
||||
setData,
|
||||
applyUpcUniqueError,
|
||||
clearUpcUniqueError
|
||||
]);
|
||||
|
||||
// Run initial UPC validation when data changes
|
||||
useEffect(() => {
|
||||
if (initialUpcValidationStartedRef.current) return;
|
||||
|
||||
validateAllUPCs();
|
||||
}, [data, validateAllUPCs]);
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
// Validation methods
|
||||
validateUpc,
|
||||
validateAllUPCs,
|
||||
|
||||
// Cell state
|
||||
isValidatingCell,
|
||||
isRowValidatingUpc,
|
||||
|
||||
// Row state
|
||||
validatingRows: validatingRows, // Expose as a Set to components
|
||||
|
||||
// Item number management
|
||||
getItemNumber,
|
||||
applyItemNumbersToData,
|
||||
|
||||
// CRITICAL: Expose the itemNumbers map directly
|
||||
itemNumbers: validationStateRef.current.itemNumbers,
|
||||
|
||||
// Initialization state
|
||||
initialValidationDone: initialUpcValidationDoneRef.current
|
||||
};
|
||||
};
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { Field, Fields, RowHook } from '../../../types'
|
||||
import { ErrorSources } from '../../../types'
|
||||
import { RowData, InfoWithSource } from './validationTypes'
|
||||
import { useFieldValidation, clearValidationCacheForField, clearAllUniquenessCaches } from './useFieldValidation'
|
||||
import { useUniqueValidation } from './useUniqueValidation'
|
||||
|
||||
// Main validation hook that brings together field and uniqueness validation
|
||||
export const useValidation = <T extends string>(
|
||||
fields: Fields<T>,
|
||||
rowHook?: RowHook<T>
|
||||
) => {
|
||||
// Use the field validation hook
|
||||
const { validateField, validateRow } = useFieldValidation(fields, rowHook);
|
||||
|
||||
// Use the uniqueness validation hook
|
||||
const {
|
||||
validateUniqueField,
|
||||
validateAllUniqueFields
|
||||
} = useUniqueValidation(fields);
|
||||
|
||||
// Run complete validation
|
||||
const validateData = useCallback(async (data: RowData<T>[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => {
|
||||
const validationErrors = new Map<number, Record<string, InfoWithSource>>();
|
||||
|
||||
// If we're updating a specific field, only validate that field for that row
|
||||
if (fieldToUpdate) {
|
||||
const { rowIndex, fieldKey } = fieldToUpdate;
|
||||
|
||||
// Special handling for fields that often update item_number
|
||||
const triggersItemNumberValidation = fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier';
|
||||
|
||||
// If updating a uniqueness field or field that affects item_number, clear ALL related validation caches
|
||||
const isUniqueField = fieldKey === 'upc' || fieldKey === 'item_number' ||
|
||||
fieldKey === 'supplier_no' || fieldKey === 'notions_no' ||
|
||||
fieldKey === 'name' || triggersItemNumberValidation;
|
||||
|
||||
// Force cache clearing for uniqueness-validated fields to ensure fresh validation
|
||||
if (isUniqueField) {
|
||||
console.log(`Clearing validation cache for uniqueness field: ${fieldKey}`);
|
||||
clearValidationCacheForField(fieldKey);
|
||||
|
||||
// If a field that might affect item_number, also clear item_number cache
|
||||
if (triggersItemNumberValidation) {
|
||||
console.log('Also clearing item_number validation cache');
|
||||
clearValidationCacheForField('item_number');
|
||||
}
|
||||
}
|
||||
|
||||
if (rowIndex >= 0 && rowIndex < data.length) {
|
||||
const row = data[rowIndex];
|
||||
|
||||
// Find the field definition
|
||||
const field = fields.find(f => String(f.key) === fieldKey);
|
||||
|
||||
if (field) {
|
||||
// Validate just this field for this row
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
const errors = validateField(value, field as Field<T>);
|
||||
|
||||
if (errors.length > 0) {
|
||||
// Store the validation error
|
||||
validationErrors.set(rowIndex, {
|
||||
[fieldKey]: {
|
||||
message: errors[0].message,
|
||||
level: errors[0].level as 'info' | 'warning' | 'error',
|
||||
source: ErrorSources.Row,
|
||||
type: errors[0].type
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the field requires uniqueness validation or if it's item_number after UPC/Supplier change
|
||||
const needsUniquenessCheck = isUniqueField ||
|
||||
field.validations?.some(v => v.rule === 'unique');
|
||||
|
||||
if (needsUniquenessCheck) {
|
||||
console.log(`Running immediate uniqueness validation for field ${fieldKey}`);
|
||||
|
||||
// For item_number updated via UPC validation, or direct UPC update, check both fields
|
||||
if (fieldKey === 'item_number' || fieldKey === 'upc' || fieldKey === 'barcode') {
|
||||
// Validate both item_number and UPC/barcode fields for uniqueness
|
||||
const itemNumberUniqueErrors = validateUniqueField(data, 'item_number');
|
||||
const upcUniqueErrors = validateUniqueField(data, fieldKey === 'item_number' ? 'upc' : fieldKey);
|
||||
|
||||
// Combine the errors
|
||||
itemNumberUniqueErrors.forEach((errors, rowIdx) => {
|
||||
if (!validationErrors.has(rowIdx)) {
|
||||
validationErrors.set(rowIdx, {});
|
||||
}
|
||||
Object.assign(validationErrors.get(rowIdx)!, errors);
|
||||
});
|
||||
|
||||
upcUniqueErrors.forEach((errors, rowIdx) => {
|
||||
if (!validationErrors.has(rowIdx)) {
|
||||
validationErrors.set(rowIdx, {});
|
||||
}
|
||||
Object.assign(validationErrors.get(rowIdx)!, errors);
|
||||
});
|
||||
} else {
|
||||
// Normal uniqueness validation for other fields
|
||||
const uniqueErrors = validateUniqueField(data, fieldKey);
|
||||
|
||||
// Add unique errors to validation errors
|
||||
uniqueErrors.forEach((errors, rowIdx) => {
|
||||
if (!validationErrors.has(rowIdx)) {
|
||||
validationErrors.set(rowIdx, {});
|
||||
}
|
||||
Object.assign(validationErrors.get(rowIdx)!, errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Full validation - all fields for all rows
|
||||
console.log('Running full validation for all fields and rows');
|
||||
|
||||
// Process each row for field-level validations
|
||||
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
|
||||
const row = data[rowIndex];
|
||||
let rowErrors: Record<string, InfoWithSource> = {};
|
||||
|
||||
// Validate all fields for this row
|
||||
fields.forEach(field => {
|
||||
const fieldKey = String(field.key);
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
const errors = validateField(value, field as Field<T>);
|
||||
|
||||
if (errors.length > 0) {
|
||||
rowErrors[fieldKey] = {
|
||||
message: errors[0].message,
|
||||
level: errors[0].level as 'info' | 'warning' | 'error',
|
||||
source: ErrorSources.Row,
|
||||
type: errors[0].type
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Add row to validationErrors if it has any errors
|
||||
if (Object.keys(rowErrors).length > 0) {
|
||||
validationErrors.set(rowIndex, rowErrors);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate all unique fields
|
||||
const uniqueErrors = validateAllUniqueFields(data);
|
||||
|
||||
// Merge in unique errors
|
||||
uniqueErrors.forEach((errors, rowIdx) => {
|
||||
if (!validationErrors.has(rowIdx)) {
|
||||
validationErrors.set(rowIdx, {});
|
||||
}
|
||||
Object.assign(validationErrors.get(rowIdx)!, errors);
|
||||
});
|
||||
|
||||
console.log('Uniqueness validation complete');
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
validationErrors
|
||||
};
|
||||
}, [fields, validateField, validateUniqueField, validateAllUniqueFields]);
|
||||
|
||||
return {
|
||||
validateData,
|
||||
validateField,
|
||||
validateRow,
|
||||
validateUniqueField,
|
||||
clearValidationCacheForField,
|
||||
clearAllUniquenessCaches
|
||||
};
|
||||
}
|
||||
-538
@@ -1,538 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useRsi } from "../../../hooks/useRsi";
|
||||
import { ErrorType } from "../../../types";
|
||||
import { RowSelectionState } from "@tanstack/react-table";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import config from "@/config";
|
||||
import { useValidation } from "./useValidation";
|
||||
import { useRowOperations } from "./useRowOperations";
|
||||
import { useTemplateManagement } from "./useTemplateManagement";
|
||||
import { useFilterManagement } from "./useFilterManagement";
|
||||
import { useUniqueItemNumbersValidation } from "./useUniqueItemNumbersValidation";
|
||||
import { useUpcValidation } from "./useUpcValidation";
|
||||
import { useInitialValidation } from "./useInitialValidation";
|
||||
import { Props, RowData } from "./validationTypes";
|
||||
import { normalizeCountryCode } from "../utils/countryUtils";
|
||||
import { cleanPriceField } from "../utils/priceUtils";
|
||||
import { correctUpcValue } from "../utils/upcUtils";
|
||||
|
||||
export const useValidationState = <T extends string>({
|
||||
initialData,
|
||||
onBack,
|
||||
onNext,
|
||||
}: Props<T>) => {
|
||||
const { fields, rowHook, tableHook } = useRsi<T>();
|
||||
|
||||
// Import validateField and validateUniqueField from useValidation
|
||||
const { validateField: validateFieldFromHook, validateUniqueField } = useValidation<T>(
|
||||
fields,
|
||||
rowHook
|
||||
);
|
||||
|
||||
// Add ref to track template application state
|
||||
const isApplyingTemplateRef = useRef(false);
|
||||
|
||||
// Core data state
|
||||
const [data, setData] = useState<RowData<T>[]>(() => {
|
||||
// Clean price fields in initial data before setting state
|
||||
return initialData.map((row, index) => {
|
||||
const updatedRow = { ...row } as Record<string, any>;
|
||||
|
||||
// Ensure each row has a stable __index key for downstream lookups
|
||||
if (updatedRow.__index === undefined || updatedRow.__index === null || updatedRow.__index === '') {
|
||||
updatedRow.__index = String(index);
|
||||
}
|
||||
|
||||
// Clean price fields using utility
|
||||
if (updatedRow.msrp !== undefined) {
|
||||
updatedRow.msrp = cleanPriceField(updatedRow.msrp);
|
||||
}
|
||||
if (updatedRow.cost_each !== undefined) {
|
||||
updatedRow.cost_each = cleanPriceField(updatedRow.cost_each);
|
||||
}
|
||||
|
||||
// Set default tax category if not already set
|
||||
if (
|
||||
updatedRow.tax_cat === undefined ||
|
||||
updatedRow.tax_cat === null ||
|
||||
updatedRow.tax_cat === ""
|
||||
) {
|
||||
updatedRow.tax_cat = "0";
|
||||
}
|
||||
|
||||
// Set default shipping restrictions if not already set
|
||||
if (
|
||||
updatedRow.ship_restrictions === undefined ||
|
||||
updatedRow.ship_restrictions === null ||
|
||||
updatedRow.ship_restrictions === ""
|
||||
) {
|
||||
updatedRow.ship_restrictions = "0";
|
||||
}
|
||||
|
||||
// Normalize country code (COO) to 2-letter ISO if possible
|
||||
if (typeof updatedRow.coo === "string") {
|
||||
const raw = updatedRow.coo.trim();
|
||||
const normalized = normalizeCountryCode(raw);
|
||||
if (normalized) {
|
||||
updatedRow.coo = normalized;
|
||||
} else {
|
||||
// Uppercase 2-letter values as fallback
|
||||
if (raw.length === 2) updatedRow.coo = raw.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedRow.upc !== undefined && updatedRow.upc !== null) {
|
||||
const { corrected, changed } = correctUpcValue(updatedRow.upc);
|
||||
if (changed) {
|
||||
updatedRow.upc = corrected;
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedRow.barcode !== undefined && updatedRow.barcode !== null) {
|
||||
const { corrected, changed } = correctUpcValue(updatedRow.barcode);
|
||||
if (changed) {
|
||||
updatedRow.barcode = corrected;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRow as RowData<T>;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Row selection state
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
// Validation state
|
||||
const [validationErrors, setValidationErrors] = useState<
|
||||
Map<number, Record<string, any[]>>
|
||||
>(new Map());
|
||||
const [rowValidationStatus, setRowValidationStatus] = useState<
|
||||
Map<number, "pending" | "validating" | "validated" | "error">
|
||||
>(new Map());
|
||||
|
||||
// Add state for tracking cells in loading state
|
||||
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set());
|
||||
|
||||
// Add global editing state to prevent validation during editing
|
||||
const [editingCells, setEditingCells] = useState<Set<string>>(new Set());
|
||||
const hasEditingCells = editingCells.size > 0;
|
||||
|
||||
// Track initial validation lifecycle
|
||||
const [initialValidationComplete, setInitialValidationComplete] = useState(false);
|
||||
// Track last seen item_number signature to drive targeted uniqueness checks
|
||||
const lastItemNumberSigRef = useRef<string | null>(null);
|
||||
|
||||
// Use row operations hook
|
||||
const { validateRow, updateRow, revalidateRows, copyDown } = useRowOperations<T>(
|
||||
data,
|
||||
fields,
|
||||
setData,
|
||||
setValidationErrors,
|
||||
validateFieldFromHook
|
||||
);
|
||||
|
||||
// Use UPC validation hook - MUST be initialized before template management
|
||||
const upcValidation = useUpcValidation(data, setData, setValidationErrors);
|
||||
|
||||
// Use unique item numbers validation hook
|
||||
const { validateUniqueItemNumbers } = useUniqueItemNumbersValidation<T>(
|
||||
data,
|
||||
fields,
|
||||
setValidationErrors
|
||||
);
|
||||
|
||||
// Use template management hook
|
||||
const templateManagement = useTemplateManagement<T>(
|
||||
data,
|
||||
setData,
|
||||
rowSelection,
|
||||
setValidationErrors,
|
||||
setRowValidationStatus,
|
||||
validateRow,
|
||||
isApplyingTemplateRef,
|
||||
upcValidation,
|
||||
setValidatingCells
|
||||
);
|
||||
|
||||
// Use filter management hook
|
||||
const filterManagement = useFilterManagement<T>(data, fields, validationErrors);
|
||||
|
||||
// Disable global full-table revalidation on any data change.
|
||||
// Field-level validation now runs inside updateRow/validateRow, and per-column
|
||||
// uniqueness is handled surgically where needed.
|
||||
// Intentionally left blank to avoid UI lock-ups on small edits.
|
||||
useEffect(() => {
|
||||
return; // no-op
|
||||
}, [data, fields, hasEditingCells]);
|
||||
|
||||
// Add field options query
|
||||
const { data: fieldOptionsData } = useQuery({
|
||||
queryKey: ["import-field-options"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/import/field-options`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch field options");
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
|
||||
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
|
||||
});
|
||||
|
||||
// Get display text for a template
|
||||
const getTemplateDisplayText = useCallback(
|
||||
(templateId: string | null) => {
|
||||
if (!templateId) return "Select a template";
|
||||
|
||||
const template = templateManagement.templates.find((t) => t.id.toString() === templateId);
|
||||
if (!template) return "Unknown template";
|
||||
|
||||
try {
|
||||
const companyId = template.company || "";
|
||||
const productType = template.product_type || "Unknown Type";
|
||||
|
||||
// Find company name from field options
|
||||
const companyName =
|
||||
fieldOptionsData?.companies?.find(
|
||||
(c: { value: string; label: string }) => c.value === companyId
|
||||
)?.label || companyId;
|
||||
|
||||
return `${companyName} - ${productType}`;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error formatting template display text:",
|
||||
error,
|
||||
template
|
||||
);
|
||||
return "Error displaying template";
|
||||
}
|
||||
},
|
||||
[templateManagement.templates, fieldOptionsData]
|
||||
);
|
||||
|
||||
// Check if there are any errors
|
||||
const hasErrors = useMemo(() => {
|
||||
for (const [_, status] of rowValidationStatus.entries()) {
|
||||
if (status === "error") return true;
|
||||
}
|
||||
return false;
|
||||
}, [rowValidationStatus]);
|
||||
|
||||
// Create a function to handle button clicks (continue or back)
|
||||
const handleButtonClick = useCallback(
|
||||
async (direction: "next" | "back") => {
|
||||
if (direction === "back" && onBack) {
|
||||
// If a specific action is defined for back, use it
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (direction === "next") {
|
||||
// When proceeding to the next screen, check for unvalidated rows first
|
||||
const hasErrors = [...validationErrors.entries()].some(
|
||||
([_, errors]) => {
|
||||
return Object.values(errors).some((errorSet) =>
|
||||
errorSet.some((error) => error.type !== ErrorType.Required)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (hasErrors) {
|
||||
// We have validation errors - ask the user to fix them first or continue anyway
|
||||
const shouldContinue = window.confirm(
|
||||
"There are validation errors in your data. Do you want to continue anyway?"
|
||||
);
|
||||
|
||||
if (!shouldContinue) {
|
||||
// User chose to fix errors
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the data for the next step
|
||||
try {
|
||||
// No toast here - unnecessary and distracting
|
||||
|
||||
// Call onNext with the cleaned data
|
||||
if (onNext) {
|
||||
// Remove metadata fields before passing to onNext
|
||||
const cleanedData = data.map((row) => {
|
||||
const {
|
||||
__index,
|
||||
__template,
|
||||
__original,
|
||||
__corrected,
|
||||
__changes,
|
||||
__aiSupplemental: _aiSupplemental,
|
||||
...cleanRow
|
||||
} = row;
|
||||
return cleanRow as any;
|
||||
});
|
||||
|
||||
onNext(cleanedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error proceeding to next step:", error);
|
||||
toast.error("Error saving data");
|
||||
}
|
||||
}
|
||||
},
|
||||
[data, onBack, onNext, validationErrors]
|
||||
);
|
||||
const { isValidating: isInitialValidationRunning } = useInitialValidation<T>({
|
||||
data,
|
||||
fields,
|
||||
setData,
|
||||
setValidationErrors,
|
||||
validateUniqueItemNumbers,
|
||||
upcValidationComplete: upcValidation.initialValidationDone,
|
||||
onComplete: () => {
|
||||
setInitialValidationComplete(true);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValidationComplete) return;
|
||||
if ((!data || data.length === 0) && upcValidation.initialValidationDone) {
|
||||
setInitialValidationComplete(true);
|
||||
}
|
||||
}, [data, initialValidationComplete, upcValidation.initialValidationDone]);
|
||||
|
||||
const hasPendingUpcValidation = upcValidation.validatingRows.size > 0;
|
||||
|
||||
// Separate initial validation from subsequent validations
|
||||
// isInitializing should ONLY be true during the first load, never again
|
||||
const isInitializing =
|
||||
!initialValidationComplete ||
|
||||
isInitialValidationRunning ||
|
||||
templateManagement.isLoadingTemplates ||
|
||||
(hasPendingUpcValidation && !upcValidation.initialValidationDone);
|
||||
|
||||
const isValidating = isInitialValidationRunning;
|
||||
|
||||
// Track initialization task statuses for the progress UI
|
||||
const initializationTasks = {
|
||||
upcValidation: {
|
||||
label: 'Generating item numbers',
|
||||
status: upcValidation.initialValidationDone ? 'completed' :
|
||||
hasPendingUpcValidation ? 'in_progress' : 'pending'
|
||||
},
|
||||
fieldValidation: {
|
||||
label: 'Checking for field errors',
|
||||
status: initialValidationComplete ? 'completed' :
|
||||
isInitialValidationRunning ? 'in_progress' : 'pending'
|
||||
},
|
||||
templateLoading: {
|
||||
label: 'Loading product templates',
|
||||
status: !templateManagement.isLoadingTemplates ? 'completed' :
|
||||
'in_progress'
|
||||
}
|
||||
};
|
||||
|
||||
// Targeted uniqueness revalidation: run only when item_number values change
|
||||
useEffect(() => {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
// Build a simple signature of the item_number column
|
||||
const sig = data.map((r) => String((r as Record<string, any>).item_number ?? '')).join('|');
|
||||
if (lastItemNumberSigRef.current === sig) return;
|
||||
lastItemNumberSigRef.current = sig;
|
||||
|
||||
// Compute unique errors for item_number only and merge
|
||||
const uniqueMap = validateUniqueField(data, 'item_number');
|
||||
const rowsWithUnique = new Set<number>();
|
||||
uniqueMap.forEach((_, idx) => rowsWithUnique.add(idx));
|
||||
|
||||
setValidationErrors((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
|
||||
// Apply unique errors
|
||||
uniqueMap.forEach((errorsForRow, rowIdx) => {
|
||||
const existing = { ...(newMap.get(rowIdx) || {}) } as Record<string, any[]>;
|
||||
const info = (errorsForRow as any)['item_number'];
|
||||
const currentValue = (data[rowIdx] as any)?.['item_number'];
|
||||
// Only apply uniqueness error when the value is non-empty
|
||||
if (info && currentValue !== undefined && currentValue !== null && String(currentValue) !== '') {
|
||||
existing['item_number'] = [
|
||||
{
|
||||
message: info.message,
|
||||
level: info.level,
|
||||
source: info.source,
|
||||
type: info.type,
|
||||
},
|
||||
];
|
||||
}
|
||||
// If value is now present, make sure to clear any lingering Required error
|
||||
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && existing['item_number']) {
|
||||
existing['item_number'] = (existing['item_number'] as any[]).filter((e) => e.type !== ErrorType.Required);
|
||||
if ((existing['item_number'] as any[]).length === 0) delete existing['item_number'];
|
||||
}
|
||||
if (Object.keys(existing).length > 0) newMap.set(rowIdx, existing);
|
||||
else newMap.delete(rowIdx);
|
||||
});
|
||||
|
||||
// Remove stale unique errors for rows no longer duplicated
|
||||
newMap.forEach((rowErrs, rowIdx) => {
|
||||
const currentValue = (data[rowIdx] as any)?.['item_number'];
|
||||
const shouldRemoveUnique = !rowsWithUnique.has(rowIdx) || currentValue === undefined || currentValue === null || String(currentValue) === '';
|
||||
if (shouldRemoveUnique && (rowErrs as any)['item_number']) {
|
||||
const filtered = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Unique);
|
||||
if (filtered.length > 0) (rowErrs as any)['item_number'] = filtered;
|
||||
else delete (rowErrs as any)['item_number'];
|
||||
}
|
||||
// If value now present, also clear any lingering Required error for this field
|
||||
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && (rowErrs as any)['item_number']) {
|
||||
const nonRequired = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Required);
|
||||
if (nonRequired.length > 0) (rowErrs as any)['item_number'] = nonRequired;
|
||||
else delete (rowErrs as any)['item_number'];
|
||||
}
|
||||
|
||||
if (Object.keys(rowErrs).length > 0) newMap.set(rowIdx, rowErrs);
|
||||
else newMap.delete(rowIdx);
|
||||
});
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}, [data, validateUniqueField, setValidationErrors]);
|
||||
|
||||
// Update fields with latest options
|
||||
const fieldsWithOptions = useMemo(() => {
|
||||
if (!fieldOptionsData) return fields;
|
||||
|
||||
return fields.map((field) => {
|
||||
// Skip fields that aren't select or multi-select
|
||||
if (
|
||||
typeof field.fieldType !== "object" ||
|
||||
(field.fieldType.type !== "select" &&
|
||||
field.fieldType.type !== "multi-select")
|
||||
) {
|
||||
return field;
|
||||
}
|
||||
|
||||
// Get the correct options based on field key
|
||||
let options = [];
|
||||
switch (field.key) {
|
||||
case "company":
|
||||
options = [...(fieldOptionsData.companies || [])];
|
||||
break;
|
||||
case "supplier":
|
||||
options = [...(fieldOptionsData.suppliers || [])];
|
||||
break;
|
||||
case "categories":
|
||||
options = [...(fieldOptionsData.categories || [])];
|
||||
break;
|
||||
case "themes":
|
||||
options = [...(fieldOptionsData.themes || [])];
|
||||
break;
|
||||
case "colors":
|
||||
options = [...(fieldOptionsData.colors || [])];
|
||||
break;
|
||||
case "tax_cat":
|
||||
options = [...(fieldOptionsData.taxCategories || [])];
|
||||
// Ensure tax_cat is always a select, not multi-select
|
||||
return {
|
||||
...field,
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options,
|
||||
},
|
||||
};
|
||||
case "ship_restrictions":
|
||||
options = [...(fieldOptionsData.shippingRestrictions || [])];
|
||||
break;
|
||||
case "artist":
|
||||
options = [...(fieldOptionsData.artists || [])];
|
||||
break;
|
||||
case "size_cat":
|
||||
options = [...(fieldOptionsData.sizes || [])];
|
||||
break;
|
||||
default:
|
||||
options = [...(field.fieldType.options || [])];
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
fieldType: {
|
||||
...field.fieldType,
|
||||
options,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [fields, fieldOptionsData]);
|
||||
|
||||
// Load templates on mount
|
||||
useEffect(() => {
|
||||
templateManagement.loadTemplates();
|
||||
}, [templateManagement.loadTemplates]);
|
||||
|
||||
return {
|
||||
// Data
|
||||
data,
|
||||
setData,
|
||||
filteredData: filterManagement.filteredData,
|
||||
|
||||
// Validation
|
||||
isValidating,
|
||||
isInitializing,
|
||||
initializationTasks,
|
||||
validationErrors,
|
||||
rowValidationStatus,
|
||||
validateRow,
|
||||
hasErrors,
|
||||
|
||||
// CRITICAL: Export validatingCells to make it available to ValidationContainer
|
||||
validatingCells,
|
||||
setValidatingCells,
|
||||
|
||||
// PERFORMANCE FIX: Export editing state management
|
||||
editingCells,
|
||||
setEditingCells,
|
||||
|
||||
// Row selection
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
|
||||
// Row manipulation
|
||||
updateRow,
|
||||
copyDown,
|
||||
|
||||
// Templates
|
||||
templates: templateManagement.templates,
|
||||
isLoadingTemplates: templateManagement.isLoadingTemplates,
|
||||
selectedTemplateId: templateManagement.templateState.selectedTemplateId,
|
||||
showSaveTemplateDialog: templateManagement.templateState.showSaveTemplateDialog,
|
||||
newTemplateName: templateManagement.templateState.newTemplateName,
|
||||
newTemplateType: templateManagement.templateState.newTemplateType,
|
||||
setTemplateState: templateManagement.setTemplateState,
|
||||
templateState: templateManagement.templateState,
|
||||
loadTemplates: templateManagement.loadTemplates,
|
||||
saveTemplate: templateManagement.saveTemplate,
|
||||
applyTemplate: templateManagement.applyTemplate,
|
||||
applyTemplateToSelected: templateManagement.applyTemplateToSelected,
|
||||
getTemplateDisplayText,
|
||||
refreshTemplates: templateManagement.refreshTemplates,
|
||||
|
||||
// UPC validation
|
||||
upcValidation,
|
||||
|
||||
// Filters
|
||||
filters: filterManagement.filters,
|
||||
filterFields: filterManagement.filterFields,
|
||||
filterValues: filterManagement.filterValues,
|
||||
updateFilters: filterManagement.updateFilters,
|
||||
resetFilters: filterManagement.resetFilters,
|
||||
|
||||
// Fields reference
|
||||
fields: fieldsWithOptions, // Return updated fields with options
|
||||
|
||||
// Hooks
|
||||
rowHook,
|
||||
tableHook,
|
||||
|
||||
// Button handling
|
||||
handleButtonClick,
|
||||
revalidateRows,
|
||||
};
|
||||
};
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
import type { Data } from "../../../types";
|
||||
import { ErrorSources, ErrorType } from "../../../types";
|
||||
import config from "@/config";
|
||||
|
||||
// Define the Props interface for ValidationStepNew
|
||||
export interface Props<T extends string> {
|
||||
initialData: RowData<T>[];
|
||||
file?: File;
|
||||
onBack?: () => void;
|
||||
onNext?: (data: RowData<T>[]) => void;
|
||||
isFromScratch?: boolean;
|
||||
}
|
||||
|
||||
// Extended Data type with meta information
|
||||
export type RowData<T extends string> = Data<T> & {
|
||||
__index?: string;
|
||||
__template?: string;
|
||||
__original?: Record<string, any>;
|
||||
__corrected?: Record<string, any>;
|
||||
__changes?: Record<string, boolean>;
|
||||
upc?: string;
|
||||
barcode?: string;
|
||||
supplier?: string;
|
||||
company?: string;
|
||||
item_number?: string;
|
||||
[key: string]: any; // Allow any string key for dynamic fields
|
||||
};
|
||||
|
||||
// Template interface
|
||||
export interface Template {
|
||||
id: number;
|
||||
company: string;
|
||||
product_type: string;
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
// Props for the useValidationState hook
|
||||
export interface ValidationStateProps<T extends string> extends Props<T> {}
|
||||
|
||||
// Interface for validation results
|
||||
export interface ValidationResult {
|
||||
error?: boolean;
|
||||
message?: string;
|
||||
data?: Record<string, any>;
|
||||
type?: ErrorType;
|
||||
source?: ErrorSources;
|
||||
}
|
||||
|
||||
// Filter state interface
|
||||
export interface FilterState {
|
||||
searchText: string;
|
||||
showErrorsOnly: boolean;
|
||||
filterField: string | null;
|
||||
filterValue: string | null;
|
||||
}
|
||||
|
||||
// UI validation state interface for useUpcValidation
|
||||
export interface ValidationState {
|
||||
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
|
||||
itemNumbers: Map<number, string>; // Using rowIndex as key
|
||||
validatingRows: Set<number>; // Rows currently being validated
|
||||
activeValidations: Set<string>; // Active validations
|
||||
}
|
||||
|
||||
// InfoWithSource interface for validation errors
|
||||
export interface InfoWithSource {
|
||||
message: string;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
source: ErrorSources;
|
||||
type: ErrorType;
|
||||
}
|
||||
|
||||
// Template state interface
|
||||
export interface TemplateState {
|
||||
selectedTemplateId: string | null;
|
||||
showSaveTemplateDialog: boolean;
|
||||
newTemplateName: string;
|
||||
newTemplateType: string;
|
||||
}
|
||||
|
||||
// Add config at the top of the file
|
||||
// Import the config or access it through window
|
||||
declare global {
|
||||
interface Window {
|
||||
config?: {
|
||||
apiUrl: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use a helper to get API URL consistently
|
||||
export const getApiUrl = () => config.apiUrl;
|
||||
|
||||
// Shared utility function for checking empty values
|
||||
export const isEmpty = (value: any): boolean =>
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
|
||||
@@ -1,28 +0,0 @@
|
||||
import ValidationContainer from './components/ValidationContainer'
|
||||
import { Props } from './hooks/validationTypes'
|
||||
|
||||
/**
|
||||
* ValidationStepNew component - modern implementation of the validation step
|
||||
*
|
||||
* This component is a refactored version of the original ValidationStep component
|
||||
* with improved architecture, performance and maintainability.
|
||||
*/
|
||||
export const ValidationStepNew = <T extends string>({
|
||||
initialData,
|
||||
file,
|
||||
onBack,
|
||||
onNext,
|
||||
isFromScratch
|
||||
}: Props<T>) => {
|
||||
return (
|
||||
<ValidationContainer<T>
|
||||
initialData={initialData}
|
||||
file={file}
|
||||
onBack={onBack}
|
||||
onNext={onNext}
|
||||
isFromScratch={isFromScratch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ValidationStepNew
|
||||
@@ -1,5 +0,0 @@
|
||||
import { InfoWithSource } from "../../types"
|
||||
|
||||
export type Meta = { __index: string }
|
||||
export type Error = { [key: string]: InfoWithSource }
|
||||
export type Errors = { [id: string]: Error }
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ErrorLevel, ErrorSources, ErrorType as ValidationErrorType } from "../../../types"
|
||||
|
||||
// Define our own Error type that's compatible with the original
|
||||
export interface ErrorType {
|
||||
message: string;
|
||||
level: ErrorLevel;
|
||||
source?: ErrorSources;
|
||||
type: ValidationErrorType;
|
||||
}
|
||||
|
||||
// Export a namespace to make it accessible at runtime
|
||||
export const ErrorTypes = {
|
||||
createError: (message: string, level: ErrorLevel = 'error', source: ErrorSources = ErrorSources.Row, type: ValidationErrorType = ValidationErrorType.Custom): ErrorType => {
|
||||
return { message, level, source, type };
|
||||
}
|
||||
};
|
||||
|
||||
// Type for a collection of errors
|
||||
export interface Errors { [id: string]: ErrorType[] }
|
||||
|
||||
// Make our Meta type match the original for compatibility
|
||||
export interface Meta {
|
||||
__index?: string;
|
||||
}
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* AI Validation utility functions
|
||||
*
|
||||
* Helper functions for processing AI validation data and managing progress
|
||||
*/
|
||||
|
||||
import type { Fields } from '@/components/product-import/types';
|
||||
|
||||
/**
|
||||
* Clean data for AI validation by including all fields
|
||||
*
|
||||
* Ensures every field is present in the data sent to the API,
|
||||
* converting undefined values to empty strings
|
||||
*/
|
||||
export function prepareDataForAiValidation<T extends string>(
|
||||
data: any[],
|
||||
fields: Fields<T>
|
||||
): Record<string, any>[] {
|
||||
return data.map(item => {
|
||||
const { __index, __aiSupplemental, ...rest } = item as Record<string, any>;
|
||||
const withAllKeys: Record<string, any> = {};
|
||||
|
||||
fields.forEach((f) => {
|
||||
const k = String(f.key);
|
||||
if (Array.isArray(rest[k])) {
|
||||
withAllKeys[k] = rest[k];
|
||||
} else if (rest[k] === undefined) {
|
||||
withAllKeys[k] = "";
|
||||
} else {
|
||||
withAllKeys[k] = rest[k];
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof __aiSupplemental === 'object' && __aiSupplemental !== null) {
|
||||
withAllKeys.aiSupplementalInfo = __aiSupplemental;
|
||||
}
|
||||
|
||||
return withAllKeys;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process AI-corrected data to handle multi-select and select fields
|
||||
*
|
||||
* Converts comma-separated strings to arrays for multi-select fields
|
||||
* and handles label-to-value conversions for select fields
|
||||
*/
|
||||
export function processAiCorrectedData<T extends string>(
|
||||
correctedData: any[],
|
||||
originalData: any[],
|
||||
fields: Fields<T>
|
||||
): any[] {
|
||||
return correctedData.map((corrected: any, index: number) => {
|
||||
// Start with original data to preserve metadata like __index
|
||||
const original = originalData[index] || {};
|
||||
const processed = { ...original, ...corrected };
|
||||
|
||||
// Process each field according to its type
|
||||
Object.keys(processed).forEach(key => {
|
||||
if (key.startsWith('__')) return; // Skip metadata fields
|
||||
|
||||
const fieldConfig = fields.find(f => String(f.key) === key);
|
||||
if (!fieldConfig) return;
|
||||
|
||||
// Handle multi-select fields (comma-separated values → array)
|
||||
if (fieldConfig?.fieldType.type === 'multi-select' && typeof processed[key] === 'string') {
|
||||
processed[key] = processed[key]
|
||||
.split(',')
|
||||
.map((v: string) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
});
|
||||
|
||||
return processed;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress percentage based on elapsed time and estimates
|
||||
*
|
||||
* @param step - Current step number (1-5)
|
||||
* @param elapsedSeconds - Time elapsed since start
|
||||
* @param estimatedSeconds - Estimated total time (optional)
|
||||
* @returns Progress percentage (0-95, never reaches 100 until complete)
|
||||
*/
|
||||
export function calculateProgressPercent(
|
||||
step: number,
|
||||
elapsedSeconds: number,
|
||||
estimatedSeconds?: number
|
||||
): number {
|
||||
if (estimatedSeconds && estimatedSeconds > 0) {
|
||||
// Time-based progress
|
||||
return Math.min(95, (elapsedSeconds / estimatedSeconds) * 100);
|
||||
}
|
||||
|
||||
// Step-based progress with time adjustment
|
||||
const baseProgress = (step / 5) * 100;
|
||||
const timeAdjustment = step === 1 ? Math.min(20, elapsedSeconds * 0.5) : 0;
|
||||
return Math.min(95, baseProgress + timeAdjustment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base status message by removing time information
|
||||
*
|
||||
* Removes patterns like "(5s remaining)" or "(1m 30s elapsed)"
|
||||
*/
|
||||
export function extractBaseStatus(status: string): string {
|
||||
return status
|
||||
.replace(/\s\(\d+[ms].+\)$/, '')
|
||||
.replace(/\s\(\d+m \d+s.+\)$/, '');
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Country code normalization utilities
|
||||
*
|
||||
* Converts various country code formats and country names to ISO 3166-1 alpha-2 codes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalizes country codes and names to ISO 3166-1 alpha-2 format (2-letter codes)
|
||||
*
|
||||
* Supports:
|
||||
* - ISO 3166-1 alpha-2 codes (e.g., "US", "GB")
|
||||
* - ISO 3166-1 alpha-3 codes (e.g., "USA", "GBR")
|
||||
* - Common country names (e.g., "United States", "China")
|
||||
*
|
||||
* @param input - Country code or name to normalize
|
||||
* @returns ISO 3166-1 alpha-2 code or null if not recognized
|
||||
*
|
||||
* @example
|
||||
* normalizeCountryCode("USA") // "US"
|
||||
* normalizeCountryCode("United States") // "US"
|
||||
* normalizeCountryCode("US") // "US"
|
||||
* normalizeCountryCode("invalid") // null
|
||||
*/
|
||||
export function normalizeCountryCode(input: string): string | null {
|
||||
if (!input) return null;
|
||||
|
||||
const s = input.trim();
|
||||
const upper = s.toUpperCase();
|
||||
|
||||
// Already in ISO 3166-1 alpha-2 format
|
||||
if (/^[A-Z]{2}$/.test(upper)) return upper;
|
||||
|
||||
// ISO 3166-1 alpha-3 to alpha-2 mapping
|
||||
const iso3to2: Record<string, string> = {
|
||||
USA: "US", GBR: "GB", UK: "GB", CHN: "CN", DEU: "DE", FRA: "FR", ITA: "IT", ESP: "ES",
|
||||
CAN: "CA", MEX: "MX", AUS: "AU", NZL: "NZ", JPN: "JP", KOR: "KR", PRK: "KP", TWN: "TW",
|
||||
VNM: "VN", THA: "TH", IDN: "ID", IND: "IN", BRA: "BR", ARG: "AR", CHL: "CL", PER: "PE",
|
||||
ZAF: "ZA", RUS: "RU", UKR: "UA", NLD: "NL", BEL: "BE", CHE: "CH", SWE: "SE", NOR: "NO",
|
||||
DNK: "DK", POL: "PL", AUT: "AT", PRT: "PT", GRC: "GR", CZE: "CZ", HUN: "HU", IRL: "IE",
|
||||
ISR: "IL", PAK: "PK", BGD: "BD", PHL: "PH", MYS: "MY", SGP: "SG", HKG: "HK", MAC: "MO"
|
||||
};
|
||||
|
||||
if (iso3to2[upper]) return iso3to2[upper];
|
||||
|
||||
// Country name to ISO 3166-1 alpha-2 mapping
|
||||
const nameMap: Record<string, string> = {
|
||||
"UNITED STATES": "US", "UNITED STATES OF AMERICA": "US", "AMERICA": "US", "U.S.": "US", "U.S.A": "US", "USA": "US",
|
||||
"UNITED KINGDOM": "GB", "UK": "GB", "GREAT BRITAIN": "GB", "ENGLAND": "GB",
|
||||
"CHINA": "CN", "PEOPLE'S REPUBLIC OF CHINA": "CN", "PRC": "CN",
|
||||
"CANADA": "CA", "MEXICO": "MX", "JAPAN": "JP", "SOUTH KOREA": "KR", "KOREA, REPUBLIC OF": "KR",
|
||||
"TAIWAN": "TW", "VIETNAM": "VN", "THAILAND": "TH", "INDONESIA": "ID", "INDIA": "IN",
|
||||
"GERMANY": "DE", "FRANCE": "FR", "ITALY": "IT", "SPAIN": "ES", "NETHERLANDS": "NL", "BELGIUM": "BE",
|
||||
"SWITZERLAND": "CH", "SWEDEN": "SE", "NORWAY": "NO", "DENMARK": "DK", "POLAND": "PL", "AUSTRIA": "AT",
|
||||
"PORTUGAL": "PT", "GREECE": "GR", "CZECH REPUBLIC": "CZ", "CZECHIA": "CZ", "HUNGARY": "HU", "IRELAND": "IE",
|
||||
"RUSSIA": "RU", "UKRAINE": "UA", "AUSTRALIA": "AU", "NEW ZEALAND": "NZ",
|
||||
"BRAZIL": "BR", "ARGENTINA": "AR", "CHILE": "CL", "PERU": "PE", "SOUTH AFRICA": "ZA",
|
||||
"ISRAEL": "IL", "PAKISTAN": "PK", "BANGLADESH": "BD", "PHILIPPINES": "PH", "MALAYSIA": "MY", "SINGAPORE": "SG",
|
||||
"HONG KONG": "HK", "MACAU": "MO"
|
||||
};
|
||||
|
||||
// Normalize input: remove dots, trim, uppercase
|
||||
const normalizedName = s.replace(/\./g, "").trim().toUpperCase();
|
||||
if (nameMap[normalizedName]) return nameMap[normalizedName];
|
||||
|
||||
return null;
|
||||
}
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
import type { Data, Fields, Info, RowHook, TableHook } from "../../../types"
|
||||
import type { Meta, Errors } from "../types"
|
||||
import { v4 } from "uuid"
|
||||
import { ErrorSources, ErrorType } from "../../../types"
|
||||
|
||||
|
||||
type DataWithMeta<T extends string> = Data<T> & Meta & {
|
||||
__index?: string;
|
||||
}
|
||||
|
||||
export const addErrorsAndRunHooks = async <T extends string>(
|
||||
data: (Data<T> & Partial<Meta>)[],
|
||||
fields: Fields<T>,
|
||||
rowHook?: RowHook<T>,
|
||||
tableHook?: TableHook<T>,
|
||||
changedRowIndexes?: number[],
|
||||
): Promise<DataWithMeta<T>[]> => {
|
||||
const errors: Errors = {}
|
||||
|
||||
const addError = (source: ErrorSources, rowIndex: number, fieldKey: string, error: Info, type: ErrorType = ErrorType.Custom) => {
|
||||
errors[rowIndex] = {
|
||||
...errors[rowIndex],
|
||||
[fieldKey]: { ...error, source, type },
|
||||
}
|
||||
}
|
||||
|
||||
let processedData = [...data] as DataWithMeta<T>[]
|
||||
|
||||
if (tableHook) {
|
||||
const tableResults = await tableHook(processedData)
|
||||
processedData = tableResults.map((result, index) => ({
|
||||
...processedData[index],
|
||||
...result
|
||||
}))
|
||||
}
|
||||
|
||||
if (rowHook) {
|
||||
if (changedRowIndexes) {
|
||||
for (const index of changedRowIndexes) {
|
||||
const rowResult = await rowHook(processedData[index], index, processedData)
|
||||
processedData[index] = {
|
||||
...processedData[index],
|
||||
...rowResult
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const rowResults = await Promise.all(
|
||||
processedData.map(async (value, index) => {
|
||||
const result = await rowHook(value, index, processedData)
|
||||
return {
|
||||
...value,
|
||||
...result
|
||||
}
|
||||
})
|
||||
)
|
||||
processedData = rowResults
|
||||
}
|
||||
}
|
||||
|
||||
fields.forEach((field) => {
|
||||
const fieldKey = field.key as string
|
||||
field.validations?.forEach((validation) => {
|
||||
switch (validation.rule) {
|
||||
case "unique": {
|
||||
const values = processedData.map((entry) => {
|
||||
const value = entry[fieldKey as keyof typeof entry]
|
||||
return value
|
||||
})
|
||||
|
||||
const taken = new Set() // Set of items used at least once
|
||||
const duplicates = new Set() // Set of items used multiple times
|
||||
|
||||
values.forEach((value) => {
|
||||
if (validation.allowEmpty && !value) {
|
||||
// If allowEmpty is set, we will not validate falsy fields such as undefined or empty string.
|
||||
return
|
||||
}
|
||||
|
||||
if (taken.has(value)) {
|
||||
duplicates.add(value)
|
||||
} else {
|
||||
taken.add(value)
|
||||
}
|
||||
})
|
||||
|
||||
values.forEach((value, index) => {
|
||||
if (duplicates.has(value)) {
|
||||
addError(ErrorSources.Table, index, fieldKey, {
|
||||
level: validation.level || "error",
|
||||
message: validation.errorMessage || "Field must be unique",
|
||||
}, ErrorType.Unique)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case "required": {
|
||||
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
|
||||
dataToValidate.forEach((entry, index) => {
|
||||
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
|
||||
const value = entry[fieldKey as keyof typeof entry]
|
||||
if (value === null || value === undefined || value === "") {
|
||||
addError(ErrorSources.Row, realIndex, fieldKey, {
|
||||
level: validation.level || "error",
|
||||
message: validation.errorMessage || "Field is required",
|
||||
}, ErrorType.Required)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case "regex": {
|
||||
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
|
||||
const regex = new RegExp(validation.value, validation.flags)
|
||||
dataToValidate.forEach((entry, index) => {
|
||||
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
|
||||
const value = entry[fieldKey as keyof typeof entry]
|
||||
const stringValue = value?.toString() ?? ""
|
||||
if (!stringValue.match(regex)) {
|
||||
addError(ErrorSources.Row, realIndex, fieldKey, {
|
||||
level: validation.level || "error",
|
||||
message:
|
||||
validation.errorMessage || `Field did not match the regex /${validation.value}/${validation.flags} `,
|
||||
}, ErrorType.Regex)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return processedData.map((value) => {
|
||||
// This is required only for table. Mutates to prevent needless rerenders
|
||||
const result: DataWithMeta<T> = { ...value }
|
||||
if (!result.__index) {
|
||||
result.__index = v4()
|
||||
}
|
||||
|
||||
// We no longer store errors in the row data
|
||||
// The errors are now only stored in the validationErrors Map
|
||||
return result
|
||||
})
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Price field cleaning and formatting utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cleans a price field by removing currency symbols and formatting to 2 decimal places
|
||||
*
|
||||
* - Removes dollar signs ($) and commas (,)
|
||||
* - Converts to number and formats with 2 decimal places
|
||||
* - Returns original value if conversion fails
|
||||
*
|
||||
* @param value - Price value to clean (string or number)
|
||||
* @returns Cleaned price string formatted to 2 decimals, or original value if invalid
|
||||
*
|
||||
* @example
|
||||
* cleanPriceField("$1,234.56") // "1234.56"
|
||||
* cleanPriceField("$99.9") // "99.90"
|
||||
* cleanPriceField(123.456) // "123.46"
|
||||
* cleanPriceField("invalid") // "invalid"
|
||||
*/
|
||||
export function cleanPriceField(value: string | number): string {
|
||||
if (typeof value === "string") {
|
||||
const cleaned = value.replace(/[\s$,]/g, "");
|
||||
const numValue = parseFloat(cleaned);
|
||||
if (!isNaN(numValue)) {
|
||||
return numValue.toFixed(2);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans multiple price fields in a data object
|
||||
*
|
||||
* @param data - Object containing price fields
|
||||
* @param priceFields - Array of field keys to clean
|
||||
* @returns New object with cleaned price fields
|
||||
*
|
||||
* @example
|
||||
* cleanPriceFields({ msrp: "$99.99", cost_each: "$50.00" }, ["msrp", "cost_each"])
|
||||
* // { msrp: "99.99", cost_each: "50.00" }
|
||||
*/
|
||||
export function cleanPriceFields<T extends Record<string, any>>(
|
||||
data: T,
|
||||
priceFields: (keyof T)[]
|
||||
): T {
|
||||
const cleaned = { ...data };
|
||||
|
||||
for (const field of priceFields) {
|
||||
if (cleaned[field] !== undefined && cleaned[field] !== null) {
|
||||
cleaned[field] = cleanPriceField(cleaned[field]) as any;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
const NUMERIC_REGEX = /^\d+$/;
|
||||
|
||||
export function calculateUpcCheckDigit(upcBody: string): number {
|
||||
if (!NUMERIC_REGEX.test(upcBody) || upcBody.length !== 11) {
|
||||
throw new Error('UPC body must be 11 numeric characters');
|
||||
}
|
||||
|
||||
const digits = upcBody.split('').map((d) => Number.parseInt(d, 10));
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < digits.length; i += 1) {
|
||||
sum += (i % 2 === 0 ? digits[i] * 3 : digits[i]);
|
||||
}
|
||||
|
||||
const mod = sum % 10;
|
||||
return mod === 0 ? 0 : 10 - mod;
|
||||
}
|
||||
|
||||
export function calculateEanCheckDigit(eanBody: string): number {
|
||||
if (!NUMERIC_REGEX.test(eanBody) || eanBody.length !== 12) {
|
||||
throw new Error('EAN body must be 12 numeric characters');
|
||||
}
|
||||
|
||||
const digits = eanBody.split('').map((d) => Number.parseInt(d, 10));
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < digits.length; i += 1) {
|
||||
sum += (i % 2 === 0 ? digits[i] : digits[i] * 3);
|
||||
}
|
||||
|
||||
const mod = sum % 10;
|
||||
return mod === 0 ? 0 : 10 - mod;
|
||||
}
|
||||
|
||||
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
|
||||
const value = rawValue ?? '';
|
||||
const str = typeof value === 'string' ? value.trim() : String(value);
|
||||
|
||||
if (str === '' || !NUMERIC_REGEX.test(str)) {
|
||||
return { corrected: str, changed: false };
|
||||
}
|
||||
|
||||
if (str.length === 11) {
|
||||
const check = calculateUpcCheckDigit(str);
|
||||
return { corrected: `${str}${check}`, changed: true };
|
||||
}
|
||||
|
||||
if (str.length === 12) {
|
||||
const body = str.slice(0, 11);
|
||||
const check = calculateUpcCheckDigit(body);
|
||||
const corrected = `${body}${check}`;
|
||||
return { corrected, changed: corrected !== str };
|
||||
}
|
||||
|
||||
if (str.length === 13) {
|
||||
const body = str.slice(0, 12);
|
||||
const check = calculateEanCheckDigit(body);
|
||||
const corrected = `${body}${check}`;
|
||||
return { corrected, changed: corrected !== str };
|
||||
}
|
||||
|
||||
return { corrected: str, changed: false };
|
||||
}
|
||||
@@ -2,11 +2,11 @@ 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',
|
||||
'Reorder Soon': 'bg-yellow-400 text-black border-transparent',
|
||||
'Healthy': 'bg-green-600 text-white border-transparent',
|
||||
'Overstock': 'bg-blue-600 text-white border-secondary',
|
||||
'Overstock': 'bg-teal-700 text-white border-transparent',
|
||||
'At Risk': 'border-orange-500 text-orange-600',
|
||||
'New': 'bg-purple-600 text-white border-transparent',
|
||||
'New': 'bg-green-600 text-white border-transparent',
|
||||
'Unknown': 'bg-muted text-muted-foreground border-transparent',
|
||||
};
|
||||
|
||||
|
||||
@@ -484,7 +484,7 @@ function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PayloadSection label="Request Payload" data={detail.request_payload} />
|
||||
<RequestPayloadSection payload={detail.request_payload} />
|
||||
|
||||
{detail.response_payload != null && (
|
||||
<PayloadSection label="Response Payload" data={detail.response_payload} />
|
||||
@@ -493,6 +493,36 @@ function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType
|
||||
);
|
||||
}
|
||||
|
||||
/** Keys in request_payload that were NOT sent to the API — stored for audit context only */
|
||||
const CONTEXT_ONLY_KEYS = new Set(["previous_values", "previous_ids"]);
|
||||
|
||||
function RequestPayloadSection({ payload }: { payload: unknown }) {
|
||||
const parsed = typeof payload === "string" ? (() => { try { return JSON.parse(payload); } catch { return payload; } })() : payload;
|
||||
|
||||
// Extract context-only keys from the payload
|
||||
let apiPayload = parsed;
|
||||
let contextData: Record<string, unknown> | null = null;
|
||||
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const contextEntries = Object.entries(obj).filter(([k]) => CONTEXT_ONLY_KEYS.has(k));
|
||||
if (contextEntries.length > 0) {
|
||||
const remaining = Object.fromEntries(Object.entries(obj).filter(([k]) => !CONTEXT_ONLY_KEYS.has(k)));
|
||||
apiPayload = remaining;
|
||||
contextData = Object.fromEntries(contextEntries);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PayloadSection label="Request Payload" data={apiPayload} />
|
||||
{contextData && (
|
||||
<PayloadSection label="Previous Values" data={contextData} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Formatted JSON viewer ---
|
||||
|
||||
/** Unwrap double-encoded JSON strings from JSONB columns */
|
||||
@@ -689,6 +719,18 @@ function JsonArray({ items, depth }: { items: unknown[]; depth: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Arrays of objects — render each item as a collapsible block
|
||||
const hasObjects = items.some((v) => v !== null && typeof v === "object");
|
||||
if (hasObjects) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{items.map((item, i) => (
|
||||
<JsonArrayItem key={i} index={i} value={item} depth={depth} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{items.map((item, i) => (
|
||||
@@ -700,3 +742,34 @@ function JsonArray({ items, depth }: { items: unknown[]; depth: number }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonArrayItem({ index, value, depth }: { index: number; value: unknown; depth: number }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const isObject = value !== null && typeof value === "object";
|
||||
|
||||
if (!isObject) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-muted-foreground mr-1">[{index}]</span>
|
||||
<JsonValue value={value} depth={depth + 1} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3 w-3 text-muted-foreground" /> : <ChevronRightIcon className="h-3 w-3 text-muted-foreground" />}
|
||||
<span className="text-muted-foreground font-medium">[{index}]</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ml-4 border-l border-border pl-3 mt-1">
|
||||
<JsonValue value={value} depth={depth + 1} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -241,10 +241,13 @@ export function TemplateForm({
|
||||
} catch (error: any) {
|
||||
console.error('Error saving template:', error);
|
||||
console.error('Error response:', error.response?.data);
|
||||
const serverMessage = error.response?.data?.error;
|
||||
toast.error(
|
||||
'Failed to save template',
|
||||
error.response?.status === 409
|
||||
? 'Duplicate template'
|
||||
: 'Failed to save template',
|
||||
{
|
||||
description: error.response?.data?.message || error.message || 'An unknown error occurred'
|
||||
description: serverMessage || error.message || 'An unknown error occurred'
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
|
||||
+407
-131
@@ -14,33 +14,59 @@ import {
|
||||
Header,
|
||||
HeaderGroup,
|
||||
} from "@tanstack/react-table";
|
||||
import { columns, ForecastItem, renderSubComponent } from "@/components/forecasting/columns";
|
||||
import {
|
||||
ProductDetail,
|
||||
LineGroup,
|
||||
CategoryGroup,
|
||||
DesignerGroup,
|
||||
lineColumns,
|
||||
categoryColumns,
|
||||
designerColumns,
|
||||
LineSubComponent,
|
||||
CategorySubComponent,
|
||||
DesignerSubComponent,
|
||||
groupByLine,
|
||||
groupByCategory,
|
||||
groupByDesigner,
|
||||
computeCrossLineAverages,
|
||||
} from "@/components/forecasting/columns";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { addDays, addMonths } from "date-fns";
|
||||
import { DateRangePickerQuick } from "@/components/forecasting/DateRangePickerQuick";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X } from "lucide-react";
|
||||
import { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { QuickOrderBuilder } from "@/components/forecasting/QuickOrderBuilder";
|
||||
|
||||
type GroupMode = "line" | "category" | "designer";
|
||||
|
||||
const FILTERS_KEY = "forecastingFilters";
|
||||
|
||||
const fmt = (v: number, decimals = 0) =>
|
||||
v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
|
||||
export default function Forecasting() {
|
||||
const [selectedBrand, setSelectedBrand] = useState<string>("");
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
from: addDays(addMonths(new Date(), -1), 1),
|
||||
from: addDays(addMonths(new Date(), -6), 1),
|
||||
to: new Date(),
|
||||
});
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [groupMode, setGroupMode] = useState<GroupMode>("line");
|
||||
const [selectedArtist, setSelectedArtist] = useState<string>("all");
|
||||
const [lineSorting, setLineSorting] = useState<SortingState>([]);
|
||||
const [catSorting, setCatSorting] = useState<SortingState>([]);
|
||||
const [designerSorting, setDesignerSorting] = useState<SortingState>([]);
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const FILTERS_KEY = "forecastingFilters";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Restore saved brand and date range on first mount
|
||||
// Restore saved filters on first mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(FILTERS_KEY);
|
||||
if (!raw) return;
|
||||
const saved = JSON.parse(raw);
|
||||
if (typeof saved.brand === 'string') setSelectedBrand(saved.brand);
|
||||
if (typeof saved.brand === "string") setSelectedBrand(saved.brand);
|
||||
if (saved.from && saved.to) {
|
||||
const from = new Date(saved.from);
|
||||
const to = new Date(saved.to);
|
||||
@@ -48,148 +74,180 @@ export default function Forecasting() {
|
||||
setDateRange({ from, to });
|
||||
}
|
||||
}
|
||||
// Force a refetch once state settles
|
||||
if (saved.groupMode === "line" || saved.groupMode === "category" || saved.groupMode === "designer") {
|
||||
setGroupMode(saved.groupMode);
|
||||
}
|
||||
if (typeof saved.artist === "string") setSelectedArtist(saved.artist);
|
||||
setTimeout(() => {
|
||||
try { queryClient.invalidateQueries({ queryKey: ["forecast"] }); } catch {}
|
||||
try {
|
||||
queryClient.invalidateQueries({ queryKey: ["forecast-v2"] });
|
||||
} catch {}
|
||||
}, 0);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
// Persist brand and date range
|
||||
// Persist filters
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
FILTERS_KEY,
|
||||
JSON.stringify({ brand: selectedBrand, from: dateRange.from?.toISOString(), to: dateRange.to?.toISOString() })
|
||||
JSON.stringify({
|
||||
brand: selectedBrand,
|
||||
from: dateRange.from?.toISOString(),
|
||||
to: dateRange.to?.toISOString(),
|
||||
groupMode,
|
||||
artist: selectedArtist,
|
||||
})
|
||||
);
|
||||
} catch {}
|
||||
}, [selectedBrand, dateRange]);
|
||||
|
||||
}, [selectedBrand, dateRange, groupMode, selectedArtist]);
|
||||
|
||||
const handleDateRangeChange = (range: DateRange | undefined) => {
|
||||
if (range) {
|
||||
setDateRange(range);
|
||||
}
|
||||
if (range) setDateRange(range);
|
||||
};
|
||||
|
||||
// ─── Data fetching ──────────────────────────────────────────────────────
|
||||
|
||||
const { data: brands = [], isLoading: brandsLoading } = useQuery({
|
||||
queryKey: ["brands"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/products/brands");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch brands");
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to fetch brands");
|
||||
const data = await response.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: forecastData, isLoading: forecastLoading } = useQuery({
|
||||
queryKey: ["forecast", selectedBrand, dateRange],
|
||||
const { data: rawData, isLoading: dataLoading } = useQuery<{
|
||||
products: ProductDetail[];
|
||||
artists: string[];
|
||||
}>({
|
||||
queryKey: ["forecast-v2", selectedBrand, dateRange],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
brand: selectedBrand,
|
||||
startDate: dateRange.from?.toISOString() || "",
|
||||
endDate: dateRange.to?.toISOString() || "",
|
||||
});
|
||||
const response = await fetch(`/api/analytics/forecast?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch forecast data");
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.map((item: any) => ({
|
||||
category: item.category_name,
|
||||
categoryPath: item.path,
|
||||
totalSold: Number(item.total_sold) || 0,
|
||||
numProducts: Number(item.num_products) || 0,
|
||||
avgTotalSold: Number(item.avgTotalSold) || 0,
|
||||
minSold: Number(item.minSold) || 0,
|
||||
maxSold: Number(item.maxSold) || 0,
|
||||
products: item.products?.map((p: any) => ({
|
||||
pid: p.pid,
|
||||
title: p.title,
|
||||
sku: p.sku,
|
||||
total_sold: Number(p.total_sold) || 0,
|
||||
categoryPath: item.path
|
||||
}))
|
||||
}));
|
||||
const response = await fetch(`/api/analytics/forecast-v2?${params}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch forecast data");
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!selectedBrand && !!dateRange.from && !!dateRange.to,
|
||||
});
|
||||
|
||||
// Local, instant filter + summary for title substring matches within category groups
|
||||
// ─── Client-side filtering & grouping ───────────────────────────────────
|
||||
|
||||
type ProductLite = { pid: string; title: string; sku: string; total_sold: number; categoryPath: string };
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (!rawData?.products) return [];
|
||||
let products = rawData.products;
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
if (!forecastData) return [] as ForecastItem[];
|
||||
// Artist filter (skip when in designer mode — show all designers)
|
||||
if (selectedArtist !== "all" && groupMode !== "designer") {
|
||||
products = products.filter((p) => p.artist === selectedArtist);
|
||||
}
|
||||
|
||||
// Title search
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) return forecastData;
|
||||
|
||||
const filteredGroups: ForecastItem[] = [];
|
||||
const allMatchedProducts: ProductLite[] = [];
|
||||
for (const g of forecastData) {
|
||||
const matched: ProductLite[] = (g.products || []).filter((p: ProductLite) => p.title?.toLowerCase().includes(term));
|
||||
if (matched.length === 0) continue;
|
||||
const totalSold = matched.reduce((sum: number, p: ProductLite) => sum + (p.total_sold || 0), 0);
|
||||
const numProducts = matched.length;
|
||||
const avgTotalSold = numProducts > 0 ? totalSold / numProducts : 0;
|
||||
const minSold = matched.reduce((min: number, p: ProductLite) => Math.min(min, p.total_sold || 0), Number.POSITIVE_INFINITY);
|
||||
const maxSold = matched.reduce((max: number, p: ProductLite) => Math.max(max, p.total_sold || 0), 0);
|
||||
filteredGroups.push({
|
||||
category: g.category,
|
||||
categoryPath: g.categoryPath,
|
||||
totalSold,
|
||||
numProducts,
|
||||
avgTotalSold,
|
||||
minSold: Number.isFinite(minSold) ? minSold : 0,
|
||||
maxSold,
|
||||
products: matched,
|
||||
});
|
||||
allMatchedProducts.push(...matched);
|
||||
if (term) {
|
||||
products = products.filter((p) => p.title.toLowerCase().includes(term));
|
||||
}
|
||||
|
||||
if (allMatchedProducts.length > 0) {
|
||||
const totalSoldAll = allMatchedProducts.reduce((sum: number, p: ProductLite) => sum + (p.total_sold || 0), 0);
|
||||
const avgTotalSoldAll = totalSoldAll / allMatchedProducts.length;
|
||||
const minSoldAll = allMatchedProducts.reduce((min: number, p: ProductLite) => Math.min(min, p.total_sold || 0), Number.POSITIVE_INFINITY);
|
||||
const maxSoldAll = allMatchedProducts.reduce((max: number, p: ProductLite) => Math.max(max, p.total_sold || 0), 0);
|
||||
filteredGroups.unshift({
|
||||
category: `Matches: "${search}"`,
|
||||
categoryPath: "",
|
||||
totalSold: totalSoldAll,
|
||||
numProducts: allMatchedProducts.length,
|
||||
avgTotalSold: avgTotalSoldAll,
|
||||
minSold: Number.isFinite(minSoldAll) ? minSoldAll : 0,
|
||||
maxSold: maxSoldAll,
|
||||
products: allMatchedProducts,
|
||||
});
|
||||
}
|
||||
return products;
|
||||
}, [rawData, selectedArtist, search, groupMode]);
|
||||
|
||||
return filteredGroups;
|
||||
}, [forecastData, search]);
|
||||
const lineGroups = useMemo(
|
||||
() => groupByLine(filteredProducts),
|
||||
[filteredProducts]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: displayData || [],
|
||||
columns,
|
||||
const categoryGroups = useMemo(
|
||||
() => groupByCategory(filteredProducts),
|
||||
[filteredProducts]
|
||||
);
|
||||
|
||||
const designerGroups = useMemo(
|
||||
() => groupByDesigner(filteredProducts),
|
||||
[filteredProducts]
|
||||
);
|
||||
|
||||
const crossLineAverages = useMemo(
|
||||
() => computeCrossLineAverages(lineGroups),
|
||||
[lineGroups]
|
||||
);
|
||||
|
||||
// ─── Tables ─────────────────────────────────────────────────────────────
|
||||
|
||||
const lineTable = useReactTable({
|
||||
data: lineGroups,
|
||||
columns: lineColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onSortingChange: setLineSorting,
|
||||
getRowCanExpand: () => true,
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
state: { sorting: lineSorting },
|
||||
});
|
||||
|
||||
const catTable = useReactTable({
|
||||
data: categoryGroups,
|
||||
columns: categoryColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
onSortingChange: setCatSorting,
|
||||
getRowCanExpand: () => true,
|
||||
state: { sorting: catSorting },
|
||||
});
|
||||
|
||||
const designerTable = useReactTable({
|
||||
data: designerGroups,
|
||||
columns: designerColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
onSortingChange: setDesignerSorting,
|
||||
getRowCanExpand: () => true,
|
||||
state: { sorting: designerSorting },
|
||||
});
|
||||
|
||||
// ─── QuickOrderBuilder data (always category-based) ─────────────────────
|
||||
|
||||
const qobCategories = useMemo(
|
||||
() =>
|
||||
categoryGroups.map((c) => ({
|
||||
category: c.category,
|
||||
categoryPath: c.categoryPath,
|
||||
avgTotalSold: c.avgLifetimeSales,
|
||||
minSold: c.minSales,
|
||||
maxSold: c.maxSales,
|
||||
})),
|
||||
[categoryGroups]
|
||||
);
|
||||
|
||||
// ─── Summary stats ─────────────────────────────────────────────────────
|
||||
|
||||
const totalProducts = filteredProducts.length;
|
||||
const totalLines = lineGroups.filter((l) => l.line !== "(No Line)").length;
|
||||
const totalDesigners = designerGroups.filter((d) => d.artist !== "(Unknown Designer)").length;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historical Sales</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Historical Sales for New Line Ordering</CardTitle>
|
||||
{rawData && !dataLoading && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{totalProducts} products across {totalLines} lines
|
||||
{totalDesigners > 1 ? `, ${totalDesigners} designers` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4 mb-6 items-center">
|
||||
{/* ─── Filter bar ─────────────────────────────────────────── */}
|
||||
<div className="flex gap-3 mb-4 items-center flex-wrap">
|
||||
<div className="w-[200px]">
|
||||
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
|
||||
<SelectTrigger disabled={brandsLoading}>
|
||||
@@ -204,12 +262,70 @@ export default function Forecasting() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DateRangePickerQuick
|
||||
value={dateRange}
|
||||
onChange={handleDateRangeChange}
|
||||
/>
|
||||
{(Array.isArray(displayData) && displayData.length > 0) || search.trim().length > 0 ? (
|
||||
<div className="w-[400px] relative">
|
||||
|
||||
{/* Artist filter (hidden in designer mode — the view itself compares designers) */}
|
||||
{rawData?.artists && rawData.artists.length > 0 && groupMode !== "designer" && (
|
||||
<div className="w-[200px]">
|
||||
<Select value={selectedArtist} onValueChange={setSelectedArtist}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All designers" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Designers</SelectItem>
|
||||
{rawData.artists.map((artist) => (
|
||||
<SelectItem key={artist} value={artist}>
|
||||
{artist}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grouping toggle */}
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={groupMode}
|
||||
onValueChange={(v) => { if (v) setGroupMode(v as GroupMode); }}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<ToggleGroupItem value="line" aria-label="Group by line">
|
||||
<Layers className="h-4 w-4 mr-1" /> By Line
|
||||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Group products by collection/line — see how each release performed</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<ToggleGroupItem value="category" aria-label="Group by category">
|
||||
<FolderTree className="h-4 w-4 mr-1" /> By Category
|
||||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Group products by type (Paper, Embellishments, etc.) across all lines</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<ToggleGroupItem value="designer" aria-label="Group by designer">
|
||||
<Palette className="h-4 w-4 mr-1" /> By Designer
|
||||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Compare designers — see line + category breakdowns per artist</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroup>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Search */}
|
||||
{(totalProducts > 0 || search.trim().length > 0) && (
|
||||
<div className="w-[280px] relative ml-auto">
|
||||
<Input
|
||||
placeholder="Filter by product title"
|
||||
value={search}
|
||||
@@ -227,28 +343,96 @@ export default function Forecasting() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Cross-line averages (line mode only) ───────────────── */}
|
||||
{groupMode === "line" && crossLineAverages.length > 0 && (
|
||||
<div className="mb-4 rounded-md border bg-muted/40 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
Cross-Line Averages by Product Type
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
— What an "average" line looks like for this brand
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-xs">
|
||||
<TableHead className="py-1.5">Category</TableHead>
|
||||
<TableHead className="py-1.5 text-right"># Lines</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Avg Products</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Avg Lifetime Sales</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Median</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Avg First 30d</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Best Line Avg</TableHead>
|
||||
<TableHead className="py-1.5 text-right">Worst Line Avg</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{crossLineAverages.map((avg) => (
|
||||
<TableRow key={avg.category} className="text-xs">
|
||||
<TableCell className="py-1.5 font-medium">{avg.category}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{avg.lineCount}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(avg.avgProductCount, 1)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right font-medium">{fmt(avg.avgLifetimeSales, 1)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(avg.medianLifetimeSales, 0)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{fmt(avg.avgFirst30dSales, 1)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right text-green-600">{fmt(avg.maxAvgSales, 0)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right text-red-600">{fmt(avg.minAvgSales, 0)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{forecastLoading ? (
|
||||
{/* ─── Main data table ────────────────────────────────────── */}
|
||||
{dataLoading ? (
|
||||
<div className="h-24 flex items-center justify-center">
|
||||
Loading sales data...
|
||||
</div>
|
||||
) : forecastData && (
|
||||
) : rawData && (
|
||||
<div className="rounded-md border">
|
||||
{groupMode === "line" ? (
|
||||
<LineViewTable table={lineTable} />
|
||||
) : groupMode === "designer" ? (
|
||||
<DesignerViewTable table={designerTable} />
|
||||
) : (
|
||||
<CategoryViewTable table={catTable} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Order Builder (unchanged interface) */}
|
||||
<QuickOrderBuilder brand={selectedBrand} categories={qobCategories} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Line view table component ──────────────────────────────────────────────
|
||||
|
||||
function LineViewTable({
|
||||
table,
|
||||
}: {
|
||||
table: ReturnType<typeof useReactTable<LineGroup>>;
|
||||
}) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup: HeaderGroup<ForecastItem>) => (
|
||||
{table.getHeaderGroups().map((headerGroup: HeaderGroup<LineGroup>) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header: Header<ForecastItem, unknown>) => (
|
||||
{headerGroup.headers.map((header: Header<LineGroup, unknown>) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -256,11 +440,11 @@ export default function Forecasting() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row: Row<ForecastItem>) => (
|
||||
table.getRowModel().rows.map((row: Row<LineGroup>) => (
|
||||
<Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={String(row.original.category || '').startsWith('Matches:') ? 'bg-muted font-medium' : ''}
|
||||
className={row.original.line === "(No Line)" ? "opacity-60" : ""}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
@@ -270,8 +454,8 @@ export default function Forecasting() {
|
||||
</TableRow>
|
||||
{row.getIsExpanded() && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="p-0">
|
||||
{renderSubComponent({ row })}
|
||||
<TableCell colSpan={lineColumns.length} className="p-0">
|
||||
<LineSubComponent row={row} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -279,31 +463,123 @@ export default function Forecasting() {
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
<TableCell colSpan={lineColumns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Category view table component ──────────────────────────────────────────
|
||||
|
||||
function CategoryViewTable({
|
||||
table,
|
||||
}: {
|
||||
table: ReturnType<typeof useReactTable<CategoryGroup>>;
|
||||
}) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup: HeaderGroup<CategoryGroup>) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header: Header<CategoryGroup, unknown>) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row: Row<CategoryGroup>) => (
|
||||
<Fragment key={row.id}>
|
||||
<TableRow data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{row.getIsExpanded() && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={categoryColumns.length} className="p-0">
|
||||
<CategorySubComponent row={row} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={categoryColumns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Designer view table component ──────────────────────────────────────────
|
||||
|
||||
function DesignerViewTable({
|
||||
table,
|
||||
}: {
|
||||
table: ReturnType<typeof useReactTable<DesignerGroup>>;
|
||||
}) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup: HeaderGroup<DesignerGroup>) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header: Header<DesignerGroup, unknown>) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row: Row<DesignerGroup>) => (
|
||||
<Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={row.original.artist === "(Unknown Designer)" ? "opacity-60" : ""}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{row.getIsExpanded() && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={designerColumns.length} className="p-0">
|
||||
<DesignerSubComponent row={row} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={designerColumns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Quick Order Builder */}
|
||||
<QuickOrderBuilder
|
||||
brand={selectedBrand}
|
||||
categories={(displayData || []).map((c: any) => ({
|
||||
category: c.category,
|
||||
categoryPath: c.categoryPath,
|
||||
avgTotalSold: c.avgTotalSold,
|
||||
minSold: c.minSold,
|
||||
maxSold: c.maxSold,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import config from "../config";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface LineMetric {
|
||||
brand: string;
|
||||
line: string;
|
||||
product_count: number;
|
||||
active_product_count: number;
|
||||
replenishable_product_count: number;
|
||||
current_stock_units: number;
|
||||
current_stock_cost: number;
|
||||
current_stock_retail: number;
|
||||
on_order_qty: number;
|
||||
on_order_cost: number;
|
||||
sales_7d: number;
|
||||
revenue_7d: number;
|
||||
sales_30d: number;
|
||||
revenue_30d: number;
|
||||
profit_30d: number;
|
||||
cogs_30d: number;
|
||||
sales_365d: number;
|
||||
revenue_365d: number;
|
||||
lifetime_sales: number;
|
||||
lifetime_revenue: number;
|
||||
avg_margin_30d: number | null;
|
||||
total_velocity_daily: number;
|
||||
avg_stock_cover_days: number | null;
|
||||
avg_sells_out_in_days: number | null;
|
||||
total_stockout_days_30d: number;
|
||||
total_replenishment_units: number;
|
||||
total_replenishment_cost: number;
|
||||
phase_launch: number;
|
||||
phase_mature: number;
|
||||
phase_slow_mover: number;
|
||||
phase_decay: number;
|
||||
phase_dormant: number;
|
||||
phase_preorder: number;
|
||||
status_healthy: number;
|
||||
status_reorder: number;
|
||||
status_critical: number;
|
||||
status_overstock: number;
|
||||
status_at_risk: number;
|
||||
status_new: number;
|
||||
abc_a_count: number;
|
||||
abc_b_count: number;
|
||||
abc_c_count: number;
|
||||
earliest_received: string | null;
|
||||
latest_sale: string | null;
|
||||
sales_growth_30d_vs_prev: number | null;
|
||||
revenue_growth_30d_vs_prev: number | null;
|
||||
line_status: string;
|
||||
dominant_lifecycle_phase: string | null;
|
||||
}
|
||||
|
||||
interface LineResponse {
|
||||
lines: LineMetric[];
|
||||
pagination: { total: number; pages: number; currentPage: number; limit: number };
|
||||
}
|
||||
|
||||
interface LineFilterOptions {
|
||||
brands: string[];
|
||||
statuses: string[];
|
||||
phases: string[];
|
||||
}
|
||||
|
||||
interface LineStats {
|
||||
totalLines: number;
|
||||
brandCount: number;
|
||||
activeLines: number;
|
||||
oosLines: number;
|
||||
linesNeedingRestock: number;
|
||||
avgProductsPerLine: number;
|
||||
totalRevenue30d: number;
|
||||
avgMargin: number;
|
||||
}
|
||||
|
||||
interface LineProduct {
|
||||
pid: number;
|
||||
title: string;
|
||||
sku: string;
|
||||
current_stock: number;
|
||||
sales_30d: number;
|
||||
revenue_30d: number;
|
||||
profit_30d: number;
|
||||
margin_30d: number | null;
|
||||
sales_365d: number;
|
||||
lifecycle_phase: string | null;
|
||||
status: string | null;
|
||||
abc_class: string | null;
|
||||
on_order_qty: number;
|
||||
replenishment_units: number;
|
||||
}
|
||||
|
||||
interface LineProductsResponse {
|
||||
totalProducts: number;
|
||||
products: LineProduct[];
|
||||
}
|
||||
|
||||
// Column-level sorts (for clicking table headers)
|
||||
type SortableColumn =
|
||||
| 'brand' | 'line' | 'productCount' | 'activeProductCount'
|
||||
| 'currentStockUnits' | 'currentStockCost' | 'revenue30d' | 'profit30d'
|
||||
| 'avgMargin30d' | 'sales30d' | 'sales365d' | 'revenue365d'
|
||||
| 'totalVelocityDaily' | 'avgStockCoverDays'
|
||||
| 'salesGrowth30dVsPrev' | 'revenueGrowth30dVsPrev'
|
||||
| 'lineStatus' | 'dominantLifecyclePhase';
|
||||
|
||||
// Curated multi-column sort presets (backend handles the ORDER BY)
|
||||
type SortPreset = 'by-brand' | 'top-revenue' | 'needs-restock' | 'fastest-growing' | 'newest' | 'low-stock';
|
||||
|
||||
const SORT_PRESETS: { value: SortPreset; label: string }[] = [
|
||||
{ value: 'by-brand', label: 'By Brand' },
|
||||
{ value: 'top-revenue', label: 'Top Revenue' },
|
||||
{ value: 'needs-restock', label: 'Needs Restock' },
|
||||
{ value: 'fastest-growing', label: 'Fastest Growing' },
|
||||
{ value: 'newest', label: 'Newest Lines' },
|
||||
{ value: 'low-stock', label: 'Low Stock Cover' },
|
||||
];
|
||||
|
||||
interface Filters {
|
||||
search: string;
|
||||
brand: string;
|
||||
status: string;
|
||||
phase: string;
|
||||
}
|
||||
|
||||
// --- Formatting helpers ---
|
||||
|
||||
const formatCurrency = (value: number | string | null | undefined, digits = 0): string => {
|
||||
if (value == null) return 'N/A';
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return 'N/A';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency', currency: 'USD',
|
||||
minimumFractionDigits: digits, maximumFractionDigits: digits
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | string | null | undefined, digits = 0): string => {
|
||||
if (value == null) return 'N/A';
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return 'N/A';
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
};
|
||||
|
||||
const formatPercentage = (value: number | string | null | undefined, digits = 1): string => {
|
||||
if (value == null) return 'N/A';
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return 'N/A';
|
||||
return `${num.toFixed(digits)}%`;
|
||||
};
|
||||
|
||||
const formatGrowth = (value: number | string | null | undefined, digits = 1) => {
|
||||
if (value == null) return <span className="text-muted-foreground">--</span>;
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return <span className="text-muted-foreground">--</span>;
|
||||
const formatted = `${num >= 0 ? '+' : ''}${num.toFixed(digits)}%`;
|
||||
return <span className={num >= 0 ? 'text-green-600' : 'text-red-600'}>{formatted}</span>;
|
||||
};
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
|
||||
// --- Status & Phase badges ---
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "outline" | "destructive" }> = {
|
||||
active: { label: 'Active', variant: 'default' },
|
||||
preorder: { label: 'Pre-order', variant: 'secondary' },
|
||||
slow: { label: 'Slow', variant: 'secondary' },
|
||||
out_of_stock: { label: 'Out of Stock', variant: 'destructive' },
|
||||
dormant: { label: 'Dormant', variant: 'outline' },
|
||||
};
|
||||
|
||||
const getPhaseLabel = (phase: string): string =>
|
||||
PHASE_CONFIG[phase]?.label || phase;
|
||||
|
||||
const getPhaseColor = (phase: string): string =>
|
||||
PHASE_CONFIG[phase]?.color || '#94A3B8';
|
||||
|
||||
// Mini bar showing lifecycle phase distribution using shared PHASE_CONFIG colors
|
||||
function PhaseBar({ line }: { line: LineMetric }) {
|
||||
const phases = [
|
||||
{ key: 'preorder', count: Number(line.phase_preorder) },
|
||||
{ key: 'launch', count: Number(line.phase_launch) },
|
||||
{ key: 'decay', count: Number(line.phase_decay) },
|
||||
{ key: 'mature', count: Number(line.phase_mature) },
|
||||
{ key: 'slow_mover', count: Number(line.phase_slow_mover) },
|
||||
{ key: 'dormant', count: Number(line.phase_dormant) },
|
||||
];
|
||||
const total = phases.reduce((s, p) => s + p.count, 0);
|
||||
if (total === 0) return <span className="text-muted-foreground text-xs">--</span>;
|
||||
|
||||
const activePhases = phases.filter(p => p.count > 0);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex flex-col items-start gap-1.5 cursor-help">
|
||||
<div className="flex h-2 w-20 rounded-full overflow-hidden bg-muted">
|
||||
{activePhases.map(p => (
|
||||
<div
|
||||
key={p.key}
|
||||
className="h-full"
|
||||
style={{ width: `${(p.count / total) * 100}%`, backgroundColor: getPhaseColor(p.key) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{line.dominant_lifecycle_phase ? getPhaseLabel(line.dominant_lifecycle_phase) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<div className="space-y-1">
|
||||
{activePhases.map(p => (
|
||||
<div key={p.key} className="flex items-center gap-2 text-xs">
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: getPhaseColor(p.key) }} />
|
||||
<span>{getPhaseLabel(p.key)}</span>
|
||||
<span className="text-primary-foreground/70 ml-auto pl-2">{p.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Stock health status bar — ordered by severity for visual scanning
|
||||
const STATUS_BAR_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
critical: { label: 'Critical', color: '#ef4444' },
|
||||
reorder: { label: 'Reorder Soon', color: '#fbbf24' },
|
||||
healthy: { label: 'Healthy', color: '#22c55e' },
|
||||
at_risk: { label: 'At Risk', color: '#f97316' },
|
||||
overstock: { label: 'Overstock', color: '#0f766e' },
|
||||
preorder: { label: 'Pre-order', color: '#3b82f6' },
|
||||
};
|
||||
|
||||
// Determines display order — array position = render order in bar
|
||||
const STATUS_ORDER: string[] = ['critical', 'reorder', 'healthy', 'at_risk', 'overstock', 'preorder'];
|
||||
|
||||
// Map DB status strings to STATUS_BAR_CONFIG keys
|
||||
const STATUS_KEY_MAP: Record<string, string> = {
|
||||
'Critical': 'critical',
|
||||
'Reorder Soon': 'reorder',
|
||||
'Healthy': 'healthy',
|
||||
'Overstock': 'overstock',
|
||||
'At Risk': 'at_risk',
|
||||
'New': 'healthy',
|
||||
};
|
||||
|
||||
function StatusBar({ line }: { line: LineMetric }) {
|
||||
const statusCounts: Record<string, number> = {
|
||||
critical: Number(line.status_critical),
|
||||
reorder: Number(line.status_reorder),
|
||||
healthy: Number(line.status_healthy) + Number(line.status_new),
|
||||
at_risk: Number(line.status_at_risk),
|
||||
overstock: Number(line.status_overstock),
|
||||
preorder: Number(line.phase_preorder),
|
||||
};
|
||||
|
||||
const statuses = STATUS_ORDER.map(key => ({ key, count: statusCounts[key] }));
|
||||
const total = statuses.reduce((s, st) => s + st.count, 0);
|
||||
if (total === 0) return <span className="text-muted-foreground text-xs">--</span>;
|
||||
|
||||
const active = statuses.filter(s => s.count > 0);
|
||||
const stockCover = line.avg_stock_cover_days != null ? Number(line.avg_stock_cover_days) : null;
|
||||
const onOrder = Number(line.on_order_qty);
|
||||
|
||||
// Summary label: pick the most notable non-healthy/non-preorder status, or "Good"
|
||||
const neutralCount = statusCounts.healthy + statusCounts.preorder;
|
||||
const healthyPct = neutralCount / total;
|
||||
let summaryLabel = 'Good';
|
||||
if (Number(line.current_stock_units) === 0 && onOrder === 0) {
|
||||
summaryLabel = 'No Stock';
|
||||
} else if (stockCover !== null && stockCover < 14) {
|
||||
summaryLabel = `${Math.round(stockCover)}d cover`;
|
||||
} else if (healthyPct < 0.8 && active.length > 1) {
|
||||
// Find the largest problem segment
|
||||
const biggest = active
|
||||
.filter(s => s.key !== 'healthy' && s.key !== 'preorder')
|
||||
.sort((a, b) => b.count - a.count)[0];
|
||||
if (biggest) summaryLabel = `${biggest.count} ${STATUS_BAR_CONFIG[biggest.key].label}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex flex-col items-end gap-1.5 cursor-help">
|
||||
<div className="flex h-2 w-20 rounded-full overflow-hidden bg-muted">
|
||||
{active.map(s => (
|
||||
<div
|
||||
key={s.key}
|
||||
className="h-full"
|
||||
style={{ width: `${(s.count / total) * 100}%`, backgroundColor: STATUS_BAR_CONFIG[s.key].color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{summaryLabel}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<div className="space-y-1">
|
||||
{active.map(s => (
|
||||
<div key={s.key} className="flex items-center gap-2 text-xs">
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: STATUS_BAR_CONFIG[s.key].color }} />
|
||||
<span>{STATUS_BAR_CONFIG[s.key].label}</span>
|
||||
<span className="text-primary-foreground/70 ml-auto pl-2">{s.count}</span>
|
||||
</div>
|
||||
))}
|
||||
{stockCover !== null && (
|
||||
<div className="text-xs text-primary-foreground/70 pt-1 border-t border-primary-foreground/20 mt-1">
|
||||
{Math.round(stockCover)}d avg cover
|
||||
</div>
|
||||
)}
|
||||
{onOrder > 0 && (
|
||||
<div className="text-xs text-primary-foreground/70">
|
||||
{formatNumber(onOrder)} on order
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Expandable product detail row
|
||||
function LineProductDetail({ brand, line }: { brand: string; line: string }) {
|
||||
const { data, isLoading } = useQuery<LineProductsResponse>({
|
||||
queryKey: ['lineProducts', brand, line],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`${config.apiUrl}/lines-aggregate/${encodeURIComponent(brand)}/${encodeURIComponent(line)}/products`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="bg-muted/30 p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.products.length === 0) return null;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="bg-muted/30 p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">{data.totalProducts} products in this line — sorted by revenue</p>
|
||||
<div className="max-h-[400px] overflow-y-auto rounded border bg-background">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow className="text-xs">
|
||||
<TableHead className="py-1">Product</TableHead>
|
||||
<TableHead className="py-1 text-right">Stock</TableHead>
|
||||
<TableHead className="py-1 text-right">Sales (30d)</TableHead>
|
||||
<TableHead className="py-1 text-right">Revenue (30d)</TableHead>
|
||||
<TableHead className="py-1 text-right">Margin</TableHead>
|
||||
<TableHead className="py-1 text-right">Phase</TableHead>
|
||||
<TableHead className="py-1 text-right">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.products.map((p) => (
|
||||
<TableRow key={p.pid} className="text-xs">
|
||||
<TableCell className="py-1.5 max-w-[300px]">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block truncate">{p.title}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>{p.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{formatNumber(p.current_stock)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{formatNumber(p.sales_30d)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{formatCurrency(p.revenue_30d)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">{formatPercentage(p.margin_30d)}</TableCell>
|
||||
<TableCell className="py-1.5 text-right">
|
||||
{p.lifecycle_phase ? (
|
||||
<span
|
||||
className="inline-block px-1.5 py-0.5 rounded text-[10px] text-white"
|
||||
style={{ backgroundColor: getPhaseColor(p.lifecycle_phase) }}
|
||||
>
|
||||
{getPhaseLabel(p.lifecycle_phase)}
|
||||
</span>
|
||||
) : '--'}
|
||||
</TableCell>
|
||||
<TableCell className="py-1.5 text-right">
|
||||
{p.status ? (
|
||||
<span
|
||||
className="inline-block px-1.5 py-0.5 rounded text-[10px] text-white"
|
||||
style={{ backgroundColor: STATUS_BAR_CONFIG[STATUS_KEY_MAP[p.status] || 'healthy']?.color }}
|
||||
>
|
||||
{p.status}
|
||||
</span>
|
||||
) : '--'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Page Component ---
|
||||
|
||||
export function ProductLines() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(ITEMS_PER_PAGE);
|
||||
// Sort can be either a preset (compound sort) or a single column
|
||||
const [sortPreset, setSortPreset] = useState<SortPreset | null>('newest');
|
||||
const [sortColumn, setSortColumn] = useState<SortableColumn>("brand");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const [expandedLine, setExpandedLine] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
search: "",
|
||||
brand: "all",
|
||||
status: "all",
|
||||
phase: "all",
|
||||
});
|
||||
|
||||
// The active sort key sent to the backend
|
||||
const activeSort = sortPreset || sortColumn;
|
||||
|
||||
// --- Data Fetching ---
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', page.toString());
|
||||
params.set('limit', limit.toString());
|
||||
params.set('sort', activeSort);
|
||||
if (!sortPreset) {
|
||||
params.set('order', sortDirection);
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
params.set('line_ilike', filters.search);
|
||||
}
|
||||
if (filters.brand !== 'all') {
|
||||
params.set('brand', filters.brand);
|
||||
}
|
||||
if (filters.status !== 'all') {
|
||||
params.set('lineStatus', filters.status);
|
||||
}
|
||||
if (filters.phase !== 'all') {
|
||||
params.set('dominantLifecyclePhase', filters.phase);
|
||||
}
|
||||
return params;
|
||||
}, [page, limit, activeSort, sortPreset, sortDirection, filters]);
|
||||
|
||||
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<LineResponse, Error>({
|
||||
queryKey: ['productLines', queryParams.toString()],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${config.apiUrl}/lines-aggregate?${queryParams.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) throw new Error(`Network response was not ok (${res.status})`);
|
||||
return res.json();
|
||||
},
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const { data: statsData, isLoading: isLoadingStats } = useQuery<LineStats, Error>({
|
||||
queryKey: ['productLineStats'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${config.apiUrl}/lines-aggregate/stats`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error("Failed to fetch stats");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: filterOptions } = useQuery<LineFilterOptions, Error>({
|
||||
queryKey: ['productLineFilterOptions'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${config.apiUrl}/lines-aggregate/filter-options`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error("Failed to fetch filter options");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const handleSort = useCallback((column: SortableColumn) => {
|
||||
setSortPreset(null); // Clear preset when manually sorting by column
|
||||
setSortDirection(prev => (sortColumn === column && prev === "desc" ? "asc" : "desc"));
|
||||
setSortColumn(column);
|
||||
setPage(1);
|
||||
}, [sortColumn]);
|
||||
|
||||
const handlePresetChange = useCallback((preset: string) => {
|
||||
if (preset === 'custom') {
|
||||
// Switching to the "custom" placeholder does nothing; user clicks column headers
|
||||
return;
|
||||
}
|
||||
setSortPreset(preset as SortPreset);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((name: keyof Filters, value: string) => {
|
||||
setFilters(prev => ({ ...prev, [name]: value }));
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= (listData?.pagination.pages ?? 1)) {
|
||||
setPage(newPage);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = (brand: string, line: string) => {
|
||||
const key = `${brand}|||${line}`;
|
||||
setExpandedLine(prev => prev === key ? null : key);
|
||||
};
|
||||
|
||||
// --- Derived ---
|
||||
const lines = listData?.lines ?? [];
|
||||
const pagination = listData?.pagination;
|
||||
const totalPages = pagination?.pages ?? 0;
|
||||
|
||||
const sortIndicator = (col: SortableColumn) => {
|
||||
if (sortPreset || sortColumn !== col) return null;
|
||||
return <span className="ml-1">{sortDirection === 'asc' ? '\u2191' : '\u2193'}</span>;
|
||||
};
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
|
||||
className="container mx-auto py-6 space-y-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Product Lines</h1>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<motion.div layout="preserve-aspect" transition={{ duration: 0.15 }} className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Product Lines</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : (
|
||||
<div className="text-2xl font-bold">{formatNumber(statsData?.totalLines)}</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
|
||||
`${formatNumber(statsData?.activeLines)} active across ${formatNumber(statsData?.brandCount)} brands`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Products / Line</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : (
|
||||
<div className="text-2xl font-bold">{formatNumber(statsData?.avgProductsPerLine, 1)}</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Average line depth</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Margin (30d)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : (
|
||||
<div className="text-2xl font-bold">{formatPercentage(statsData?.avgMargin)}</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
|
||||
`${formatCurrency(statsData?.totalRevenue30d)} total revenue`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card
|
||||
className={`cursor-pointer transition-colors hover:border-primary/40 ${sortPreset === 'needs-restock' ? 'border-primary' : ''}`}
|
||||
onClick={() => { setSortPreset('needs-restock'); setPage(1); }}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Lines Needing Restock</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : (
|
||||
<div className="text-2xl font-bold">{formatNumber(statsData?.linesNeedingRestock)}</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
|
||||
`${formatNumber(statsData?.oosLines)} fully out of stock`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Filters & Sort */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search lines..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="w-full sm:w-[250px]"
|
||||
/>
|
||||
<Select value={filters.brand} onValueChange={(v) => handleFilterChange('brand', v)}>
|
||||
<SelectTrigger className="w-full sm:w-[200px]">
|
||||
<SelectValue placeholder="Brand" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Brands</SelectItem>
|
||||
{filterOptions?.brands?.map((b) => (
|
||||
<SelectItem key={b} value={b}>{b}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filters.status} onValueChange={(v) => handleFilterChange('status', v)}>
|
||||
<SelectTrigger className="w-full sm:w-[160px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{filterOptions?.statuses?.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{statusConfig[s]?.label || s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filters.phase} onValueChange={(v) => handleFilterChange('phase', v)}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="Lifecycle" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Phases</SelectItem>
|
||||
{filterOptions?.phases?.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{getPhaseLabel(p)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="ml-auto">
|
||||
<Select value={sortPreset || 'custom'} onValueChange={handlePresetChange}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="Sort by..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_PRESETS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
{!sortPreset && (
|
||||
<SelectItem value="custom" disabled>Custom sort</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8"></TableHead>
|
||||
<TableHead onClick={() => handleSort("brand")} className="cursor-pointer">
|
||||
Brand{sortIndicator("brand")}
|
||||
</TableHead>
|
||||
<TableHead onClick={() => handleSort("line")} className="cursor-pointer">
|
||||
Line{sortIndicator("line")}
|
||||
</TableHead>
|
||||
<TableHead onClick={() => handleSort("productCount")} className="cursor-pointer text-right">
|
||||
Products{sortIndicator("productCount")}
|
||||
</TableHead>
|
||||
<TableHead onClick={() => handleSort("currentStockUnits")} className="cursor-pointer text-right">
|
||||
Stock{sortIndicator("currentStockUnits")}
|
||||
</TableHead>
|
||||
<TableHead onClick={() => handleSort("revenue30d")} className="cursor-pointer text-right">
|
||||
Revenue (30d){sortIndicator("revenue30d")}
|
||||
</TableHead>
|
||||
<TableHead onClick={() => handleSort("profit30d")} className="cursor-pointer text-right">
|
||||
Profit (30d){sortIndicator("profit30d")}
|
||||
</TableHead>
|
||||
<TableHead onClick={() => handleSort("avgMargin30d")} className="cursor-pointer text-right">
|
||||
Margin{sortIndicator("avgMargin30d")}
|
||||
</TableHead>
|
||||
<TableHead onClick={() => handleSort("totalVelocityDaily")} className="cursor-pointer text-right whitespace-nowrap">
|
||||
Velocity/d{sortIndicator("totalVelocityDaily")}
|
||||
</TableHead>
|
||||
<TableHead onClick={() => handleSort("salesGrowth30dVsPrev")} className="cursor-pointer text-right">
|
||||
Growth{sortIndicator("salesGrowth30dVsPrev")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Lifecycle</TableHead>
|
||||
<TableHead className="text-right">Product Status</TableHead>
|
||||
<TableHead onClick={() => handleSort("lineStatus")} className="cursor-pointer text-right">
|
||||
Status{sortIndicator("lineStatus")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoadingList && !listData ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<TableRow key={`skel-${i}`}>
|
||||
<TableCell><Skeleton className="h-4 w-4" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-28" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-36" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-10 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-14 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-18 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-14 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-12 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-14 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : listError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="text-center py-8 text-destructive">
|
||||
Error loading product lines: {listError.message}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : lines.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="text-center py-8 text-muted-foreground">
|
||||
No product lines found matching your criteria.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
lines.map((line) => {
|
||||
const key = `${line.brand}|||${line.line}`;
|
||||
const isExpanded = expandedLine === key;
|
||||
const sc = statusConfig[line.line_status] || statusConfig.dormant;
|
||||
|
||||
return (
|
||||
<AnimatePresence key={key}>
|
||||
<TableRow
|
||||
className={`cursor-pointer hover:bg-muted/50 ${line.line_status === 'out_of_stock' || line.line_status === 'dormant' ? 'opacity-60' : ''}`}
|
||||
onClick={() => toggleExpand(line.brand, line.line)}
|
||||
>
|
||||
<TableCell className="w-8 px-2">
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-sm whitespace-nowrap max-w-[200px] overflow-hidden text-ellipsis">{line.brand}</TableCell>
|
||||
<TableCell className="text-sm">{line.line}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(line.product_count)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(line.current_stock_units)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(line.revenue_30d)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(line.profit_30d)}</TableCell>
|
||||
<TableCell className="text-right">{formatPercentage(line.avg_margin_30d)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(line.total_velocity_daily, 1)}</TableCell>
|
||||
<TableCell className="text-right">{formatGrowth(line.sales_growth_30d_vs_prev)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<PhaseBar line={line} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<StatusBar line={line} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant={sc.variant} className="whitespace-nowrap">{sc.label}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<LineProductDetail brand={line.brand} line={line.line} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && pagination && (
|
||||
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex justify-center">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage - 1); }}
|
||||
aria-disabled={pagination.currentPage === 1}
|
||||
className={pagination.currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{[...Array(Math.min(totalPages, 7))].map((_, i) => {
|
||||
// Show pages around current page
|
||||
let pageNum: number;
|
||||
if (totalPages <= 7) {
|
||||
pageNum = i + 1;
|
||||
} else if (pagination.currentPage <= 4) {
|
||||
pageNum = i + 1;
|
||||
} else if (pagination.currentPage >= totalPages - 3) {
|
||||
pageNum = totalPages - 6 + i;
|
||||
} else {
|
||||
pageNum = pagination.currentPage - 3 + i;
|
||||
}
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); handlePageChange(pageNum); }}
|
||||
isActive={pagination.currentPage === pageNum}
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage + 1); }}
|
||||
aria-disabled={pagination.currentPage >= totalPages}
|
||||
className={pagination.currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProductLines;
|
||||
@@ -269,7 +269,7 @@ export function Settings() {
|
||||
|
||||
<TabsContent value="audit-log" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||
<Protected
|
||||
adminOnly
|
||||
permission="settings:audit_log"
|
||||
fallback={
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const PHASE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
preorder: { label: "Pre-order", color: "#3B82F6" },
|
||||
launch: { label: "Launch", color: "#22C55E" },
|
||||
launch: { label: "Launch", color: "#84cc16" },
|
||||
decay: { label: "Active", color: "#F59E0B" },
|
||||
mature: { label: "Evergreen", color: "#8B5CF6" },
|
||||
slow_mover: { label: "Slow Mover", color: "#14B8A6" },
|
||||
slow_mover: { label: "Slow Mover", color: "#06B6D4" },
|
||||
dormant: { label: "Dormant", color: "#6B7280" },
|
||||
unknown: { label: "Unclassified", color: "#94A3B8" },
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user