Compare commits
10 Commits
45ded53530
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f8b81d2111 | |||
| 1b836567cd | |||
| 39b8faa208 | |||
| 177f7778b9 | |||
| f887dc6af1 | |||
| c344fdc3b8 | |||
| ebef903f3b | |||
| 16d2399de8 | |||
| c3e09d5fd1 | |||
| bae8c575bc |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -84,4 +84,6 @@ chat-migration*/**
|
|||||||
venv/
|
venv/
|
||||||
venv/**
|
venv/**
|
||||||
**/venv/*
|
**/venv/*
|
||||||
**/venv/**
|
**/venv/**
|
||||||
|
|
||||||
|
inventory-server/data/taxonomy-embeddings.json
|
||||||
@@ -51,6 +51,7 @@ DOW_LOOKBACK_DAYS = 90 # days of order history for day-of-week indices
|
|||||||
MIN_R_SQUARED = 0.1 # curves below this are unreliable (fall back to velocity)
|
MIN_R_SQUARED = 0.1 # curves below this are unreliable (fall back to velocity)
|
||||||
SEASONAL_LOOKBACK_DAYS = 365 # 12 months of order history for monthly seasonal indices
|
SEASONAL_LOOKBACK_DAYS = 365 # 12 months of order history for monthly seasonal indices
|
||||||
MIN_PREORDER_DAYS = 3 # minimum pre-order accumulation days for reliable scaling
|
MIN_PREORDER_DAYS = 3 # minimum pre-order accumulation days for reliable scaling
|
||||||
|
MAX_SMOOTHING_MULTIPLIER = 10 # cap exp smoothing forecast at Nx observed velocity
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -838,6 +839,12 @@ def forecast_mature(product, history_df):
|
|||||||
if np.count_nonzero(series) < 2:
|
if np.count_nonzero(series) < 2:
|
||||||
return np.full(FORECAST_HORIZON_DAYS, velocity)
|
return np.full(FORECAST_HORIZON_DAYS, velocity)
|
||||||
|
|
||||||
|
# Cap: prevent runaway forecasts from one-time spikes.
|
||||||
|
# Use the higher of 30d velocity or the observed mean as the baseline,
|
||||||
|
# so sustained increases are respected.
|
||||||
|
observed_mean = float(np.mean(series))
|
||||||
|
cap = max(velocity, observed_mean) * MAX_SMOOTHING_MULTIPLIER
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Holt's with damped trend: the phi parameter dampens the trend over
|
# Holt's with damped trend: the phi parameter dampens the trend over
|
||||||
# the horizon so forecasts converge to a level instead of extrapolating
|
# the horizon so forecasts converge to a level instead of extrapolating
|
||||||
@@ -845,7 +852,7 @@ def forecast_mature(product, history_df):
|
|||||||
model = Holt(series, initialization_method='estimated', damped_trend=True)
|
model = Holt(series, initialization_method='estimated', damped_trend=True)
|
||||||
fit = model.fit(optimized=True)
|
fit = model.fit(optimized=True)
|
||||||
forecast = fit.forecast(FORECAST_HORIZON_DAYS)
|
forecast = fit.forecast(FORECAST_HORIZON_DAYS)
|
||||||
forecast = np.maximum(forecast, 0)
|
forecast = np.clip(forecast, 0, cap)
|
||||||
return forecast
|
return forecast
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fall back to SES if Holt's fails (e.g. insufficient data points)
|
# Fall back to SES if Holt's fails (e.g. insufficient data points)
|
||||||
@@ -853,7 +860,7 @@ def forecast_mature(product, history_df):
|
|||||||
model = SimpleExpSmoothing(series, initialization_method='estimated')
|
model = SimpleExpSmoothing(series, initialization_method='estimated')
|
||||||
fit = model.fit(optimized=True)
|
fit = model.fit(optimized=True)
|
||||||
forecast = fit.forecast(FORECAST_HORIZON_DAYS)
|
forecast = fit.forecast(FORECAST_HORIZON_DAYS)
|
||||||
forecast = np.maximum(forecast, 0)
|
forecast = np.clip(forecast, 0, cap)
|
||||||
return forecast
|
return forecast
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.debug(f"ExpSmoothing failed for pid {pid}: {e}")
|
log.debug(f"ExpSmoothing failed for pid {pid}: {e}")
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ async function ensureInitialized() {
|
|||||||
...result.stats,
|
...result.stats,
|
||||||
groqEnabled: result.groqEnabled
|
groqEnabled: result.groqEnabled
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Watch for taxonomy changes in the background (checks every hour)
|
||||||
|
aiService.startBackgroundCheck(getDbConnection);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AI Routes] Failed to initialize AI service:', error);
|
console.error('[AI Routes] Failed to initialize AI service:', error);
|
||||||
@@ -431,4 +435,16 @@ router.post('/validate/sanity-check', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off AI initialization in the background (no-op if already initialized).
|
||||||
|
* Call once from server startup so the taxonomy embeddings are ready before
|
||||||
|
* the first user request hits a taxonomy dropdown.
|
||||||
|
*/
|
||||||
|
function initInBackground() {
|
||||||
|
ensureInitialized().catch(err =>
|
||||||
|
console.error('[AI Routes] Background initialization failed:', err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
module.exports.initInBackground = initInBackground;
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ router.get('/stock/metrics', async (req, res) => {
|
|||||||
COALESCE(COUNT(*), 0)::integer as total_products,
|
COALESCE(COUNT(*), 0)::integer as total_products,
|
||||||
COALESCE(COUNT(CASE WHEN current_stock > 0 THEN 1 END), 0)::integer as products_in_stock,
|
COALESCE(COUNT(CASE WHEN current_stock > 0 THEN 1 END), 0)::integer as products_in_stock,
|
||||||
COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock END), 0)::integer as total_units,
|
COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock END), 0)::integer as total_units,
|
||||||
ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_cost END), 0)::numeric, 3) as total_cost,
|
ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_cost END), 0)::numeric, 2) as total_cost,
|
||||||
ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_retail END), 0)::numeric, 3) as total_retail
|
ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_retail END), 0)::numeric, 2) as total_retail
|
||||||
FROM product_metrics
|
FROM product_metrics
|
||||||
WHERE is_visible = true
|
WHERE is_visible = true
|
||||||
`);
|
`);
|
||||||
@@ -34,21 +34,21 @@ router.get('/stock/metrics', async (req, res) => {
|
|||||||
COALESCE(brand, 'Unbranded') as brand,
|
COALESCE(brand, 'Unbranded') as brand,
|
||||||
COUNT(DISTINCT pid)::integer as variant_count,
|
COUNT(DISTINCT pid)::integer as variant_count,
|
||||||
COALESCE(SUM(current_stock), 0)::integer as stock_units,
|
COALESCE(SUM(current_stock), 0)::integer as stock_units,
|
||||||
ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 3) as stock_cost,
|
ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 2) as stock_cost,
|
||||||
ROUND(COALESCE(SUM(current_stock_retail), 0)::numeric, 3) as stock_retail
|
ROUND(COALESCE(SUM(current_stock_retail), 0)::numeric, 2) as stock_retail
|
||||||
FROM product_metrics
|
FROM product_metrics
|
||||||
WHERE current_stock > 0
|
WHERE current_stock > 0
|
||||||
AND is_visible = true
|
AND is_visible = true
|
||||||
GROUP BY COALESCE(brand, 'Unbranded')
|
GROUP BY COALESCE(brand, 'Unbranded')
|
||||||
HAVING ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 3) > 0
|
HAVING ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 2) > 0
|
||||||
),
|
),
|
||||||
other_brands AS (
|
other_brands AS (
|
||||||
SELECT
|
SELECT
|
||||||
'Other' as brand,
|
'Other' as brand,
|
||||||
SUM(variant_count)::integer as variant_count,
|
SUM(variant_count)::integer as variant_count,
|
||||||
SUM(stock_units)::integer as stock_units,
|
SUM(stock_units)::integer as stock_units,
|
||||||
ROUND(SUM(stock_cost)::numeric, 3) as stock_cost,
|
ROUND(SUM(stock_cost)::numeric, 2) as stock_cost,
|
||||||
ROUND(SUM(stock_retail)::numeric, 3) as stock_retail
|
ROUND(SUM(stock_retail)::numeric, 2) as stock_retail
|
||||||
FROM brand_totals
|
FROM brand_totals
|
||||||
WHERE stock_cost <= 5000
|
WHERE stock_cost <= 5000
|
||||||
),
|
),
|
||||||
@@ -154,7 +154,10 @@ router.get('/purchase/metrics', async (req, res) => {
|
|||||||
vendor,
|
vendor,
|
||||||
SUM(on_order_qty)::integer AS units,
|
SUM(on_order_qty)::integer AS units,
|
||||||
ROUND(SUM(on_order_cost)::numeric, 2) AS cost,
|
ROUND(SUM(on_order_cost)::numeric, 2) AS cost,
|
||||||
ROUND(SUM(on_order_retail)::numeric, 2) AS retail
|
ROUND(SUM(on_order_retail)::numeric, 2) AS retail,
|
||||||
|
SUM(SUM(on_order_qty)::integer) OVER () AS total_units,
|
||||||
|
ROUND(SUM(SUM(on_order_cost)) OVER ()::numeric, 2) AS total_cost,
|
||||||
|
ROUND(SUM(SUM(on_order_retail)) OVER ()::numeric, 2) AS total_retail
|
||||||
FROM product_metrics
|
FROM product_metrics
|
||||||
WHERE is_visible = true AND on_order_qty > 0
|
WHERE is_visible = true AND on_order_qty > 0
|
||||||
GROUP BY vendor
|
GROUP BY vendor
|
||||||
@@ -169,9 +172,10 @@ router.get('/purchase/metrics', async (req, res) => {
|
|||||||
retail: parseFloat(v.retail) || 0
|
retail: parseFloat(v.retail) || 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const onOrderUnits = vendorOrders.reduce((sum, v) => sum + v.units, 0);
|
const firstRow = vendorRows[0];
|
||||||
const onOrderCost = vendorOrders.reduce((sum, v) => sum + v.cost, 0);
|
const onOrderUnits = firstRow ? parseInt(firstRow.total_units) || 0 : 0;
|
||||||
const onOrderRetail = vendorOrders.reduce((sum, v) => sum + v.retail, 0);
|
const onOrderCost = firstRow ? parseFloat(firstRow.total_cost) || 0 : 0;
|
||||||
|
const onOrderRetail = firstRow ? parseFloat(firstRow.total_retail) || 0 : 0;
|
||||||
|
|
||||||
// Format response to match PurchaseMetricsData interface
|
// Format response to match PurchaseMetricsData interface
|
||||||
const response = {
|
const response = {
|
||||||
@@ -199,8 +203,8 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT pm.pid)::integer as products_to_replenish,
|
COUNT(DISTINCT pm.pid)::integer as products_to_replenish,
|
||||||
COALESCE(SUM(pm.replenishment_units), 0)::integer as total_units_needed,
|
COALESCE(SUM(pm.replenishment_units), 0)::integer as total_units_needed,
|
||||||
ROUND(COALESCE(SUM(pm.replenishment_cost), 0)::numeric, 3) as total_cost,
|
ROUND(COALESCE(SUM(pm.replenishment_cost), 0)::numeric, 2) as total_cost,
|
||||||
ROUND(COALESCE(SUM(pm.replenishment_retail), 0)::numeric, 3) as total_retail
|
ROUND(COALESCE(SUM(pm.replenishment_retail), 0)::numeric, 2) as total_retail
|
||||||
FROM product_metrics pm
|
FROM product_metrics pm
|
||||||
WHERE pm.is_visible = true
|
WHERE pm.is_visible = true
|
||||||
AND pm.is_replenishable = true
|
AND pm.is_replenishable = true
|
||||||
@@ -216,8 +220,8 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
pm.title,
|
pm.title,
|
||||||
pm.current_stock::integer as current_stock,
|
pm.current_stock::integer as current_stock,
|
||||||
pm.replenishment_units::integer as replenish_qty,
|
pm.replenishment_units::integer as replenish_qty,
|
||||||
ROUND(pm.replenishment_cost::numeric, 3) as replenish_cost,
|
ROUND(pm.replenishment_cost::numeric, 2) as replenish_cost,
|
||||||
ROUND(pm.replenishment_retail::numeric, 3) as replenish_retail,
|
ROUND(pm.replenishment_retail::numeric, 2) as replenish_retail,
|
||||||
pm.status,
|
pm.status,
|
||||||
pm.planning_period_days::text as planning_period
|
pm.planning_period_days::text as planning_period
|
||||||
FROM product_metrics pm
|
FROM product_metrics pm
|
||||||
@@ -552,7 +556,7 @@ router.get('/forecast/metrics', async (req, res) => {
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
forecastSales: Math.round(totalUnits),
|
forecastSales: Math.round(totalUnits),
|
||||||
forecastRevenue: totalRevenue.toFixed(2),
|
forecastRevenue: parseFloat(totalRevenue.toFixed(2)),
|
||||||
confidenceLevel,
|
confidenceLevel,
|
||||||
dailyForecasts,
|
dailyForecasts,
|
||||||
dailyForecastsByPhase,
|
dailyForecastsByPhase,
|
||||||
@@ -611,7 +615,7 @@ router.get('/forecast/metrics', async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
forecastSales: Math.round(dailyUnits * days),
|
forecastSales: Math.round(dailyUnits * days),
|
||||||
forecastRevenue: (dailyRevenue * days).toFixed(2),
|
forecastRevenue: parseFloat((dailyRevenue * days).toFixed(2)),
|
||||||
confidenceLevel: 0,
|
confidenceLevel: 0,
|
||||||
dailyForecasts,
|
dailyForecasts,
|
||||||
categoryForecasts: categoryRows.map(c => ({
|
categoryForecasts: categoryRows.map(c => ({
|
||||||
@@ -794,10 +798,10 @@ router.get('/overstock/metrics', async (req, res) => {
|
|||||||
if (parseInt(countCheck.overstock_count) === 0) {
|
if (parseInt(countCheck.overstock_count) === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
overstockedProducts: 0,
|
overstockedProducts: 0,
|
||||||
total_excess_units: 0,
|
totalExcessUnits: 0,
|
||||||
total_excess_cost: 0,
|
totalExcessCost: 0,
|
||||||
total_excess_retail: 0,
|
totalExcessRetail: 0,
|
||||||
category_data: []
|
categoryData: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,8 +810,8 @@ router.get('/overstock/metrics', async (req, res) => {
|
|||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT pid)::integer as total_overstocked,
|
COUNT(DISTINCT pid)::integer as total_overstocked,
|
||||||
SUM(overstocked_units)::integer as total_excess_units,
|
SUM(overstocked_units)::integer as total_excess_units,
|
||||||
ROUND(SUM(overstocked_cost)::numeric, 3) as total_excess_cost,
|
ROUND(SUM(overstocked_cost)::numeric, 2) as total_excess_cost,
|
||||||
ROUND(SUM(overstocked_retail)::numeric, 3) as total_excess_retail
|
ROUND(SUM(overstocked_retail)::numeric, 2) as total_excess_retail
|
||||||
FROM product_metrics
|
FROM product_metrics
|
||||||
WHERE status = 'Overstock'
|
WHERE status = 'Overstock'
|
||||||
AND is_visible = true
|
AND is_visible = true
|
||||||
@@ -819,8 +823,8 @@ router.get('/overstock/metrics', async (req, res) => {
|
|||||||
c.name as category_name,
|
c.name as category_name,
|
||||||
COUNT(DISTINCT pm.pid)::integer as overstocked_products,
|
COUNT(DISTINCT pm.pid)::integer as overstocked_products,
|
||||||
SUM(pm.overstocked_units)::integer as total_excess_units,
|
SUM(pm.overstocked_units)::integer as total_excess_units,
|
||||||
ROUND(SUM(pm.overstocked_cost)::numeric, 3) as total_excess_cost,
|
ROUND(SUM(pm.overstocked_cost)::numeric, 2) as total_excess_cost,
|
||||||
ROUND(SUM(pm.overstocked_retail)::numeric, 3) as total_excess_retail
|
ROUND(SUM(pm.overstocked_retail)::numeric, 2) as total_excess_retail
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||||
JOIN product_metrics pm ON pc.pid = pm.pid
|
JOIN product_metrics pm ON pc.pid = pm.pid
|
||||||
@@ -850,10 +854,10 @@ router.get('/overstock/metrics', async (req, res) => {
|
|||||||
// Format response with explicit type conversion
|
// Format response with explicit type conversion
|
||||||
const response = {
|
const response = {
|
||||||
overstockedProducts: parseInt(summaryMetrics.total_overstocked) || 0,
|
overstockedProducts: parseInt(summaryMetrics.total_overstocked) || 0,
|
||||||
total_excess_units: parseInt(summaryMetrics.total_excess_units) || 0,
|
totalExcessUnits: parseInt(summaryMetrics.total_excess_units) || 0,
|
||||||
total_excess_cost: parseFloat(summaryMetrics.total_excess_cost) || 0,
|
totalExcessCost: parseFloat(summaryMetrics.total_excess_cost) || 0,
|
||||||
total_excess_retail: parseFloat(summaryMetrics.total_excess_retail) || 0,
|
totalExcessRetail: parseFloat(summaryMetrics.total_excess_retail) || 0,
|
||||||
category_data: categoryData.map(cat => ({
|
categoryData: categoryData.map(cat => ({
|
||||||
category: cat.category_name,
|
category: cat.category_name,
|
||||||
products: parseInt(cat.overstocked_products) || 0,
|
products: parseInt(cat.overstocked_products) || 0,
|
||||||
units: parseInt(cat.total_excess_units) || 0,
|
units: parseInt(cat.total_excess_units) || 0,
|
||||||
|
|||||||
@@ -1058,7 +1058,16 @@ router.get('/search-products', async (req, res) => {
|
|||||||
// Build WHERE clause with additional filters
|
// Build WHERE clause with additional filters
|
||||||
let whereClause;
|
let whereClause;
|
||||||
if (pid) {
|
if (pid) {
|
||||||
whereClause = `\n WHERE p.pid = ${connection.escape(Number(pid))}`;
|
const pids = String(pid).split(',').map(Number).filter(n => !isNaN(n) && n > 0);
|
||||||
|
if (pids.length === 0) {
|
||||||
|
connection.release();
|
||||||
|
return res.status(400).json({ error: 'Invalid pid parameter' });
|
||||||
|
}
|
||||||
|
if (pids.length === 1) {
|
||||||
|
whereClause = `\n WHERE p.pid = ${connection.escape(pids[0])}`;
|
||||||
|
} else {
|
||||||
|
whereClause = `\n WHERE p.pid IN (${pids.map(p => connection.escape(p)).join(',')})`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
whereClause = `
|
whereClause = `
|
||||||
WHERE (
|
WHERE (
|
||||||
@@ -1142,12 +1151,13 @@ router.get('/search-products', async (req, res) => {
|
|||||||
p.itemnumber AS sku,
|
p.itemnumber AS sku,
|
||||||
p.upc AS barcode,
|
p.upc AS barcode,
|
||||||
p.harmonized_tariff_code,
|
p.harmonized_tariff_code,
|
||||||
pcp.price_each AS price,
|
MIN(pcp.price_each) AS price,
|
||||||
p.sellingprice AS regular_price,
|
p.sellingprice AS regular_price,
|
||||||
CASE
|
CASE
|
||||||
WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0)
|
WHEN sid.supplier_id = 92 THEN
|
||||||
THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0)
|
CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END
|
||||||
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
ELSE
|
||||||
|
CASE WHEN COALESCE(sid.supplier_cost_each, 0) > 0 THEN sid.supplier_cost_each ELSE sid.notions_cost_each END
|
||||||
END AS cost_price,
|
END AS cost_price,
|
||||||
s.companyname AS vendor,
|
s.companyname AS vendor,
|
||||||
sid.supplier_itemnumber AS vendor_reference,
|
sid.supplier_itemnumber AS vendor_reference,
|
||||||
@@ -1162,7 +1172,7 @@ router.get('/search-products', async (req, res) => {
|
|||||||
p.subline AS subline_id,
|
p.subline AS subline_id,
|
||||||
pc4.name AS artist,
|
pc4.name AS artist,
|
||||||
p.artist AS artist_id,
|
p.artist AS artist_id,
|
||||||
COALESCE(CASE
|
COALESCE(CASE
|
||||||
WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit
|
WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit
|
||||||
ELSE sid.supplier_qty_per_unit
|
ELSE sid.supplier_qty_per_unit
|
||||||
END, sid.notions_qty_per_unit) AS moq,
|
END, sid.notions_qty_per_unit) AS moq,
|
||||||
@@ -1263,12 +1273,13 @@ const PRODUCT_SELECT = `
|
|||||||
p.itemnumber AS sku,
|
p.itemnumber AS sku,
|
||||||
p.upc AS barcode,
|
p.upc AS barcode,
|
||||||
p.harmonized_tariff_code,
|
p.harmonized_tariff_code,
|
||||||
pcp.price_each AS price,
|
MIN(pcp.price_each) AS price,
|
||||||
p.sellingprice AS regular_price,
|
p.sellingprice AS regular_price,
|
||||||
CASE
|
CASE
|
||||||
WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0)
|
WHEN sid.supplier_id = 92 THEN
|
||||||
THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0)
|
CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END
|
||||||
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
ELSE
|
||||||
|
CASE WHEN COALESCE(sid.supplier_cost_each, 0) > 0 THEN sid.supplier_cost_each ELSE sid.notions_cost_each END
|
||||||
END AS cost_price,
|
END AS cost_price,
|
||||||
s.companyname AS vendor,
|
s.companyname AS vendor,
|
||||||
sid.supplier_itemnumber AS vendor_reference,
|
sid.supplier_itemnumber AS vendor_reference,
|
||||||
@@ -1884,4 +1895,772 @@ router.get('/product-categories/:pid', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
// Build a PIDs-from-query SQL using product_query_filter rows (mirrors productquery.class.php logic)
|
||||||
|
// NOTE: PRODUCT_SELECT uses aliases: products→p, current_inventory→ci, supplier_item_data→sid
|
||||||
|
// All filter conditions must use those aliases. Extra JOINs (category index, etc.) are appended.
|
||||||
|
//
|
||||||
|
// Constants sourced from registry.class.php / product_category.class.php (ACOT store id confirmed by user):
|
||||||
|
// ACOT_STORE=0, SRC_ACOT=10, SRC_PREORDER=11, SRC_NOTIONS=13
|
||||||
|
// CAT types: section=10, cat=11, subcat=12, subsubcat=13, theme=20, subtheme=21, digitheme=30
|
||||||
|
function buildQueryFilterSql(filters) {
|
||||||
|
// filterGroups: Map<key, { conditions: string[], isNot: boolean, ororor: boolean }>
|
||||||
|
// ororor=true means this group is OR-connected to the previous group (PHP filter_or mechanism)
|
||||||
|
const filterGroups = new Map();
|
||||||
|
const joinTables = new Map(); // alias -> JOIN clause
|
||||||
|
const unsupported = [];
|
||||||
|
|
||||||
|
function addGroup(key, condition, isNot = false, ororor = false) {
|
||||||
|
if (!filterGroups.has(key)) filterGroups.set(key, { conditions: [], isNot, ororor });
|
||||||
|
filterGroups.get(key).conditions.push(condition);
|
||||||
|
}
|
||||||
|
function addJoin(alias, clause) {
|
||||||
|
if (!joinTables.has(alias)) joinTables.set(alias, clause);
|
||||||
|
}
|
||||||
|
// INNER JOIN on product_category_index (product must have this category to appear)
|
||||||
|
function ciJoin(alias) {
|
||||||
|
addJoin(alias, `JOIN product_category_index AS ${alias} ON (p.pid=${alias}.pid)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate operator string to SQL operator
|
||||||
|
function toSqlOp(op) {
|
||||||
|
const map = {
|
||||||
|
equals: '=', notequals: '<>', greater: '>', greater_equals: '>=',
|
||||||
|
less: '<', less_equals: '<=', between: ' BETWEEN ',
|
||||||
|
contains: ' LIKE ', notcontains: ' NOT LIKE ', begins: ' LIKE ',
|
||||||
|
true: '<>0', true1: '=1', false: '=0', isnull: ' IS NULL',
|
||||||
|
};
|
||||||
|
return map[op] || '=';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proper MySQL string escaping (mirrors mysql_real_escape_string order)
|
||||||
|
function strVal(v) {
|
||||||
|
return String(v)
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\0/g, '\\0')
|
||||||
|
.replace(/\n/g, '\\n')
|
||||||
|
.replace(/\r/g, '\\r')
|
||||||
|
.replace(/'/g, "\\'")
|
||||||
|
.replace(/\x1a/g, '\\Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL-decode then escape — mirrors PHP: safefor_query(urldecode($row['filter_text1']))
|
||||||
|
function decode(v) {
|
||||||
|
if (!v) return '';
|
||||||
|
try { return decodeURIComponent(String(v)); } catch { return String(v); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric field with BETWEEN support (appends AND t2 when operator is BETWEEN)
|
||||||
|
function numFilt(field, sqlOp, t1, t2) {
|
||||||
|
if (sqlOp.trim() === 'BETWEEN' && t2) return `${field} BETWEEN ${parseFloat(t1) || 0} AND ${parseFloat(t2) || 0}`;
|
||||||
|
return `${field}${sqlOp}${parseFloat(t1) || 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date/string field with BETWEEN support
|
||||||
|
function dateFilt(field, sqlOp, t1, t2) {
|
||||||
|
if (sqlOp.trim() === 'BETWEEN' && t2) return `${field} BETWEEN '${strVal(t1)}' AND '${strVal(t2)}'`;
|
||||||
|
return `${field}${sqlOp}'${strVal(t1)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store/source constants (registry.class.php; ACOT store id confirmed by user)
|
||||||
|
const ACOT_STORE = 0;
|
||||||
|
const SRC_ACOT = 10;
|
||||||
|
const SRC_PREORDER = 11;
|
||||||
|
const SRC_NOTIONS = 13;
|
||||||
|
|
||||||
|
// Category type constants (product_category.class.php)
|
||||||
|
const CAT_THEMES = '20,21';
|
||||||
|
const CAT_CATEGORIES_NO_SECTION = '11,12,13';
|
||||||
|
|
||||||
|
for (const row of filters) {
|
||||||
|
const sqlOp = toSqlOp(row.filter_operator);
|
||||||
|
const isNot = sqlOp === '<>' || row.filter_operator === 'notequals' || row.filter_operator === 'notcontains';
|
||||||
|
const t1 = decode(row.filter_text1);
|
||||||
|
const t2 = decode(row.filter_text2);
|
||||||
|
const filterOr = Boolean(row.filter_or);
|
||||||
|
|
||||||
|
switch (row.filter_type) {
|
||||||
|
|
||||||
|
// ── products table (aliased as p) ──────────────────────────────────────
|
||||||
|
case 'company':
|
||||||
|
if (t1 && t2) {
|
||||||
|
const filt = `(p.company=${parseFloat(t1)||0} AND p.line=${parseFloat(t2)||0})`;
|
||||||
|
addGroup('company' + sqlOp, isNot ? `NOT ${filt}` : filt, isNot);
|
||||||
|
} else {
|
||||||
|
addGroup('company' + sqlOp, `p.company${sqlOp}${parseFloat(t1)||0}`, isNot);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'line': addGroup('line' + sqlOp, numFilt('p.line', sqlOp, t1, t2), isNot); break;
|
||||||
|
case 'subline': addGroup('subline' + sqlOp, numFilt('p.subline', sqlOp, t1, t2), isNot); break;
|
||||||
|
case 'no_company': addGroup('no_company', 'p.company=0'); break;
|
||||||
|
case 'no_line': addGroup('no_line', 'p.line=0'); break;
|
||||||
|
case 'no_subline': addGroup('no_subline', 'p.subline=0'); break;
|
||||||
|
case 'artist': addGroup('artist' + sqlOp, numFilt('p.artist', sqlOp, t1, t2), isNot); break;
|
||||||
|
case 'size_cat': addGroup('size_cat' + sqlOp, numFilt('p.size_cat', sqlOp, t1, t2), isNot); break;
|
||||||
|
case 'dimension': addGroup('dimension' + sqlOp, numFilt('p.dimension', sqlOp, t1, t2), isNot); break;
|
||||||
|
case 'yarn_weight': addGroup('yarn_weight' + sqlOp, numFilt('p.yarn_weight', sqlOp, t1, t2), isNot); break;
|
||||||
|
case 'material': addGroup('material' + sqlOp, numFilt('p.material', sqlOp, t1, t2), isNot); break;
|
||||||
|
case 'weight': addGroup('weight' + sqlOp, numFilt('p.weight', sqlOp, t1, t2)); break;
|
||||||
|
case 'weight_price_ratio': addGroup('weight' + sqlOp, numFilt('p.weight/p.price_for_sort', sqlOp, t1, t2)); break;
|
||||||
|
case 'price_weight_ratio': addGroup('weight' + sqlOp, numFilt('p.price_for_sort/p.weight', sqlOp, t1, t2)); break;
|
||||||
|
case 'length': addGroup('length' + sqlOp, numFilt('p.length', sqlOp, t1, t2)); break;
|
||||||
|
case 'width': addGroup('width' + sqlOp, numFilt('p.width', sqlOp, t1, t2)); break;
|
||||||
|
case 'height': addGroup('height' + sqlOp, numFilt('p.height', sqlOp, t1, t2)); break;
|
||||||
|
case 'no_dim': addGroup('no_dim', 'p.length=0 AND p.width=0 AND p.height=0'); break;
|
||||||
|
case 'hide': addGroup('hide', `p.hide${sqlOp}`); break;
|
||||||
|
case 'hide_in_shop':addGroup('hide_in_shop', `p.hide_in_shop${sqlOp}`); break;
|
||||||
|
case 'discontinued':addGroup('discontinued', `p.discontinued${sqlOp}`); break;
|
||||||
|
case 'force_flag': addGroup('force_flag', `p.force_flag${sqlOp}`); break;
|
||||||
|
case 'exclusive': addGroup('exclusive', `p.exclusive${sqlOp}`); break;
|
||||||
|
case 'lock_quantity': addGroup('lock_quantity', `p.lock_qty${sqlOp}`); break;
|
||||||
|
case 'show_notify': addGroup('show_notify', `p.show_notify${sqlOp}`); break;
|
||||||
|
case 'downloadable':addGroup('downloadable', `p.downloadable${sqlOp}`); break;
|
||||||
|
case 'usa_only': addGroup('usa_only', `p.usa_only${sqlOp}`); break;
|
||||||
|
case 'not_clearance': addGroup('not_clearance', `p.not_clearance${sqlOp}`); break;
|
||||||
|
case 'stat_stop': addGroup('stat_stop', `p.stat_stop${sqlOp}`); break;
|
||||||
|
case 'notnew': addGroup('notnew', `p.notnew${sqlOp}`); break;
|
||||||
|
case 'not_backinstock': addGroup('not_backinstock', `p.not_backinstock${sqlOp}`); break;
|
||||||
|
case 'reorder': addGroup('reorder', `p.reorder${sqlOp}${parseFloat(t1)||0}`); break;
|
||||||
|
case 'score': addGroup('score' + sqlOp, numFilt('p.score', sqlOp, t1, t2)); break;
|
||||||
|
case 'sold_view_score': addGroup('sold_view_score' + sqlOp, numFilt('p.sold_view_score', sqlOp, t1, t2)); break;
|
||||||
|
case 'visibility_score': addGroup('visibility_score' + sqlOp, numFilt('p.visibility_score', sqlOp, t1, t2)); break;
|
||||||
|
case 'health_score': addGroup('health_score' + sqlOp, `p.health_score${sqlOp}'${strVal(t1)}'`); break;
|
||||||
|
case 'tax_code': addGroup('tax_code', `p.tax_code${sqlOp}${parseFloat(t1)||0}`); break;
|
||||||
|
case 'investor': addGroup('investor' + sqlOp, `p.investorid${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||||
|
case 'price':
|
||||||
|
case 'default_price':
|
||||||
|
addGroup('default_price' + sqlOp, numFilt('p.sellingprice', sqlOp, t1, t2), isNot); break;
|
||||||
|
case 'price_for_sort':
|
||||||
|
addGroup('price_for_sort' + sqlOp, numFilt('p.price_for_sort', sqlOp, t1, t2)); break;
|
||||||
|
case 'salepercent_for_sort':
|
||||||
|
case 'salepercent_for_sort__clearance': {
|
||||||
|
// PHP divides by 100 if value > 1 (percentages stored as decimals)
|
||||||
|
const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0);
|
||||||
|
const v2 = t2 ? ((parseFloat(t2)||0) > 1 ? (parseFloat(t2)/100) : (parseFloat(t2)||0)) : null;
|
||||||
|
const filt = (sqlOp.trim() === 'BETWEEN' && v2 != null)
|
||||||
|
? `p.salepercent_for_sort BETWEEN ${v1} AND ${v2}`
|
||||||
|
: `p.salepercent_for_sort${sqlOp}${v1}`;
|
||||||
|
addGroup(row.filter_type + sqlOp, filt); break;
|
||||||
|
}
|
||||||
|
case 'is_clearance':
|
||||||
|
addGroup('is_clearance' + sqlOp, `(p.clearance_date != '0000-00-00 00:00:00')${sqlOp}`); break;
|
||||||
|
case 'msrp': addGroup('msrp' + sqlOp, numFilt('p.msrp', sqlOp, t1, t2)); break;
|
||||||
|
case 'default_less_msrp':addGroup('default_less_msrp', '(p.sellingprice < p.msrp)'); break;
|
||||||
|
case 'default_more_msrp':addGroup('default_more_msrp', '(p.sellingprice > p.msrp)'); break;
|
||||||
|
case 'minimum_advertised_price':
|
||||||
|
addGroup('map', numFilt('p.minimum_advertised_price', sqlOp, t1, t2)); break;
|
||||||
|
case 'minimum_advertised_price_error':
|
||||||
|
addGroup('map_error', row.filter_operator !== 'false'
|
||||||
|
? 'p.minimum_advertised_price > p.sellingprice'
|
||||||
|
: 'p.minimum_advertised_price <= p.sellingprice');
|
||||||
|
break;
|
||||||
|
case 'wholesale_discount': {
|
||||||
|
const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0);
|
||||||
|
const v2 = t2 ? ((parseFloat(t2)||0) > 1 ? (parseFloat(t2)/100) : (parseFloat(t2)||0)) : null;
|
||||||
|
const filt = (sqlOp.trim() === 'BETWEEN' && v2 != null)
|
||||||
|
? `p.wholesale_discount BETWEEN ${v1} AND ${v2}`
|
||||||
|
: `p.wholesale_discount${sqlOp}${v1}`;
|
||||||
|
addGroup('wholesale_discount' + sqlOp, filt); break;
|
||||||
|
}
|
||||||
|
case 'wholesale_unit_qty': addGroup('wholesale_unit_qty' + sqlOp, numFilt('p.wholesale_unit_qty', sqlOp, t1, t2)); break;
|
||||||
|
case 'points_multiplier': addGroup('points_multiplier' + sqlOp, numFilt('p.points_multiplier', sqlOp, t1, t2)); break;
|
||||||
|
case 'points_bonus': addGroup('points_bonus' + sqlOp, numFilt('p.points_bonus', sqlOp, t1, t2)); break;
|
||||||
|
case 'points_extra': addGroup('points_extra', '(p.points_multiplier>.5 OR p.points_bonus>0)'); break;
|
||||||
|
case 'auto_pricing_allowed': addGroup('auto_pricing', 'p.price_lock=0'); break;
|
||||||
|
case 'auto_pricing_disallowed': addGroup('auto_pricing', 'p.price_lock=1'); break;
|
||||||
|
case 'handling_fee': addGroup('handling_fee' + sqlOp, numFilt('p.handling_fee', sqlOp, t1, t2)); break;
|
||||||
|
case 'ship_tier': addGroup('ship_tier' + sqlOp, numFilt('p.shipping_tier', sqlOp, t1, t2)); break;
|
||||||
|
case 'shipping_restrictions': addGroup('ship_tier' + sqlOp, `p.shipping_restrictions${sqlOp}${parseFloat(t1)||0}`); break;
|
||||||
|
case 'min_qty_wanted': addGroup('min_qty_wanted', numFilt('p.min_qty_wanted', sqlOp, t1, t2)); break;
|
||||||
|
case 'qty_bundled': addGroup('min_qty_wanted', numFilt('p.qty_bundled', sqlOp, t1, t2)); break;
|
||||||
|
case 'no_40_percent_promo': addGroup('no_40_percent_promo', `p.no_40_percent_promo${sqlOp}`); break;
|
||||||
|
case 'exclude_google_feed': addGroup('exclude_google_feed', `p.exclude_google_feed${sqlOp}`); break;
|
||||||
|
case 'store': {
|
||||||
|
const bit = Math.pow(2, parseInt(t1) || 0);
|
||||||
|
addGroup('store_' + (parseInt(t1)||0), `p.store & ${bit}${sqlOp}${bit}`); break;
|
||||||
|
}
|
||||||
|
case 'no_store': addGroup('store_' + (parseInt(t1)||0), 'p.store=0'); break;
|
||||||
|
case 'location': {
|
||||||
|
let filt = `p.aisle${sqlOp}'${strVal(t1)}'`;
|
||||||
|
if (t2) filt += ` AND p.rack${sqlOp}'${strVal(t2)}'`;
|
||||||
|
addGroup('location' + sqlOp, filt); break;
|
||||||
|
}
|
||||||
|
case 'name':
|
||||||
|
addGroup('name' + sqlOp,
|
||||||
|
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||||
|
? `p.description${sqlOp}'%${strVal(t1)}%'`
|
||||||
|
: `p.description${sqlOp}'${strVal(t1)}'`, isNot);
|
||||||
|
break;
|
||||||
|
case 'short_description':
|
||||||
|
addGroup('short_description' + sqlOp,
|
||||||
|
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||||
|
? `p.description_short${sqlOp}'%${strVal(t1)}%'`
|
||||||
|
: `p.description_short${sqlOp}'${strVal(t1)}'`, isNot);
|
||||||
|
break;
|
||||||
|
case 'description':
|
||||||
|
addGroup('description' + sqlOp,
|
||||||
|
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||||
|
? `p.notes${sqlOp}'%${strVal(t1)}%'`
|
||||||
|
: `p.notes${sqlOp}'${strVal(t1)}'`, isNot);
|
||||||
|
break;
|
||||||
|
case 'description_char_count':
|
||||||
|
addGroup('description_char_count' + sqlOp, `CHAR_LENGTH(p.notes)${sqlOp}${parseFloat(t1)||0}`); break;
|
||||||
|
case 'description2':
|
||||||
|
addGroup('description2' + sqlOp,
|
||||||
|
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||||
|
? `p.notes2${sqlOp}'%${strVal(t1)}%'`
|
||||||
|
: `p.notes2${sqlOp}'${strVal(t1)}'`, isNot);
|
||||||
|
break;
|
||||||
|
case 'keyword':
|
||||||
|
addGroup('keyword' + sqlOp,
|
||||||
|
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||||
|
? `p.keyword1${sqlOp}'%${strVal(t1)}%'`
|
||||||
|
: `p.keyword1${sqlOp}'${strVal(t1)}'`, isNot);
|
||||||
|
break;
|
||||||
|
case 'notes':
|
||||||
|
addGroup('notes' + sqlOp,
|
||||||
|
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||||
|
? `p.priv_notes${sqlOp}'%${strVal(t1)}%'`
|
||||||
|
: `p.priv_notes${sqlOp}'${strVal(t1)}'`, isNot);
|
||||||
|
break;
|
||||||
|
case 'price_notes':
|
||||||
|
addGroup('price_notes' + sqlOp,
|
||||||
|
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||||
|
? `p.price_notes${sqlOp}'%${strVal(t1)}%'`
|
||||||
|
: `p.price_notes${sqlOp}'${strVal(t1)}'`);
|
||||||
|
break;
|
||||||
|
case 'itemnumber':
|
||||||
|
addGroup('itemnumber_' + sqlOp, `p.itemnumber${sqlOp}'${strVal(t1)}'`, isNot); break;
|
||||||
|
case 'pid':
|
||||||
|
case 'pid_auto': {
|
||||||
|
// filter_or=true means OR-connect this group to the previous one (PHP OROROR mechanism)
|
||||||
|
const key = 'pid' + sqlOp + (filterOr ? 'OROROR' : '');
|
||||||
|
addGroup(key, `p.pid${sqlOp}${parseInt(t1) || 0}`, isNot, filterOr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'upc':
|
||||||
|
addGroup('upc' + sqlOp, `p.upc${sqlOp}'${strVal(t1)}'`, isNot); break;
|
||||||
|
case 'size':
|
||||||
|
addGroup('size' + sqlOp,
|
||||||
|
row.filter_operator === 'begins' ? `p.size LIKE '${strVal(t1)}%'` : `p.size${sqlOp}'${strVal(t1)}'`, isNot);
|
||||||
|
break;
|
||||||
|
case 'country_of_origin':
|
||||||
|
addGroup('country_of_origin',
|
||||||
|
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||||
|
? `p.country_of_origin${sqlOp}'%${strVal(t1)}%'`
|
||||||
|
: `p.country_of_origin${sqlOp}'${strVal(t1)}'`);
|
||||||
|
break;
|
||||||
|
case 'date_in': addGroup('date_in' + sqlOp, dateFilt('DATE(p.datein)', sqlOp, t1, t2)); break;
|
||||||
|
case 'date_in_days':
|
||||||
|
case 'age': addGroup('date_in' + sqlOp, numFilt('DATEDIFF(NOW(),p.datein)', sqlOp, t1, t2)); break;
|
||||||
|
case 'date_created': addGroup('date_created' + sqlOp, dateFilt('p.date_created', sqlOp, t1, t2)); break;
|
||||||
|
case 'date_created_days': addGroup('date_created' + sqlOp, numFilt('DATEDIFF(NOW(),p.date_created)', sqlOp, t1, t2)); break;
|
||||||
|
case 'date_modified': addGroup('date_modified' + sqlOp, dateFilt('p.stamp', sqlOp, t1, t2)); break;
|
||||||
|
case 'date_modified_days': addGroup('date_modified' + sqlOp, numFilt('DATEDIFF(NOW(),p.stamp)', sqlOp, t1, t2)); break;
|
||||||
|
case 'date_refill': addGroup('date_refill' + sqlOp, dateFilt('p.date_refill', sqlOp, t1, t2)); break;
|
||||||
|
case 'date_refill_days': addGroup('date_refill' + sqlOp, numFilt('DATEDIFF(NOW(),p.date_refill)', sqlOp, t1, t2)); break;
|
||||||
|
case 'new':
|
||||||
|
addGroup('new', `DATEDIFF(NOW(),p.date_ol) <= ${parseInt(t1) || 45}`);
|
||||||
|
addGroup('notnew', 'p.notnew=0');
|
||||||
|
break;
|
||||||
|
case 'new_in':
|
||||||
|
addGroup('new2', `p.datein BETWEEN NOW()-INTERVAL ${parseInt(t1) || 30} DAY AND NOW()`);
|
||||||
|
addGroup('notnew', 'p.notnew=0');
|
||||||
|
break;
|
||||||
|
case 'backinstock':
|
||||||
|
addGroup('backinstock_1', `p.date_refill BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW()`);
|
||||||
|
addGroup('backinstock_2', 'p.date_refill > p.datein');
|
||||||
|
addGroup('backinstock_3', 'NOT (p.datein BETWEEN NOW()-INTERVAL 30 DAY AND NOW())');
|
||||||
|
break;
|
||||||
|
case 'arrivals':
|
||||||
|
addGroup('arrivals', `(p.date_ol BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW() AND p.notnew=0)`);
|
||||||
|
addGroup('arrivals', `(p.date_refill BETWEEN NOW()-INTERVAL ${parseInt(t1)||30} DAY AND NOW() AND p.date_refill > p.datein)`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── current_inventory (aliased as ci, already LEFT JOINed in PRODUCT_SELECT) ──
|
||||||
|
case 'count': addGroup('count' + sqlOp, numFilt('ci.available', sqlOp, t1, t2)); break;
|
||||||
|
case 'count_onhand': addGroup('count_onhand' + sqlOp, numFilt('(ci.count-ci.pending)', sqlOp, t1, t2)); break;
|
||||||
|
case 'count_shelf': addGroup('count_shelf' + sqlOp, numFilt('ci.count_shelf', sqlOp, t1, t2)); break;
|
||||||
|
case 'on_order': addGroup('on_order' + sqlOp, numFilt('ci.onorder', sqlOp, t1, t2)); break;
|
||||||
|
case 'on_preorder': addGroup('on_preorder' + sqlOp, numFilt('ci.onpreorder', sqlOp, t1, t2)); break;
|
||||||
|
case 'infinite': addGroup('infinite', `ci.infinite${sqlOp}`); break;
|
||||||
|
case 'pending': addGroup('pending', numFilt('ci.pending', sqlOp, t1, t2)); break;
|
||||||
|
case 'date_sold': addGroup('date_sold' + sqlOp, numFilt('DATEDIFF(NOW(),ci.lastsolddate)', sqlOp, t1, t2)); break;
|
||||||
|
case 'average_cost': addGroup('avg_cost' + sqlOp, numFilt('ci.avg_cost', sqlOp, t1, t2)); break;
|
||||||
|
case 'markup': addGroup('markup' + sqlOp, numFilt('(p.sellingprice/ci.avg_cost*100-100)', sqlOp, t1, t2)); break;
|
||||||
|
case 'total_sold': addGroup('total_sold' + sqlOp, numFilt('ci.totalsold', sqlOp, t1, t2)); break;
|
||||||
|
case 'inbaskets': addGroup('inbaskets', numFilt('ci.baskets', sqlOp, t1, t2)); break;
|
||||||
|
|
||||||
|
// ── supplier_item_data (aliased as sid, already LEFT JOINed in PRODUCT_SELECT) ──
|
||||||
|
case 'supplier':
|
||||||
|
addGroup('supplier' + sqlOp, `sid.supplier_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||||
|
case 'supplier_cost_each':
|
||||||
|
addGroup('supplier_cost_each' + sqlOp, numFilt('sid.supplier_cost_each', sqlOp, t1, t2)); break;
|
||||||
|
case 'notions_cost_each':
|
||||||
|
addGroup('notions_cost_each' + sqlOp, numFilt('sid.notions_cost_each', sqlOp, t1, t2)); break;
|
||||||
|
case 'supplier_qty_per_unit':
|
||||||
|
addGroup('supplier_qty_per_unit' + sqlOp, numFilt('sid.supplier_qty_per_unit', sqlOp, t1, t2)); break;
|
||||||
|
case 'notions_qty_per_unit':
|
||||||
|
addGroup('notions_qty_per_unit' + sqlOp, numFilt('sid.notions_qty_per_unit', sqlOp, t1, t2)); break;
|
||||||
|
case 'case_pack':
|
||||||
|
addGroup('case_pack', `sid.notions_case_pack${sqlOp}${parseFloat(t1)||0}`); break;
|
||||||
|
case 'notions_discontinued':
|
||||||
|
addGroup('notions_discontinued' + sqlOp, `sid.notions_discontinued${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||||
|
case 'missing_any_cost':
|
||||||
|
addGroup('missing_any_cost', 'sid.supplier_cost_each=0 OR sid.notions_cost_each=0 OR sid.supplier_cost_each IS NULL OR sid.notions_cost_each IS NULL OR (SELECT COUNT(*) FROM product_inventory WHERE product_inventory.pid=p.pid)=0');
|
||||||
|
break;
|
||||||
|
case 'notions_itemnumber': {
|
||||||
|
// Use a separate LEFT JOIN alias so IS NULL check works correctly
|
||||||
|
addJoin('sid_l', 'LEFT JOIN supplier_item_data AS sid_l ON (p.pid=sid_l.pid)');
|
||||||
|
let filt = `sid_l.notions_itemnumber${sqlOp}'${strVal(t1)}'`;
|
||||||
|
if (sqlOp === '=' && t1 === '') filt += ' OR sid_l.notions_itemnumber IS NULL';
|
||||||
|
addGroup('notions_itemnumber' + sqlOp, filt, isNot); break;
|
||||||
|
}
|
||||||
|
case 'supplier_itemnumber': {
|
||||||
|
addJoin('sid_l', 'LEFT JOIN supplier_item_data AS sid_l ON (p.pid=sid_l.pid)');
|
||||||
|
let filt = `sid_l.supplier_itemnumber${sqlOp}'${strVal(t1)}'`;
|
||||||
|
if (sqlOp === '=' && t1 === '') filt += ' OR sid_l.supplier_itemnumber IS NULL';
|
||||||
|
addGroup('supplier_itemnumber' + sqlOp, filt, isNot); break;
|
||||||
|
}
|
||||||
|
case 'supplier_cost_each_grouped':
|
||||||
|
addGroup('supplier_cost_each' + sqlOp, numFilt('sid.supplier_cost_each', sqlOp, t1, t2)); break;
|
||||||
|
|
||||||
|
// ── category index: extra INNER JOINs needed ───────────────────────────
|
||||||
|
case 'type':
|
||||||
|
case 'cat':
|
||||||
|
ciJoin('product_ci_cat');
|
||||||
|
addGroup('cat' + sqlOp, `product_ci_cat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||||
|
case 'cat2':
|
||||||
|
ciJoin('product_ci_cat2');
|
||||||
|
addGroup('cat2' + sqlOp, `product_ci_cat2.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||||
|
case 'subtype':
|
||||||
|
case 'subcat':
|
||||||
|
ciJoin('product_ci_subcat');
|
||||||
|
addGroup('subcat' + sqlOp, `product_ci_subcat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||||
|
case 'subsubcat':
|
||||||
|
ciJoin('product_ci_subsubcat');
|
||||||
|
addGroup('subsubcat' + sqlOp, `product_ci_subsubcat.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||||
|
case 'section':
|
||||||
|
ciJoin('product_ci_section');
|
||||||
|
addGroup('section' + sqlOp, `product_ci_section.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||||
|
case 'theme':
|
||||||
|
ciJoin('product_ci_theme');
|
||||||
|
addGroup('themes' + sqlOp, `product_ci_theme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||||
|
case 'subtheme':
|
||||||
|
ciJoin('product_ci_subtheme');
|
||||||
|
addGroup('subtheme' + sqlOp, `product_ci_subtheme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||||
|
case 'digitheme':
|
||||||
|
ciJoin('product_ci_digitheme');
|
||||||
|
addGroup('digithemes' + sqlOp, `product_ci_digitheme.cat_id${sqlOp}${parseFloat(t1)||0}`, isNot); break;
|
||||||
|
case 'all_categories':
|
||||||
|
if (sqlOp === '=') {
|
||||||
|
ciJoin('product_ci_allcats');
|
||||||
|
addGroup('allcategories=', `product_ci_allcats.cat_id=${parseFloat(t1)||0}`);
|
||||||
|
} else {
|
||||||
|
const alias = `not_ci_${parseInt(t1) || 0}`;
|
||||||
|
addJoin(alias, `LEFT JOIN product_category_index AS ${alias} ON (${alias}.pid=p.pid AND ${alias}.cat_id=${parseFloat(t1)||0})`);
|
||||||
|
addGroup('allcategories<>', `${alias}.cat_id IS NULL`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'not_section':
|
||||||
|
addGroup('not_section', `(SELECT 1 FROM product_category_index WHERE product_category_index.pid=p.pid AND cat_id=${parseInt(t1)||0}) IS NULL`);
|
||||||
|
break;
|
||||||
|
case 'all_themes':
|
||||||
|
if (sqlOp === '=') {
|
||||||
|
ciJoin('product_ci_allthemes');
|
||||||
|
addGroup('allthemes=', `product_ci_allthemes.cat_id=${parseFloat(t1)||0}`);
|
||||||
|
} else {
|
||||||
|
addJoin('product_ci_not_allthemes', `LEFT JOIN product_category_index AS product_ci_not_allthemes ON (p.pid=product_ci_not_allthemes.pid AND product_ci_not_allthemes.cat_id=${parseFloat(t1)||0})`);
|
||||||
|
addGroup('allthemes<>', 'product_ci_not_allthemes.pid IS NULL');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'has_any_category':
|
||||||
|
addGroup('has_any_category', '(SELECT COUNT(*) FROM product_category_index WHERE product_category_index.pid=p.pid)>0'); break;
|
||||||
|
case 'has_themes':
|
||||||
|
addGroup('has_themes', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_THEMES})) WHERE product_category_index.pid=p.pid)>0`); break;
|
||||||
|
case 'no_themes':
|
||||||
|
addGroup('no_themes', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_THEMES})) WHERE product_category_index.pid=p.pid)=0`); break;
|
||||||
|
case 'no_categories':
|
||||||
|
addGroup('no_categories', `(SELECT COUNT(*) FROM product_category_index JOIN product_categories ON (product_category_index.cat_id=product_categories.cat_id AND product_categories.type IN (${CAT_CATEGORIES_NO_SECTION})) WHERE product_category_index.pid=p.pid)=0`); break;
|
||||||
|
|
||||||
|
// ── color: extra JOIN product_colors ──────────────────────────────────
|
||||||
|
case 'color':
|
||||||
|
addJoin('product_colors', 'JOIN product_colors ON (p.pid=product_colors.pid)');
|
||||||
|
addGroup('color' + sqlOp, `product_colors.color${sqlOp}${parseInt(t1)||0}`, isNot); break;
|
||||||
|
|
||||||
|
// ── product_inventory: extra JOIN (GROUP BY p.pid covers duplicates) ──
|
||||||
|
case 'cost':
|
||||||
|
addJoin('product_inventory', 'JOIN product_inventory ON (product_inventory.pid=p.pid)');
|
||||||
|
addGroup('cost' + sqlOp, numFilt('product_inventory.costeach', sqlOp, t1, t2)); break;
|
||||||
|
case 'original_cost':
|
||||||
|
addJoin('product_inventory', 'JOIN product_inventory ON (product_inventory.pid=p.pid)');
|
||||||
|
addGroup('orig_cost' + sqlOp, numFilt('product_inventory.orig_costeach', sqlOp, t1, t2)); break;
|
||||||
|
case 'total_product_value':
|
||||||
|
addGroup('total_product_value' + sqlOp,
|
||||||
|
`(SELECT SUM(product_inventory.costeach*product_inventory.count) FROM product_inventory WHERE product_inventory.pid=p.pid)${sqlOp}${parseFloat(t1)||0}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── shop_inventory: extra LEFT JOIN ───────────────────────────────────
|
||||||
|
// All shop_* filters share alias 'shop_inv' so they add only one JOIN
|
||||||
|
case 'buyable':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_cond', `shop_inv.store=${ACOT_STORE} AND shop_inv.buyable=1`); break;
|
||||||
|
case 'not_buyable':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_cond', `shop_inv.store=${ACOT_STORE} AND shop_inv.buyable=0`); break;
|
||||||
|
case 'shop_available':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_store', `shop_inv.store=${ACOT_STORE}`);
|
||||||
|
addGroup('shop_inv_avail' + sqlOp, numFilt('shop_inv.available', sqlOp, t1, t2)); break;
|
||||||
|
case 'shop_available_local': {
|
||||||
|
addJoin('shop_inv_local', `LEFT JOIN shop_inventory AS shop_inv_local ON (p.pid=shop_inv_local.pid AND shop_inv_local.store=${ACOT_STORE})`);
|
||||||
|
const filt = sqlOp === '<'
|
||||||
|
? `(shop_inv_local.available_local${sqlOp}${parseFloat(t1)||0} OR shop_inv_local.available_local IS NULL)`
|
||||||
|
: numFilt('shop_inv_local.available_local', sqlOp, t1, t2);
|
||||||
|
addGroup('shop_avail_local' + sqlOp, filt); break;
|
||||||
|
}
|
||||||
|
case 'shop_show':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.show=1`); break;
|
||||||
|
case 'shop_show_in':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_cond', `shop_inv.store IN (${strVal(t1)}) AND shop_inv.show=1`); break;
|
||||||
|
case 'shop_buyable':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.buyable=1`); break;
|
||||||
|
case 'shop_preorder':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.\`all\`=2`); break;
|
||||||
|
case 'shop_not_preorder':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.\`all\`!=2`); break;
|
||||||
|
case 'shop_inventory_source_acot':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source=${SRC_ACOT}`); break;
|
||||||
|
case 'shop_inventory_source_acot_or_pre':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source IN (${SRC_ACOT},${SRC_PREORDER})`); break;
|
||||||
|
case 'shop_inventory_source_notions':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source=${SRC_NOTIONS}`); break;
|
||||||
|
case 'shop_inventory_source_not_notions':
|
||||||
|
addJoin('shop_inv', 'LEFT JOIN shop_inventory AS shop_inv ON (p.pid=shop_inv.pid)');
|
||||||
|
addGroup('shop_inv_cond', `shop_inv.store=${parseInt(t1)||0} AND shop_inv.inventory_source!=${SRC_NOTIONS}`); break;
|
||||||
|
case 'preorder_item':
|
||||||
|
addJoin('shop_inv_pre', `LEFT JOIN shop_inventory AS shop_inv_pre ON (p.pid=shop_inv_pre.pid AND shop_inv_pre.store=${ACOT_STORE})`);
|
||||||
|
addGroup('preorder_item', `shop_inv_pre.inventory_source=${SRC_PREORDER}`); break;
|
||||||
|
case 'not_preorder_item':
|
||||||
|
addJoin('shop_inv_pre', `LEFT JOIN shop_inventory AS shop_inv_pre ON (p.pid=shop_inv_pre.pid AND shop_inv_pre.store=${ACOT_STORE})`);
|
||||||
|
addGroup('preorder_item', `shop_inv_pre.inventory_source!=${SRC_PREORDER} OR shop_inv_pre.inventory_source IS NULL`); break;
|
||||||
|
|
||||||
|
// ── product_notions: extra JOIN ────────────────────────────────────────
|
||||||
|
case 'notions_use_inventory':
|
||||||
|
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||||
|
addGroup('notions_use_inv', `product_notions.use_inventory${sqlOp}`); break;
|
||||||
|
case 'notions_inventory':
|
||||||
|
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||||
|
addGroup('notions_inv', `product_notions.inventory${sqlOp}${parseInt(t1)||0}`); break;
|
||||||
|
case 'notions_sell_qty':
|
||||||
|
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||||
|
addGroup('notions_sell_qty', `product_notions.sell_qty${sqlOp}${parseInt(t1)||0}`); break;
|
||||||
|
case 'notions_csc':
|
||||||
|
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||||
|
addGroup('notions_csc',
|
||||||
|
(row.filter_operator === 'contains' || row.filter_operator === 'notcontains')
|
||||||
|
? `product_notions.csc${sqlOp}'%${strVal(t1)}%'`
|
||||||
|
: `product_notions.csc${sqlOp}'${strVal(t1)}'`);
|
||||||
|
break;
|
||||||
|
case 'notions_top_sellers':
|
||||||
|
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||||
|
addGroup('notions_top_sellers', 'product_notions.top_seller>0'); break;
|
||||||
|
case 'notions_cost_higher_than_selling_price':
|
||||||
|
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||||
|
addGroup('notions_cost_vs_sell', '(product_notions.sell_cost*product_notions.sell_qty) > p.sellingprice'); break;
|
||||||
|
case 'notions_order_qty_conversion_our':
|
||||||
|
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||||
|
addGroup('notion_oqc_our' + sqlOp, numFilt('product_notions.order_qty_conversion_our', sqlOp, t1, t2)); break;
|
||||||
|
case 'notions_order_qty_conversion_notions':
|
||||||
|
addJoin('product_notions', 'JOIN product_notions ON (p.pid=product_notions.pid)');
|
||||||
|
addGroup('notion_oqc_notions' + sqlOp, numFilt('product_notions.order_qty_conversion_notions', sqlOp, t1, t2)); break;
|
||||||
|
|
||||||
|
// ── product_backorders: extra LEFT JOIN ────────────────────────────────
|
||||||
|
case 'backorder_qty_any':
|
||||||
|
addJoin('product_backorders', 'LEFT JOIN product_backorders ON (product_backorders.pid=p.pid)');
|
||||||
|
break; // join only, no WHERE condition
|
||||||
|
case 'backorder_qty_notions':
|
||||||
|
addJoin('product_backorders', 'LEFT JOIN product_backorders ON (product_backorders.pid=p.pid)');
|
||||||
|
addGroup('backorder_qty_notions', `product_backorders.qty${sqlOp}${parseInt(t1)||0} AND product_backorders.supplier=92`); break;
|
||||||
|
|
||||||
|
// ── product_related: extra JOIN ────────────────────────────────────────
|
||||||
|
case 'related':
|
||||||
|
addJoin('product_related', 'JOIN product_related ON (product_related.to_pid=p.pid)');
|
||||||
|
addGroup('related_pid', `product_related.pid${sqlOp}${parseInt(t1)||0}`);
|
||||||
|
if (t2) addGroup('related_type', `product_related.type=${parseInt(t2)||0}`);
|
||||||
|
break;
|
||||||
|
case 'no_relations':
|
||||||
|
addJoin('product_related_nr', 'LEFT JOIN product_related AS product_related_nr ON (product_related_nr.pid=p.pid)');
|
||||||
|
addGroup('no_relations', 'product_related_nr.to_pid IS NULL'); break;
|
||||||
|
|
||||||
|
// ── receivings / PO: extra INNER JOINs ────────────────────────────────
|
||||||
|
case 'receiving_id':
|
||||||
|
addJoin('receivings_products', 'JOIN receivings_products ON (p.pid=receivings_products.pid)');
|
||||||
|
addGroup('receiving_id' + sqlOp, `receivings_products.receiving_id${sqlOp}'${strVal(t1)}'`, isNot); break;
|
||||||
|
case 'po_id':
|
||||||
|
addJoin('po_products', 'JOIN po_products ON (p.pid=po_products.pid)');
|
||||||
|
addGroup('po_id' + sqlOp, `po_products.po_id${sqlOp}'${strVal(t1)}'`, isNot); break;
|
||||||
|
|
||||||
|
// ── subquery filters ───────────────────────────────────────────────────
|
||||||
|
case 'missing_images':
|
||||||
|
addGroup('missing_images',
|
||||||
|
(row.filter_operator === 'true' || row.filter_operator === 'true1')
|
||||||
|
? '(SELECT COUNT(*) FROM product_images WHERE product_images.pid=p.pid)=0'
|
||||||
|
: '(SELECT COUNT(*) FROM product_images WHERE product_images.pid=p.pid)>0');
|
||||||
|
break;
|
||||||
|
case 'current_price':
|
||||||
|
addGroup('current_price' + sqlOp,
|
||||||
|
`(SELECT MIN(price_each) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)${sqlOp}${parseFloat(t1)||0}`);
|
||||||
|
break;
|
||||||
|
case 'current_price_sale_percent': {
|
||||||
|
const v1 = (parseFloat(t1)||0) > 1 ? (parseFloat(t1)/100) : (parseFloat(t1)||0);
|
||||||
|
const pe = '(SELECT MIN(price_each) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)';
|
||||||
|
addGroup('current_price_sale_pct' + sqlOp, `(1 - ${pe} / p.sellingprice)${sqlOp}${v1}`); break;
|
||||||
|
}
|
||||||
|
case 'one_current_price':
|
||||||
|
addGroup('one_current_price', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)=1'); break;
|
||||||
|
case 'multiple_current_prices':
|
||||||
|
addGroup('multiple_current_prices', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)>1'); break;
|
||||||
|
case 'current_price_is_missing':
|
||||||
|
addGroup('current_price_is_missing', '(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)=0'); break;
|
||||||
|
case 'current_price_not_one_buyable':
|
||||||
|
addGroup('current_price_not_one_buyable',
|
||||||
|
'(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_buy=1)=0 AND (SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1)>0');
|
||||||
|
break;
|
||||||
|
case 'current_price_min_buy': {
|
||||||
|
const cond = t2
|
||||||
|
? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=1 AND qty_buy${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0`
|
||||||
|
: `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=1 AND qty_buy${sqlOp}${parseFloat(t1)||0})>0`;
|
||||||
|
addGroup('current_price_min_buy' + sqlOp, cond); break;
|
||||||
|
}
|
||||||
|
case 'current_price_each_buy': {
|
||||||
|
const cond = t2
|
||||||
|
? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=0 AND qty_buy${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0`
|
||||||
|
: `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND is_min_qty_buy=0 AND qty_buy${sqlOp}${parseFloat(t1)||0})>0`;
|
||||||
|
addGroup('current_price_each_buy' + sqlOp, cond); break;
|
||||||
|
}
|
||||||
|
case 'current_price_max_qty': {
|
||||||
|
const cond = t2
|
||||||
|
? `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_limit${sqlOp}${parseFloat(t1)||0} AND ${parseFloat(t2)||0})>0`
|
||||||
|
: `(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND qty_limit${sqlOp}${parseFloat(t1)||0})>0`;
|
||||||
|
addGroup('current_price_max_qty' + sqlOp, cond); break;
|
||||||
|
}
|
||||||
|
case 'current_price_is_checkout_offer':
|
||||||
|
addGroup('current_price_checkout',
|
||||||
|
`(SELECT COUNT(*) FROM product_current_prices WHERE product_current_prices.pid=p.pid AND active=1 AND checkout_offer${sqlOp})>0`);
|
||||||
|
break;
|
||||||
|
case 'current_price_has_extra':
|
||||||
|
addJoin('cp_extras', 'JOIN (SELECT pid,count(*) ct FROM product_current_prices WHERE active=1 GROUP BY pid,qty_buy HAVING ct>1) AS cp_extras ON (cp_extras.pid=p.pid)');
|
||||||
|
addGroup('cp_extra', 'cp_extras.ct>1'); break;
|
||||||
|
case 'has_video':
|
||||||
|
addGroup('has_video', '(SELECT COUNT(*) FROM product_media WHERE product_media.pid=p.pid)>0'); break;
|
||||||
|
case 'notions_created':
|
||||||
|
addGroup('notions_created',
|
||||||
|
sqlOp !== '=0'
|
||||||
|
? '(SELECT COUNT(*) FROM product_notions_created WHERE product_notions_created.pid=p.pid)>0'
|
||||||
|
: '(SELECT COUNT(*) FROM product_notions_created WHERE product_notions_created.pid=p.pid)=0');
|
||||||
|
break;
|
||||||
|
case 'notifications':
|
||||||
|
addJoin('pnc', 'JOIN (SELECT pid,COUNT(*) AS notifications_count FROM product_notify GROUP BY pid) pnc ON (pnc.pid=p.pid)');
|
||||||
|
addGroup('notifications', numFilt('pnc.notifications_count', sqlOp, t1, t2)); break;
|
||||||
|
case 'daily_deal':
|
||||||
|
addGroup('daily_deal', '(SELECT deal_id FROM product_daily_deals WHERE deal_date=CURDATE() AND product_daily_deals.pid=p.pid)>0');
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── amazon_price (store id 5, legacy) ─────────────────────────────────
|
||||||
|
case 'amazon_price': {
|
||||||
|
const AMAZON_STORE = 5;
|
||||||
|
addJoin('product_prices', 'LEFT JOIN product_prices ON (p.pid=product_prices.pid)');
|
||||||
|
let filt = `product_prices.store=${AMAZON_STORE} AND product_prices.price${sqlOp}${parseFloat(t1)||0}`;
|
||||||
|
if (t2) filt += ` AND ${parseFloat(t2)||0}`;
|
||||||
|
if ((sqlOp === '<=' || sqlOp === '=') && t1 === '0') filt += ' OR product_prices.price IS NULL';
|
||||||
|
addGroup('amazon_price' + sqlOp, filt); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── basket (cid must be in filter_text2; skip if not set) ─────────────
|
||||||
|
case 'basket':
|
||||||
|
if (!t2) { unsupported.push('basket(no-cid)'); break; }
|
||||||
|
addJoin('mybasket', 'JOIN mybasket ON (p.pid=mybasket.item)');
|
||||||
|
addGroup('basket_cid', `mybasket.cid=${parseInt(t2)||0}`);
|
||||||
|
addGroup('basket_sid', `mybasket.sid=0`); // ACOT store
|
||||||
|
addGroup('basket_bid', t1 ? `mybasket.bid=${parseInt(t1)||0}` : 'mybasket.bid=0');
|
||||||
|
if (t1 === '2') addGroup('basket_qty', 'mybasket.qty>0');
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── hot: recent sales aggregation ─────────────────────────────────────
|
||||||
|
case 'hot': {
|
||||||
|
const days = parseInt(t1) || 30;
|
||||||
|
const alias = `hot_${days}`;
|
||||||
|
addJoin(alias, `JOIN (SELECT prod_pid, COUNT(*) hot_ct, SUM(order_items.qty_ordered) hot_sm FROM order_items JOIN _order ON (_order.order_id=order_items.order_id AND _order.order_status>60 AND _order.date_placed BETWEEN NOW()-INTERVAL ${days} DAY AND NOW()) GROUP BY order_items.prod_pid) ${alias} ON (p.pid=${alias}.prod_pid)`);
|
||||||
|
break; // join alone filters to products with recent sales; no extra WHERE needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentionally skipped (require external services or missing runtime context):
|
||||||
|
// search/search_any (MeiliSearch API), basket without cid (per-session user),
|
||||||
|
// groupby (display aggregation, not a filter)
|
||||||
|
default:
|
||||||
|
unsupported.push(row.filter_type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble WHERE: groups are AND-connected by default.
|
||||||
|
// A group with ororor=true is OR-connected to the previous group instead (PHP filter_or mechanism).
|
||||||
|
let whereClause = '';
|
||||||
|
let i = 0;
|
||||||
|
for (const [, group] of filterGroups) {
|
||||||
|
const wrapped = group.conditions.map(c => `(${c})`);
|
||||||
|
const innerJoiner = group.isNot ? ' AND ' : ' OR ';
|
||||||
|
const part = `(${wrapped.join(innerJoiner)})`;
|
||||||
|
if (i > 0) {
|
||||||
|
whereClause += group.ororor ? ` OR ${part}` : ` AND ${part}`;
|
||||||
|
} else {
|
||||||
|
whereClause += part;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinClauses = [...joinTables.values()].join('\n ');
|
||||||
|
return { whereClause, joinClauses, unsupported };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load products matching a saved product_query by query_id
|
||||||
|
// Filter types whose v1/v2 values are cat_ids in product_categories
|
||||||
|
const CAT_ID_FILTER_TYPES = new Set([
|
||||||
|
'company', 'line', 'subline', 'artist', 'category', 'theme',
|
||||||
|
'size_cat', 'dimension', 'yarn_weight', 'material',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function resolveFilterLabels(connection, filters) {
|
||||||
|
const catIds = new Set();
|
||||||
|
const supplierIds = new Set();
|
||||||
|
const taxCodeIds = new Set();
|
||||||
|
|
||||||
|
for (const f of filters) {
|
||||||
|
const vals = [f.v1, f.v2].filter(v => v && !isNaN(Number(v)));
|
||||||
|
if (CAT_ID_FILTER_TYPES.has(f.type)) vals.forEach(v => catIds.add(Number(v)));
|
||||||
|
else if (f.type === 'investor') vals.forEach(v => supplierIds.add(Number(v)));
|
||||||
|
else if (f.type === 'tax_code') vals.forEach(v => taxCodeIds.add(Number(v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const catLookup = {};
|
||||||
|
const supplierLookup = {};
|
||||||
|
const taxCodeLookup = {};
|
||||||
|
|
||||||
|
if (catIds.size) {
|
||||||
|
const ids = [...catIds];
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT cat_id, name FROM product_categories WHERE cat_id IN (${ids.map(() => '?').join(',')})`, ids
|
||||||
|
);
|
||||||
|
for (const r of rows) catLookup[r.cat_id] = r.name;
|
||||||
|
}
|
||||||
|
if (supplierIds.size) {
|
||||||
|
const ids = [...supplierIds];
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT supplierid, companyname FROM suppliers WHERE supplierid IN (${ids.map(() => '?').join(',')})`, ids
|
||||||
|
);
|
||||||
|
for (const r of rows) supplierLookup[r.supplierid] = r.companyname;
|
||||||
|
}
|
||||||
|
if (taxCodeIds.size) {
|
||||||
|
const ids = [...taxCodeIds];
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT tax_code_id, name FROM product_tax_codes WHERE tax_code_id IN (${ids.map(() => '?').join(',')})`, ids
|
||||||
|
);
|
||||||
|
for (const r of rows) taxCodeLookup[r.tax_code_id] = r.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(type, val) {
|
||||||
|
if (!val || isNaN(Number(val))) return null;
|
||||||
|
const id = Number(val);
|
||||||
|
if (CAT_ID_FILTER_TYPES.has(type)) return catLookup[id] ?? null;
|
||||||
|
if (type === 'investor') return supplierLookup[id] ?? null;
|
||||||
|
if (type === 'tax_code') return taxCodeLookup[id] ?? null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters.map(f => ({
|
||||||
|
...f,
|
||||||
|
v1Label: labelFor(f.type, f.v1),
|
||||||
|
v2Label: labelFor(f.type, f.v2),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/query-products', async (req, res) => {
|
||||||
|
const { query_id } = req.query;
|
||||||
|
if (!query_id || isNaN(parseInt(query_id))) {
|
||||||
|
return res.status(400).json({ error: 'Valid query_id is required' });
|
||||||
|
}
|
||||||
|
const qid = parseInt(query_id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { connection } = await getDbConnection();
|
||||||
|
|
||||||
|
// Verify query exists
|
||||||
|
const [queryRows] = await connection.query(
|
||||||
|
'SELECT id, name FROM product_query WHERE id = ?', [qid]
|
||||||
|
);
|
||||||
|
if (!queryRows.length) {
|
||||||
|
return res.status(404).json({ error: `Query ${qid} not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all filters for this query
|
||||||
|
const [filterRows] = await connection.query(
|
||||||
|
'SELECT * FROM product_query_filter WHERE query_id = ? ORDER BY id', [qid]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filterRows.length) {
|
||||||
|
return res.json({ results: [], filters: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { whereClause, joinClauses, unsupported } = buildQueryFilterSql(filterRows);
|
||||||
|
|
||||||
|
const uniqueUnsupported = [...new Set(unsupported)];
|
||||||
|
if (uniqueUnsupported.length) {
|
||||||
|
console.warn(`query-products: unsupported filter types for query ${qid}:`, uniqueUnsupported);
|
||||||
|
}
|
||||||
|
res.setHeader('X-Query-Name', queryRows[0].name || '');
|
||||||
|
|
||||||
|
function tryDecode(v) {
|
||||||
|
if (!v) return null;
|
||||||
|
try { return decodeURIComponent(String(v)); } catch { return String(v); }
|
||||||
|
}
|
||||||
|
const rawFilters = filterRows
|
||||||
|
.filter(r => !uniqueUnsupported.includes(r.filter_type))
|
||||||
|
.map(r => ({
|
||||||
|
type: r.filter_type,
|
||||||
|
op: r.filter_operator,
|
||||||
|
v1: tryDecode(r.filter_text1),
|
||||||
|
v2: tryDecode(r.filter_text2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filters = await resolveFilterLabels(connection, rawFilters);
|
||||||
|
|
||||||
|
// If all filters were unsupported the WHERE clause is empty — return nothing
|
||||||
|
// rather than dumping the entire products table.
|
||||||
|
if (!whereClause) {
|
||||||
|
return res.json({ results: [], filters, unsupported: uniqueUnsupported });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `${PRODUCT_SELECT}
|
||||||
|
${joinClauses}
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY p.pid
|
||||||
|
ORDER BY p.description
|
||||||
|
LIMIT 2000`;
|
||||||
|
|
||||||
|
const [results] = await connection.query(sql);
|
||||||
|
res.json({ results, filters, unsupported: uniqueUnsupported });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading query products:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to load query products', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|||||||
@@ -463,31 +463,41 @@ router.get('/search', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const terms = q.trim().split(/\s+/).filter(Boolean);
|
const terms = q.trim().split(/\s+/).filter(Boolean);
|
||||||
// Each term must match at least one of: title, sku, barcode, brand, vendor
|
// Each term must match at least one of: title, sku, barcode, brand, vendor, vendor_reference, notions_reference, line, subline, artist
|
||||||
const conditions = terms.map((_, i) => {
|
const conditions = terms.map((_, i) => {
|
||||||
const p = i * 5;
|
const p = i * 10;
|
||||||
return `(p.title ILIKE $${p + 1} OR p.sku ILIKE $${p + 2} OR p.barcode ILIKE $${p + 3} OR p.brand ILIKE $${p + 4} OR p.vendor ILIKE $${p + 5})`;
|
return `(p.title ILIKE $${p + 1} OR p.sku ILIKE $${p + 2} OR p.barcode ILIKE $${p + 3} OR p.brand ILIKE $${p + 4} OR p.vendor ILIKE $${p + 5} OR p.vendor_reference ILIKE $${p + 6} OR p.notions_reference ILIKE $${p + 7} OR p.line ILIKE $${p + 8} OR p.subline ILIKE $${p + 9} OR p.artist ILIKE $${p + 10})`;
|
||||||
});
|
});
|
||||||
const params = terms.flatMap(t => {
|
const params = terms.flatMap(t => {
|
||||||
const like = `%${t}%`;
|
const like = `%${t}%`;
|
||||||
return [like, like, like, like, like];
|
return [like, like, like, like, like, like, like, like, like, like];
|
||||||
});
|
});
|
||||||
|
|
||||||
const { rows } = await pool.query(`
|
const whereClause = conditions.join(' AND ');
|
||||||
SELECT pid, title, sku, barcode, brand, line, regular_price, image_175
|
const searchParams = [...params, `%${q.trim()}%`];
|
||||||
FROM products p
|
|
||||||
WHERE ${conditions.join(' AND ')}
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0
|
|
||||||
WHEN p.barcode ILIKE $${params.length + 1} THEN 1
|
|
||||||
WHEN p.title ILIKE $${params.length + 1} THEN 2
|
|
||||||
ELSE 3
|
|
||||||
END,
|
|
||||||
p.total_sold DESC NULLS LAST
|
|
||||||
LIMIT 50
|
|
||||||
`, [...params, `%${q.trim()}%`]);
|
|
||||||
|
|
||||||
res.json(rows);
|
const [{ rows }, { rows: countRows }] = await Promise.all([
|
||||||
|
pool.query(`
|
||||||
|
SELECT pid, title, sku, barcode, brand, line, regular_price, image_175
|
||||||
|
FROM products p
|
||||||
|
WHERE ${whereClause}
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN p.sku ILIKE $${params.length + 1} THEN 0
|
||||||
|
WHEN p.barcode ILIKE $${params.length + 1} THEN 1
|
||||||
|
WHEN p.title ILIKE $${params.length + 1} THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
p.total_sold DESC NULLS LAST
|
||||||
|
LIMIT 100
|
||||||
|
`, searchParams),
|
||||||
|
pool.query(`
|
||||||
|
SELECT COUNT(*)::int AS total
|
||||||
|
FROM products p
|
||||||
|
WHERE ${whereClause}
|
||||||
|
`, params),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ results: rows, total: countRows[0].total });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error searching products:', error);
|
console.error('Error searching products:', error);
|
||||||
res.status(500).json({ error: 'Search failed' });
|
res.status(500).json({ error: 'Search failed' });
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ async function startServer() {
|
|||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
|
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
|
||||||
|
// Pre-warm AI service so taxonomy embeddings are ready before first user request
|
||||||
|
aiRouter.initInBackground();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start server:', error);
|
console.error('Failed to start server:', error);
|
||||||
|
|||||||
@@ -3,13 +3,26 @@
|
|||||||
*
|
*
|
||||||
* Generates and caches embeddings for categories, themes, and colors.
|
* Generates and caches embeddings for categories, themes, and colors.
|
||||||
* Excludes "Black Friday", "Gifts", "Deals" categories and their children.
|
* Excludes "Black Friday", "Gifts", "Deals" categories and their children.
|
||||||
|
*
|
||||||
|
* Disk cache: embeddings are saved to data/taxonomy-embeddings.json and reused
|
||||||
|
* across server restarts. Cache is invalidated by content hash — if the taxonomy
|
||||||
|
* rows in MySQL change, the next check will detect it and regenerate automatically.
|
||||||
|
*
|
||||||
|
* Background check: after initialization, call startBackgroundCheck(getConnectionFn)
|
||||||
|
* to poll for taxonomy changes on a configurable interval (default 1h).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { findTopMatches } = require('./similarity');
|
const { findTopMatches } = require('./similarity');
|
||||||
|
|
||||||
// Categories to exclude (and all their children)
|
// Categories to exclude (and all their children)
|
||||||
const EXCLUDED_CATEGORY_NAMES = ['black friday', 'gifts', 'deals'];
|
const EXCLUDED_CATEGORY_NAMES = ['black friday', 'gifts', 'deals'];
|
||||||
|
|
||||||
|
// Disk cache config
|
||||||
|
const CACHE_PATH = path.join(__dirname, '..', '..', '..', '..', 'data', 'taxonomy-embeddings.json');
|
||||||
|
|
||||||
class TaxonomyEmbeddings {
|
class TaxonomyEmbeddings {
|
||||||
constructor({ provider, logger }) {
|
constructor({ provider, logger }) {
|
||||||
this.provider = provider;
|
this.provider = provider;
|
||||||
@@ -25,12 +38,18 @@ class TaxonomyEmbeddings {
|
|||||||
this.themeMap = new Map();
|
this.themeMap = new Map();
|
||||||
this.colorMap = new Map();
|
this.colorMap = new Map();
|
||||||
|
|
||||||
|
// Content hash of the last successfully built taxonomy (from DB rows)
|
||||||
|
this.contentHash = null;
|
||||||
|
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
this.initializing = false;
|
this.initializing = false;
|
||||||
|
this._checkInterval = null;
|
||||||
|
this._regenerating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize embeddings - fetch taxonomy and generate embeddings
|
* Initialize embeddings — fetches raw taxonomy rows to compute a content hash,
|
||||||
|
* then either loads the matching disk cache or generates fresh embeddings.
|
||||||
*/
|
*/
|
||||||
async initialize(connection) {
|
async initialize(connection) {
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
@@ -48,42 +67,36 @@ class TaxonomyEmbeddings {
|
|||||||
this.initializing = true;
|
this.initializing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.info('[TaxonomyEmbeddings] Starting initialization...');
|
// Always fetch raw rows first — cheap (~10ms), no OpenAI calls.
|
||||||
|
// Used to compute a content hash for cache validation.
|
||||||
|
const rawRows = await this._fetchRawRows(connection);
|
||||||
|
const freshHash = this._computeContentHash(rawRows);
|
||||||
|
|
||||||
// Fetch raw taxonomy data
|
const cached = this._loadCache();
|
||||||
const [categories, themes, colors] = await Promise.all([
|
if (cached && cached.contentHash === freshHash) {
|
||||||
this._fetchCategories(connection),
|
this.categories = cached.categories;
|
||||||
this._fetchThemes(connection),
|
this.themes = cached.themes;
|
||||||
this._fetchColors(connection)
|
this.colors = cached.colors;
|
||||||
]);
|
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
||||||
|
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
||||||
|
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
||||||
|
this.contentHash = freshHash;
|
||||||
|
this.initialized = true;
|
||||||
|
this.logger.info(`[TaxonomyEmbeddings] Loaded from cache: ${this.categories.length} categories, ${this.themes.length} themes, ${this.colors.length} colors`);
|
||||||
|
return { categories: this.categories.length, themes: this.themes.length, colors: this.colors.length };
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.info(`[TaxonomyEmbeddings] Fetched ${categories.length} categories, ${themes.length} themes, ${colors.length} colors`);
|
if (cached) {
|
||||||
|
this.logger.info('[TaxonomyEmbeddings] Taxonomy changed since cache was built, regenerating...');
|
||||||
// Generate embeddings in parallel
|
} else {
|
||||||
const [catEmbeddings, themeEmbeddings, colorEmbeddings] = await Promise.all([
|
this.logger.info('[TaxonomyEmbeddings] No cache — fetching taxonomy and generating embeddings...');
|
||||||
this._generateEmbeddings(categories, 'categories'),
|
}
|
||||||
this._generateEmbeddings(themes, 'themes'),
|
|
||||||
this._generateEmbeddings(colors, 'colors')
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Store with embeddings
|
|
||||||
this.categories = catEmbeddings;
|
|
||||||
this.themes = themeEmbeddings;
|
|
||||||
this.colors = colorEmbeddings;
|
|
||||||
|
|
||||||
// Build lookup maps
|
|
||||||
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
|
||||||
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
|
||||||
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
|
||||||
|
|
||||||
|
await this._buildAndEmbed(rawRows, freshHash);
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
this.logger.info('[TaxonomyEmbeddings] Initialization complete');
|
this.logger.info('[TaxonomyEmbeddings] Initialization complete');
|
||||||
|
|
||||||
return {
|
return { categories: this.categories.length, themes: this.themes.length, colors: this.colors.length };
|
||||||
categories: this.categories.length,
|
|
||||||
themes: this.themes.length,
|
|
||||||
colors: this.colors.length
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('[TaxonomyEmbeddings] Initialization failed:', error);
|
this.logger.error('[TaxonomyEmbeddings] Initialization failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -92,6 +105,47 @@ class TaxonomyEmbeddings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a background interval that checks for taxonomy changes and regenerates
|
||||||
|
* embeddings automatically if the content hash differs.
|
||||||
|
*
|
||||||
|
* @param {Function} getConnectionFn - async function returning { connection }
|
||||||
|
* @param {number} intervalMs - check interval, default 1 hour
|
||||||
|
*/
|
||||||
|
startBackgroundCheck(getConnectionFn, intervalMs = 60 * 60 * 1000) {
|
||||||
|
if (this._checkInterval) return;
|
||||||
|
|
||||||
|
this.logger.info(`[TaxonomyEmbeddings] Background taxonomy check started (every ${intervalMs / 60000} min)`);
|
||||||
|
|
||||||
|
this._checkInterval = setInterval(async () => {
|
||||||
|
if (this._regenerating) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { connection } = await getConnectionFn();
|
||||||
|
const rawRows = await this._fetchRawRows(connection);
|
||||||
|
const freshHash = this._computeContentHash(rawRows);
|
||||||
|
|
||||||
|
if (freshHash === this.contentHash) return;
|
||||||
|
|
||||||
|
this.logger.info('[TaxonomyEmbeddings] Taxonomy changed, regenerating embeddings in background...');
|
||||||
|
this._regenerating = true;
|
||||||
|
await this._buildAndEmbed(rawRows, freshHash);
|
||||||
|
this.logger.info('[TaxonomyEmbeddings] Background regeneration complete');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn('[TaxonomyEmbeddings] Background taxonomy check failed:', err.message);
|
||||||
|
} finally {
|
||||||
|
this._regenerating = false;
|
||||||
|
}
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopBackgroundCheck() {
|
||||||
|
if (this._checkInterval) {
|
||||||
|
clearInterval(this._checkInterval);
|
||||||
|
this._checkInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find similar categories for a product embedding
|
* Find similar categories for a product embedding
|
||||||
*/
|
*/
|
||||||
@@ -176,29 +230,74 @@ class TaxonomyEmbeddings {
|
|||||||
// Private Methods
|
// Private Methods
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async _fetchCategories(connection) {
|
/**
|
||||||
// Fetch hierarchical categories (types 10-13)
|
* Fetch minimal raw rows from MySQL — used for content hash computation.
|
||||||
const [rows] = await connection.query(`
|
* This is the cheap path: no path-building, no embeddings, just the raw data.
|
||||||
SELECT cat_id, name, master_cat_id, type
|
*/
|
||||||
FROM product_categories
|
async _fetchRawRows(connection) {
|
||||||
WHERE type IN (10, 11, 12, 13)
|
const [[catRows], [themeRows], [colorRows]] = await Promise.all([
|
||||||
ORDER BY type, name
|
connection.query('SELECT cat_id, name, master_cat_id, type FROM product_categories WHERE type IN (10, 11, 12, 13) ORDER BY cat_id'),
|
||||||
`);
|
connection.query('SELECT cat_id, name, master_cat_id, type FROM product_categories WHERE type IN (20, 21) ORDER BY cat_id'),
|
||||||
|
connection.query('SELECT color, name, hex_color FROM product_color_list ORDER BY `order`')
|
||||||
|
]);
|
||||||
|
return { catRows, themeRows, colorRows };
|
||||||
|
}
|
||||||
|
|
||||||
// Build lookup for hierarchy
|
/**
|
||||||
|
* Compute a stable SHA-256 hash of the taxonomy row content.
|
||||||
|
* Any change to IDs, names, or parent relationships will produce a different hash.
|
||||||
|
*/
|
||||||
|
_computeContentHash({ catRows, themeRows, colorRows }) {
|
||||||
|
const content = JSON.stringify({
|
||||||
|
cats: catRows.map(r => [r.cat_id, r.name, r.master_cat_id]).sort((a, b) => a[0] - b[0]),
|
||||||
|
themes: themeRows.map(r => [r.cat_id, r.name, r.master_cat_id]).sort((a, b) => a[0] - b[0]),
|
||||||
|
colors: colorRows.map(r => [r.color, r.name]).sort()
|
||||||
|
});
|
||||||
|
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build full taxonomy objects and generate embeddings, then atomically swap
|
||||||
|
* the in-memory state. Called on cache miss and on background change detection.
|
||||||
|
*/
|
||||||
|
async _buildAndEmbed(rawRows, contentHash) {
|
||||||
|
const { catRows, themeRows, colorRows } = rawRows;
|
||||||
|
|
||||||
|
const categories = this._buildCategories(catRows);
|
||||||
|
const themes = this._buildThemes(themeRows);
|
||||||
|
const colors = this._buildColors(colorRows);
|
||||||
|
|
||||||
|
this.logger.info(`[TaxonomyEmbeddings] Generating embeddings for ${categories.length} categories, ${themes.length} themes, ${colors.length} colors`);
|
||||||
|
|
||||||
|
const [catEmbeddings, themeEmbeddings, colorEmbeddings] = await Promise.all([
|
||||||
|
this._generateEmbeddings(categories, 'categories'),
|
||||||
|
this._generateEmbeddings(themes, 'themes'),
|
||||||
|
this._generateEmbeddings(colors, 'colors')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Atomic in-memory swap (single-threaded JS — readers always see a consistent state)
|
||||||
|
this.categories = catEmbeddings;
|
||||||
|
this.themes = themeEmbeddings;
|
||||||
|
this.colors = colorEmbeddings;
|
||||||
|
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
||||||
|
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
||||||
|
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
||||||
|
this.contentHash = contentHash;
|
||||||
|
|
||||||
|
this._saveCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildCategories(rows) {
|
||||||
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
||||||
|
|
||||||
// Find IDs of excluded top-level categories and all their descendants
|
|
||||||
const excludedIds = new Set();
|
const excludedIds = new Set();
|
||||||
|
|
||||||
// First pass: find excluded top-level categories
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.type === 10 && EXCLUDED_CATEGORY_NAMES.includes(row.name.toLowerCase())) {
|
if (row.type === 10 && EXCLUDED_CATEGORY_NAMES.includes(row.name.toLowerCase())) {
|
||||||
excludedIds.add(row.cat_id);
|
excludedIds.add(row.cat_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple passes to find all descendants
|
// Multiple passes to find all descendants of excluded categories
|
||||||
let foundNew = true;
|
let foundNew = true;
|
||||||
while (foundNew) {
|
while (foundNew) {
|
||||||
foundNew = false;
|
foundNew = false;
|
||||||
@@ -212,20 +311,14 @@ class TaxonomyEmbeddings {
|
|||||||
|
|
||||||
this.logger.info(`[TaxonomyEmbeddings] Excluding ${excludedIds.size} categories (Black Friday, Gifts, Deals and children)`);
|
this.logger.info(`[TaxonomyEmbeddings] Excluding ${excludedIds.size} categories (Black Friday, Gifts, Deals and children)`);
|
||||||
|
|
||||||
// Build category objects with full paths, excluding filtered ones
|
|
||||||
const categories = [];
|
const categories = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (excludedIds.has(row.cat_id)) {
|
if (excludedIds.has(row.cat_id)) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = [];
|
const pathParts = [];
|
||||||
let current = row;
|
let current = row;
|
||||||
|
|
||||||
// Walk up the tree to build full path
|
|
||||||
while (current) {
|
while (current) {
|
||||||
path.unshift(current.name);
|
pathParts.unshift(current.name);
|
||||||
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,55 +327,37 @@ class TaxonomyEmbeddings {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
parentId: row.master_cat_id,
|
parentId: row.master_cat_id,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
fullPath: path.join(' > '),
|
fullPath: pathParts.join(' > '),
|
||||||
embeddingText: path.join(' ')
|
embeddingText: pathParts.join(' ')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _fetchThemes(connection) {
|
_buildThemes(rows) {
|
||||||
// Fetch themes (types 20-21)
|
|
||||||
const [rows] = await connection.query(`
|
|
||||||
SELECT cat_id, name, master_cat_id, type
|
|
||||||
FROM product_categories
|
|
||||||
WHERE type IN (20, 21)
|
|
||||||
ORDER BY type, name
|
|
||||||
`);
|
|
||||||
|
|
||||||
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
||||||
const themes = [];
|
|
||||||
|
|
||||||
for (const row of rows) {
|
return rows.map(row => {
|
||||||
const path = [];
|
const pathParts = [];
|
||||||
let current = row;
|
let current = row;
|
||||||
|
|
||||||
while (current) {
|
while (current) {
|
||||||
path.unshift(current.name);
|
pathParts.unshift(current.name);
|
||||||
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
themes.push({
|
return {
|
||||||
id: row.cat_id,
|
id: row.cat_id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
parentId: row.master_cat_id,
|
parentId: row.master_cat_id,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
fullPath: path.join(' > '),
|
fullPath: pathParts.join(' > '),
|
||||||
embeddingText: path.join(' ')
|
embeddingText: pathParts.join(' ')
|
||||||
});
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
return themes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _fetchColors(connection) {
|
_buildColors(rows) {
|
||||||
const [rows] = await connection.query(`
|
|
||||||
SELECT color, name, hex_color
|
|
||||||
FROM product_color_list
|
|
||||||
ORDER BY \`order\`
|
|
||||||
`);
|
|
||||||
|
|
||||||
return rows.map(row => ({
|
return rows.map(row => ({
|
||||||
id: row.color,
|
id: row.color,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -301,9 +376,7 @@ class TaxonomyEmbeddings {
|
|||||||
const results = [...items];
|
const results = [...items];
|
||||||
|
|
||||||
// Process in batches
|
// Process in batches
|
||||||
let batchNum = 0;
|
|
||||||
for await (const chunk of this.provider.embedBatchChunked(texts, { batchSize: 100 })) {
|
for await (const chunk of this.provider.embedBatchChunked(texts, { batchSize: 100 })) {
|
||||||
batchNum++;
|
|
||||||
for (let i = 0; i < chunk.embeddings.length; i++) {
|
for (let i = 0; i < chunk.embeddings.length; i++) {
|
||||||
const globalIndex = chunk.startIndex + i;
|
const globalIndex = chunk.startIndex + i;
|
||||||
results[globalIndex] = {
|
results[globalIndex] = {
|
||||||
@@ -318,6 +391,43 @@ class TaxonomyEmbeddings {
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Disk Cache Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
_loadCache() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(CACHE_PATH)) return null;
|
||||||
|
|
||||||
|
const data = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
|
||||||
|
if (!data.contentHash || !data.categories?.length || !data.themes?.length || !data.colors?.length) {
|
||||||
|
this.logger.warn('[TaxonomyEmbeddings] Disk cache malformed or missing content hash, will regenerate');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn('[TaxonomyEmbeddings] Failed to load disk cache:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveCache() {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true });
|
||||||
|
fs.writeFileSync(CACHE_PATH, JSON.stringify({
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
contentHash: this.contentHash,
|
||||||
|
categories: this.categories,
|
||||||
|
themes: this.themes,
|
||||||
|
colors: this.colors,
|
||||||
|
}));
|
||||||
|
this.logger.info(`[TaxonomyEmbeddings] Disk cache saved to ${CACHE_PATH}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn('[TaxonomyEmbeddings] Failed to save disk cache:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { TaxonomyEmbeddings };
|
module.exports = { TaxonomyEmbeddings };
|
||||||
|
|||||||
@@ -124,6 +124,17 @@ function isReady() {
|
|||||||
return initialized && taxonomyEmbeddings?.isReady();
|
return initialized && taxonomyEmbeddings?.isReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start background taxonomy change detection.
|
||||||
|
* Call once after initialization, passing a function that returns { connection }.
|
||||||
|
* @param {Function} getConnectionFn
|
||||||
|
* @param {number} [intervalMs] - default 1 hour
|
||||||
|
*/
|
||||||
|
function startBackgroundCheck(getConnectionFn, intervalMs) {
|
||||||
|
if (!initialized || !taxonomyEmbeddings) return;
|
||||||
|
taxonomyEmbeddings.startBackgroundCheck(getConnectionFn, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build weighted product text for embedding.
|
* Build weighted product text for embedding.
|
||||||
* Weights the product name heavily by repeating it, and truncates long descriptions
|
* Weights the product name heavily by repeating it, and truncates long descriptions
|
||||||
@@ -362,6 +373,7 @@ module.exports = {
|
|||||||
initialize,
|
initialize,
|
||||||
isReady,
|
isReady,
|
||||||
getStatus,
|
getStatus,
|
||||||
|
startBackgroundCheck,
|
||||||
|
|
||||||
// Embeddings (OpenAI)
|
// Embeddings (OpenAI)
|
||||||
getProductEmbedding,
|
getProductEmbedding,
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ const Import = lazy(() => import('./pages/Import').then(module => ({ default: mo
|
|||||||
// Product editor
|
// Product editor
|
||||||
const ProductEditor = lazy(() => import('./pages/ProductEditor'));
|
const ProductEditor = lazy(() => import('./pages/ProductEditor'));
|
||||||
|
|
||||||
|
// Bulk edit
|
||||||
|
const BulkEdit = lazy(() => import('./pages/BulkEdit'));
|
||||||
|
|
||||||
// 4. Chat archive - separate chunk
|
// 4. Chat archive - separate chunk
|
||||||
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
|
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
|
||||||
|
|
||||||
@@ -198,6 +201,15 @@ function App() {
|
|||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* Bulk edit */}
|
||||||
|
<Route path="/bulk-edit" element={
|
||||||
|
<Protected page="bulk_edit">
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
<BulkEdit />
|
||||||
|
</Suspense>
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Product import - separate chunk */}
|
{/* Product import - separate chunk */}
|
||||||
<Route path="/import" element={
|
<Route path="/import" element={
|
||||||
<Protected page="import">
|
<Protected page="import">
|
||||||
|
|||||||
252
inventory/src/components/ai/AiDescriptionCompare.tsx
Normal file
252
inventory/src/components/ai/AiDescriptionCompare.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* AiDescriptionCompare
|
||||||
|
*
|
||||||
|
* Shared side-by-side description editor for AI validation results.
|
||||||
|
* Shows the current description next to the AI-suggested version,
|
||||||
|
* both editable, with issues list and accept/dismiss actions.
|
||||||
|
*
|
||||||
|
* Layout uses a ResizeObserver to measure the right-side header+issues
|
||||||
|
* area and mirrors that height as a spacer on the left so both
|
||||||
|
* textareas start at the same vertical position. Textareas auto-resize
|
||||||
|
* to fit their content; the parent container controls overflow.
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - MultilineInput (inside a Popover, in the import validation table)
|
||||||
|
* - ProductEditForm (inside a Dialog, in the product editor)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Sparkles, AlertCircle, Check, RefreshCw } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface AiDescriptionCompareProps {
|
||||||
|
currentValue: string;
|
||||||
|
onCurrentChange: (value: string) => void;
|
||||||
|
suggestion: string;
|
||||||
|
issues: string[];
|
||||||
|
onAccept: (editedSuggestion: string) => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
/** Called to re-roll (re-run) the AI validation */
|
||||||
|
onRevalidate?: () => void;
|
||||||
|
/** Whether re-validation is in progress */
|
||||||
|
isRevalidating?: boolean;
|
||||||
|
productName?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiDescriptionCompare({
|
||||||
|
currentValue,
|
||||||
|
onCurrentChange,
|
||||||
|
suggestion,
|
||||||
|
issues,
|
||||||
|
onAccept,
|
||||||
|
onDismiss,
|
||||||
|
onRevalidate,
|
||||||
|
isRevalidating = false,
|
||||||
|
productName,
|
||||||
|
className,
|
||||||
|
}: AiDescriptionCompareProps) {
|
||||||
|
const [editedSuggestion, setEditedSuggestion] = useState(suggestion);
|
||||||
|
const [aiHeaderHeight, setAiHeaderHeight] = useState(0);
|
||||||
|
const aiHeaderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Reset edited suggestion when the suggestion prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
setEditedSuggestion(suggestion);
|
||||||
|
}, [suggestion]);
|
||||||
|
|
||||||
|
// Measure right-side header+issues area for left-side spacer alignment.
|
||||||
|
// Wrapped in rAF because Radix portals mount asynchronously — the ref
|
||||||
|
// is null on the first synchronous run.
|
||||||
|
useEffect(() => {
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
const el = aiHeaderRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
observer = new ResizeObserver(([entry]) => {
|
||||||
|
// Subtract 8px to compensate for the left column's py-2 top padding,
|
||||||
|
// so both "Current Description" and "Suggested" labels align vertically.
|
||||||
|
setAiHeaderHeight(Math.max(0, entry.contentRect.height - 8));
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
observer?.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-resize both textareas to fit content, then equalize their heights
|
||||||
|
// on desktop so tops and bottoms align exactly.
|
||||||
|
const syncTextareaHeights = useCallback(() => {
|
||||||
|
const main = mainTextareaRef.current;
|
||||||
|
const suggestion = suggestionTextareaRef.current;
|
||||||
|
if (!main && !suggestion) return;
|
||||||
|
|
||||||
|
// Reset to auto to measure natural content height
|
||||||
|
if (main) main.style.height = "auto";
|
||||||
|
if (suggestion) suggestion.style.height = "auto";
|
||||||
|
|
||||||
|
const mainH = main?.scrollHeight ?? 0;
|
||||||
|
const suggestionH = suggestion?.scrollHeight ?? 0;
|
||||||
|
|
||||||
|
// On desktop (lg), equalize so both textareas are the same height
|
||||||
|
const isDesktop = window.matchMedia("(min-width: 1024px)").matches;
|
||||||
|
const targetH = isDesktop ? Math.max(mainH, suggestionH) : 0;
|
||||||
|
|
||||||
|
if (main) main.style.height = `${targetH || mainH}px`;
|
||||||
|
if (suggestion) suggestion.style.height = `${targetH || suggestionH}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync heights on mount and when content changes.
|
||||||
|
// Retry after a short delay to handle dialog/popover entry animations
|
||||||
|
// where the DOM isn't fully laid out on the first frame.
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(syncTextareaHeights);
|
||||||
|
const timer = setTimeout(syncTextareaHeights, 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [currentValue, editedSuggestion, syncTextareaHeights]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col lg:flex-row items-stretch w-full", className)}>
|
||||||
|
{/* Left: current description */}
|
||||||
|
<div className="flex flex-col min-h-0 w-full lg:w-1/2">
|
||||||
|
<div className="px-3 py-2 bg-accent flex flex-col flex-1 min-h-0">
|
||||||
|
{/* Product name - shown inline on mobile */}
|
||||||
|
{productName && (
|
||||||
|
<div className="flex-shrink-0 flex flex-col lg:hidden px-1 mb-2">
|
||||||
|
<div className="text-sm font-medium text-foreground mb-1">
|
||||||
|
Editing description for:
|
||||||
|
</div>
|
||||||
|
<div className="text-md font-semibold text-foreground">
|
||||||
|
{productName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Desktop spacer matching the right-side header+issues height */}
|
||||||
|
{aiHeaderHeight > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 hidden lg:flex items-start"
|
||||||
|
style={{ height: aiHeaderHeight }}
|
||||||
|
>
|
||||||
|
{productName && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="text-sm font-medium text-foreground px-1 mb-1">
|
||||||
|
Editing description for:
|
||||||
|
</div>
|
||||||
|
<div className="text-md font-semibold text-foreground px-1">
|
||||||
|
{productName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-sm mb-1 font-medium flex items-center gap-2 flex-shrink-0">
|
||||||
|
Current Description:
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
ref={mainTextareaRef}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
onCurrentChange(e.target.value);
|
||||||
|
syncTextareaHeights();
|
||||||
|
}}
|
||||||
|
className="overflow-y-auto overscroll-contain text-sm resize-y bg-white min-h-[120px] max-h-[50vh]"
|
||||||
|
/>
|
||||||
|
{/* Footer spacer matching the action buttons height on the right */}
|
||||||
|
<div className="h-[43px] flex-shrink-0 hidden lg:block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: AI suggestion */}
|
||||||
|
<div className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
|
||||||
|
{/* Measured header + issues area (height mirrored as spacer on the left) */}
|
||||||
|
<div ref={aiHeaderRef} className="flex-shrink-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="w-full flex items-center justify-between px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||||
|
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||||
|
AI Suggestion
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-purple-500 dark:text-purple-400">
|
||||||
|
({issues.length} {issues.length === 1 ? "issue" : "issues"})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issues list */}
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 px-3 pb-3">
|
||||||
|
{issues.map((issue, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
|
<span>{issue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
|
||||||
|
{/* Editable suggestion */}
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<div className="text-sm text-purple-500 dark:text-purple-400 mb-1 font-medium flex-shrink-0">
|
||||||
|
Suggested (editable):
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
ref={suggestionTextareaRef}
|
||||||
|
value={editedSuggestion}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditedSuggestion(e.target.value);
|
||||||
|
syncTextareaHeights();
|
||||||
|
}}
|
||||||
|
className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y min-h-[120px] max-h-[50vh]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||||
|
onClick={() => onAccept(editedSuggestion)}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Replace With Suggestion
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||||
|
onClick={onDismiss}
|
||||||
|
>
|
||||||
|
Ignore
|
||||||
|
</Button>
|
||||||
|
{onRevalidate && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-3 text-xs text-purple-500 hover:text-purple-700 dark:text-purple-400"
|
||||||
|
disabled={isRevalidating}
|
||||||
|
onClick={onRevalidate}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 mr-1 ${isRevalidating ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
440
inventory/src/components/bulk-edit/BulkEditRow.tsx
Normal file
440
inventory/src/components/bulk-edit/BulkEditRow.tsx
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
ExternalLink,
|
||||||
|
Sparkles,
|
||||||
|
AlertCircle,
|
||||||
|
Save,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { SearchProduct, FieldOption } from "@/components/product-editor/types";
|
||||||
|
|
||||||
|
const PROD_IMG_HOST = "https://sbing.com";
|
||||||
|
const BACKEND_URL = "https://backend.acherryontop.com/product";
|
||||||
|
|
||||||
|
export type BulkEditFieldChoice =
|
||||||
|
| "name"
|
||||||
|
| "description"
|
||||||
|
| "categories"
|
||||||
|
| "themes"
|
||||||
|
| "colors"
|
||||||
|
| "tax_cat"
|
||||||
|
| "size_cat"
|
||||||
|
| "ship_restrictions"
|
||||||
|
| "hts_code"
|
||||||
|
| "weight"
|
||||||
|
| "msrp"
|
||||||
|
| "cost_each";
|
||||||
|
|
||||||
|
export interface AiResult {
|
||||||
|
isValid: boolean;
|
||||||
|
suggestion?: string | null;
|
||||||
|
issues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowAiState {
|
||||||
|
status: "idle" | "validating" | "done";
|
||||||
|
result: AiResult | null;
|
||||||
|
editedSuggestion: string | null;
|
||||||
|
decision: "accepted" | "dismissed" | null;
|
||||||
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
|
saveError: string | null;
|
||||||
|
/** Track manual edits to the main field value */
|
||||||
|
manualEdit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INITIAL_ROW_STATE: RowAiState = {
|
||||||
|
status: "idle",
|
||||||
|
result: null,
|
||||||
|
editedSuggestion: null,
|
||||||
|
decision: null,
|
||||||
|
saveStatus: "idle",
|
||||||
|
saveError: null,
|
||||||
|
manualEdit: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fields that support AI validation */
|
||||||
|
export const AI_FIELDS: BulkEditFieldChoice[] = ["name", "description"];
|
||||||
|
|
||||||
|
/** Field display config */
|
||||||
|
export const FIELD_OPTIONS: { value: BulkEditFieldChoice; label: string; ai?: boolean }[] = [
|
||||||
|
{ value: "description", label: "Description", ai: true },
|
||||||
|
{ value: "name", label: "Name", ai: true },
|
||||||
|
{ value: "hts_code", label: "HTS Code" },
|
||||||
|
{ value: "weight", label: "Weight" },
|
||||||
|
{ value: "msrp", label: "MSRP" },
|
||||||
|
{ value: "cost_each", label: "Cost Each" },
|
||||||
|
{ value: "tax_cat", label: "Tax Category" },
|
||||||
|
{ value: "size_cat", label: "Size Category" },
|
||||||
|
{ value: "ship_restrictions", label: "Shipping Restrictions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Get the current raw value for a field from SearchProduct */
|
||||||
|
export function getFieldValue(product: SearchProduct, field: BulkEditFieldChoice): string {
|
||||||
|
switch (field) {
|
||||||
|
case "name": return product.title ?? "";
|
||||||
|
case "description": return product.description ?? "";
|
||||||
|
case "hts_code": return product.harmonized_tariff_code ?? "";
|
||||||
|
case "weight": return product.weight != null ? String(product.weight) : "";
|
||||||
|
case "msrp": return product.regular_price != null ? String(product.regular_price) : "";
|
||||||
|
case "cost_each": return product.cost_price != null ? String(product.cost_price) : "";
|
||||||
|
case "tax_cat": return product.tax_code ?? "";
|
||||||
|
case "size_cat": return product.size_cat ?? "";
|
||||||
|
case "ship_restrictions": return product.shipping_restrictions ?? "";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the backend field key for submission */
|
||||||
|
export function getSubmitFieldKey(field: BulkEditFieldChoice): string {
|
||||||
|
switch (field) {
|
||||||
|
case "name": return "description"; // backend field is "description" for product name
|
||||||
|
case "description": return "notes"; // backend uses "notes" for product description
|
||||||
|
case "hts_code": return "harmonized_tariff_code";
|
||||||
|
case "msrp": return "sellingprice";
|
||||||
|
case "cost_each": return "cost_each";
|
||||||
|
case "tax_cat": return "tax_code";
|
||||||
|
case "size_cat": return "size_cat";
|
||||||
|
case "ship_restrictions": return "shipping_restrictions";
|
||||||
|
default: return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkEditRowProps {
|
||||||
|
product: SearchProduct;
|
||||||
|
field: BulkEditFieldChoice;
|
||||||
|
state: RowAiState;
|
||||||
|
imageUrl: string | null;
|
||||||
|
selectOptions?: FieldOption[];
|
||||||
|
onAccept: (pid: number, value: string) => void;
|
||||||
|
onDismiss: (pid: number) => void;
|
||||||
|
onManualEdit: (pid: number, value: string) => void;
|
||||||
|
onEditSuggestion: (pid: number, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkEditRow({
|
||||||
|
product,
|
||||||
|
field,
|
||||||
|
state,
|
||||||
|
imageUrl,
|
||||||
|
selectOptions,
|
||||||
|
onAccept,
|
||||||
|
onDismiss,
|
||||||
|
onManualEdit,
|
||||||
|
onEditSuggestion,
|
||||||
|
}: BulkEditRowProps) {
|
||||||
|
const currentValue = state.manualEdit ?? getFieldValue(product, field);
|
||||||
|
const hasAiSuggestion =
|
||||||
|
state.status === "done" && state.result && !state.result.isValid && state.result.suggestion;
|
||||||
|
const isValid = state.status === "done" && state.result?.isValid;
|
||||||
|
const isAccepted = state.decision === "accepted";
|
||||||
|
const isDismissed = state.decision === "dismissed";
|
||||||
|
const isValidating = state.status === "validating";
|
||||||
|
const showSuggestion = hasAiSuggestion && !isDismissed && !isAccepted;
|
||||||
|
const backendUrl = `${BACKEND_URL}/${product.pid}`;
|
||||||
|
|
||||||
|
// Determine border color based on state
|
||||||
|
const borderClass = isAccepted
|
||||||
|
? "border-l-4 border-l-green-500"
|
||||||
|
: state.saveStatus === "saved"
|
||||||
|
? "border-l-4 border-l-green-300"
|
||||||
|
: state.saveStatus === "error"
|
||||||
|
? "border-l-4 border-l-destructive"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const renderFieldEditor = () => {
|
||||||
|
// If this is a select field, render a select
|
||||||
|
if (selectOptions) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={currentValue}
|
||||||
|
onValueChange={(v) => onManualEdit(product.pid, v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full h-8 text-sm">
|
||||||
|
<SelectValue placeholder="Select..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{selectOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description gets a textarea with inline spinner
|
||||||
|
if (field === "description") {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => onManualEdit(product.pid, e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"text-sm min-h-[60px] max-h-[120px] resize-y",
|
||||||
|
isValidating && "pr-8"
|
||||||
|
)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
{isValidating && (
|
||||||
|
<Loader2 className="absolute top-2 right-2 h-4 w-4 animate-spin text-purple-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name/text fields with inline spinner
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => onManualEdit(product.pid, e.target.value)}
|
||||||
|
className={cn("h-8 text-sm", isValidating && "pr-8")}
|
||||||
|
/>
|
||||||
|
{isValidating && (
|
||||||
|
<Loader2 className="absolute top-2 right-2 h-3.5 w-3.5 animate-spin text-purple-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline AI suggestion panel for name / short text fields
|
||||||
|
const renderNameSuggestion = () => {
|
||||||
|
if (!showSuggestion) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
|
||||||
|
{/* Issues */}
|
||||||
|
{state.result!.issues.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{state.result!.issues.map((issue, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-1 text-[11px] text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
|
<span>{issue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Editable suggestion + actions */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-purple-500 shrink-0" />
|
||||||
|
<Input
|
||||||
|
value={state.editedSuggestion ?? state.result!.suggestion!}
|
||||||
|
onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
|
||||||
|
className="h-7 text-sm flex-1 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 bg-purple-50/50 dark:bg-purple-950/20"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2 text-xs shrink-0 bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||||
|
onClick={() =>
|
||||||
|
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-1.5 text-xs text-gray-500 hover:text-gray-700 shrink-0"
|
||||||
|
onClick={() => onDismiss(product.pid)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline AI suggestion panel for description (larger, stacked)
|
||||||
|
const renderDescriptionSuggestion = () => {
|
||||||
|
if (!showSuggestion) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0 flex-1 bg-purple-50/60 dark:bg-purple-950/20 rounded-md p-2">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||||
|
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||||
|
AI Suggestion
|
||||||
|
</span>
|
||||||
|
{state.result!.issues.length > 0 && (
|
||||||
|
<span className="text-[11px] text-purple-500">
|
||||||
|
({state.result!.issues.length} {state.result!.issues.length === 1 ? "issue" : "issues"})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issues */}
|
||||||
|
{state.result!.issues.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{state.result!.issues.map((issue, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-1 text-[11px] text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
|
<span>{issue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editable suggestion */}
|
||||||
|
<Textarea
|
||||||
|
value={state.editedSuggestion ?? state.result!.suggestion!}
|
||||||
|
onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
|
||||||
|
className="text-sm min-h-[60px] max-h-[120px] resize-y border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 bg-white dark:bg-black/20"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||||
|
onClick={() =>
|
||||||
|
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2 text-xs text-gray-500 hover:text-gray-700"
|
||||||
|
onClick={() => onDismiss(product.pid)}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status icon (right edge)
|
||||||
|
const renderStatus = () => {
|
||||||
|
// Don't show spinner here anymore — it's in the field editor
|
||||||
|
if (isValid && !isAccepted) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>No changes needed</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isAccepted) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Change accepted</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.saveStatus === "saving") {
|
||||||
|
return <Loader2 className="h-4 w-4 animate-spin text-primary" />;
|
||||||
|
}
|
||||||
|
if (state.saveStatus === "saved") {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Save className="h-4 w-4 text-green-500" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Saved</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.saveStatus === "error") {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{state.saveError || "Save failed"}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-lg border bg-card transition-colors", borderClass)}>
|
||||||
|
<div className="flex items-start gap-3 p-3">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<a
|
||||||
|
href={backendUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block h-12 w-12 shrink-0 overflow-hidden rounded-md border bg-muted"
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl.startsWith("/") ? PROD_IMG_HOST + imageUrl : imageUrl}
|
||||||
|
alt={product.title}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-[10px] text-muted-foreground">
|
||||||
|
No img
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Identity */}
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0 w-44 shrink-0">
|
||||||
|
<a
|
||||||
|
href={backendUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm font-medium text-primary hover:underline flex items-center gap-1 truncate"
|
||||||
|
>
|
||||||
|
<span className="truncate">{product.title}</span>
|
||||||
|
<ExternalLink className="h-3 w-3 shrink-0" />
|
||||||
|
</a>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
UPC: {product.barcode || "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
SKU: {product.sku || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field editor */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{renderFieldEditor()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI suggestion inline (same row) */}
|
||||||
|
{field === "description"
|
||||||
|
? renderDescriptionSuggestion()
|
||||||
|
: renderNameSuggestion()
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Status icon */}
|
||||||
|
<div className="flex items-center shrink-0 w-6 justify-center pt-1">
|
||||||
|
{renderStatus()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
FileSearch,
|
FileSearch,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
FilePenLine,
|
FilePenLine,
|
||||||
|
PenLine,
|
||||||
Mail,
|
Mail,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
@@ -122,6 +123,12 @@ const toolsItems = [
|
|||||||
url: "/product-editor",
|
url: "/product-editor",
|
||||||
permission: "access:product_editor"
|
permission: "access:product_editor"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Bulk Edit",
|
||||||
|
icon: PenLine,
|
||||||
|
url: "/bulk-edit",
|
||||||
|
permission: "access:bulk_edit"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Newsletter",
|
title: "Newsletter",
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ interface DailyPhaseData {
|
|||||||
|
|
||||||
interface ForecastData {
|
interface ForecastData {
|
||||||
forecastSales: number
|
forecastSales: number
|
||||||
forecastRevenue: string
|
forecastRevenue: number
|
||||||
confidenceLevel: number
|
confidenceLevel: number
|
||||||
dailyForecasts: {
|
dailyForecasts: {
|
||||||
date: string
|
date: string
|
||||||
@@ -129,7 +129,7 @@ export function ForecastMetrics() {
|
|||||||
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
|
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
|
||||||
</div>
|
</div>
|
||||||
{isLoading || !data ? <MetricSkeleton /> : (
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
<p className="text-lg font-bold">{formatCurrency(Number(data.forecastRevenue) || 0)}</p>
|
<p className="text-lg font-bold">{formatCurrency(data.forecastRevenue)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ interface PhaseBreakdown {
|
|||||||
|
|
||||||
interface OverstockMetricsData {
|
interface OverstockMetricsData {
|
||||||
overstockedProducts: number
|
overstockedProducts: number
|
||||||
total_excess_units: number
|
totalExcessUnits: number
|
||||||
total_excess_cost: number
|
totalExcessCost: number
|
||||||
total_excess_retail: number
|
totalExcessRetail: number
|
||||||
category_data: {
|
categoryData: {
|
||||||
category: string
|
category: string
|
||||||
products: number
|
products: number
|
||||||
units: number
|
units: number
|
||||||
@@ -69,7 +69,7 @@ export function OverstockMetrics() {
|
|||||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
|
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
|
||||||
</div>
|
</div>
|
||||||
{isLoading || !data ? <MetricSkeleton /> : (
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
<p className="text-lg font-bold">{data.total_excess_units.toLocaleString()}</p>
|
<p className="text-lg font-bold">{data.totalExcessUnits.toLocaleString()}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
@@ -78,7 +78,7 @@ export function OverstockMetrics() {
|
|||||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p>
|
<p className="text-sm font-medium text-muted-foreground">Overstocked Cost</p>
|
||||||
</div>
|
</div>
|
||||||
{isLoading || !data ? <MetricSkeleton /> : (
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
<p className="text-lg font-bold">{formatCurrency(data.total_excess_cost)}</p>
|
<p className="text-lg font-bold">{formatCurrency(data.totalExcessCost)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
@@ -87,7 +87,7 @@ export function OverstockMetrics() {
|
|||||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
|
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
|
||||||
</div>
|
</div>
|
||||||
{isLoading || !data ? <MetricSkeleton /> : (
|
{isLoading || !data ? <MetricSkeleton /> : (
|
||||||
<p className="text-lg font-bold">{formatCurrency(data.total_excess_retail)}</p>
|
<p className="text-lg font-bold">{formatCurrency(data.totalExcessRetail)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
|
{data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function EditableInput({
|
|||||||
copyable,
|
copyable,
|
||||||
alwaysShowCopy,
|
alwaysShowCopy,
|
||||||
formatDisplay,
|
formatDisplay,
|
||||||
|
rightAction,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
@@ -30,6 +31,7 @@ export function EditableInput({
|
|||||||
copyable?: boolean;
|
copyable?: boolean;
|
||||||
alwaysShowCopy?: boolean;
|
alwaysShowCopy?: boolean;
|
||||||
formatDisplay?: (val: string) => string;
|
formatDisplay?: (val: string) => string;
|
||||||
|
rightAction?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -106,6 +108,7 @@ export function EditableInput({
|
|||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{rightAction}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useMemo, useCallback } from "react";
|
import { useState, useMemo, useCallback, useLayoutEffect, useRef } from "react";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Check, ChevronsUpDown, Sparkles, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { badgeVariants } from "@/components/ui/badge";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { FieldOption } from "./types";
|
import type { FieldOption } from "./types";
|
||||||
|
import type { TaxonomySuggestion } from "@/components/product-import/steps/ValidationStep/store/types";
|
||||||
|
|
||||||
interface ColorOption extends FieldOption {
|
interface ColorOption extends FieldOption {
|
||||||
hex?: string;
|
hex?: string;
|
||||||
@@ -34,6 +36,39 @@ function isWhite(hex: string) {
|
|||||||
return /^#?f{3,6}$/i.test(hex);
|
return /^#?f{3,6}$/i.test(hex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TruncatedBadge({ label, hex }: { label: string; hex?: string }) {
|
||||||
|
const textRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [isTruncated, setIsTruncated] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = textRef.current;
|
||||||
|
if (el) setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||||
|
}, [label]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip open={isTruncated ? undefined : false}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className={cn(badgeVariants({ variant: "secondary" }), "text-[11px] py-0 px-1.5 gap-1 font-normal max-w-full")}>
|
||||||
|
{hex && (
|
||||||
|
<span
|
||||||
|
className={cn("inline-block h-2.5 w-2.5 rounded-full shrink-0", isWhite(hex) && "border border-black")}
|
||||||
|
style={{ backgroundColor: hex }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
ref={textRef}
|
||||||
|
className="overflow-hidden whitespace-nowrap"
|
||||||
|
style={{ direction: "rtl", textOverflow: "ellipsis" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EditableMultiSelect({
|
export function EditableMultiSelect({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -42,6 +77,9 @@ export function EditableMultiSelect({
|
|||||||
placeholder,
|
placeholder,
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
showColors,
|
showColors,
|
||||||
|
suggestions,
|
||||||
|
isLoadingSuggestions,
|
||||||
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
options: FieldOption[];
|
options: FieldOption[];
|
||||||
value: string[];
|
value: string[];
|
||||||
@@ -50,9 +88,17 @@ export function EditableMultiSelect({
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
showColors?: boolean;
|
showColors?: boolean;
|
||||||
|
suggestions?: TaxonomySuggestion[];
|
||||||
|
isLoadingSuggestions?: boolean;
|
||||||
|
onOpen?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||||
|
setOpen(isOpen);
|
||||||
|
if (isOpen) onOpen?.();
|
||||||
|
}, [onOpen]);
|
||||||
|
|
||||||
const selectedLabels = useMemo(() => {
|
const selectedLabels = useMemo(() => {
|
||||||
return value.map((v) => {
|
return value.map((v) => {
|
||||||
const opt = options.find((o) => String(o.value) === String(v));
|
const opt = options.find((o) => String(o.value) === String(v));
|
||||||
@@ -82,7 +128,7 @@ export function EditableMultiSelect({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => handleOpenChange(true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-auto w-full rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
"flex flex-col h-auto w-full rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
||||||
"hover:border-input hover:bg-muted/50 transition-colors",
|
"hover:border-input hover:bg-muted/50 transition-colors",
|
||||||
@@ -98,22 +144,7 @@ export function EditableMultiSelect({
|
|||||||
) : (
|
) : (
|
||||||
<span className="flex flex-wrap gap-1 w-full">
|
<span className="flex flex-wrap gap-1 w-full">
|
||||||
{selectedLabels.map((s) => (
|
{selectedLabels.map((s) => (
|
||||||
<Badge
|
<TruncatedBadge key={s.value} label={s.label} hex={s.hex} />
|
||||||
key={s.value}
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[11px] py-0 px-1.5 gap-1 shrink-0 font-normal"
|
|
||||||
>
|
|
||||||
{s.hex && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-2.5 w-2.5 rounded-full shrink-0",
|
|
||||||
isWhite(s.hex) && "border border-black"
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: s.hex }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{s.label}
|
|
||||||
</Badge>
|
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -126,7 +157,7 @@ export function EditableMultiSelect({
|
|||||||
{label && (
|
{label && (
|
||||||
<span className="text-xs text-muted-foreground mb-1 block">{label}</span>
|
<span className="text-xs text-muted-foreground mb-1 block">{label}</span>
|
||||||
)}
|
)}
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -192,9 +223,54 @@ export function EditableMultiSelect({
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI Suggestions section */}
|
||||||
|
{(suggestions && suggestions.length > 0) || isLoadingSuggestions ? (
|
||||||
|
<CommandGroup>
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
<span>Suggested</span>
|
||||||
|
{isLoadingSuggestions && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
|
</div>
|
||||||
|
{suggestions?.slice(0, 5).map((suggestion) => {
|
||||||
|
const isSelected = value.includes(String(suggestion.id));
|
||||||
|
if (isSelected) return null;
|
||||||
|
const similarityPercent = Math.round(suggestion.similarity * 100);
|
||||||
|
const opt = options.find((o) => String(o.value) === String(suggestion.id)) as ColorOption | undefined;
|
||||||
|
const hex = showColors && opt ? getHex(opt) : undefined;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={`suggestion-${suggestion.id}`}
|
||||||
|
value={`suggestion-${suggestion.name}`}
|
||||||
|
onSelect={() => handleSelect(String(suggestion.id))}
|
||||||
|
className="bg-purple-50/30 dark:bg-purple-950/20"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<Check className="h-4 w-4 flex-shrink-0 opacity-0" />
|
||||||
|
{hex && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-3.5 w-3.5 rounded-full mr-2 shrink-0",
|
||||||
|
isWhite(hex) && "border border-black"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: hex }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span title={suggestion.fullPath || suggestion.name}>
|
||||||
|
{showColors ? suggestion.name : (suggestion.fullPath || suggestion.name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-purple-500 dark:text-purple-400 ml-2 flex-shrink-0">
|
||||||
|
{similarityPercent}%
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* All options (excluding already-selected) */}
|
{/* All options (excluding already-selected) */}
|
||||||
<CommandGroup
|
<CommandGroup
|
||||||
heading={value.length > 0 ? "All Options" : undefined}
|
heading={value.length > 0 || (suggestions && suggestions.length > 0) ? "All Options" : undefined}
|
||||||
>
|
>
|
||||||
{options
|
{options
|
||||||
.filter((o) => !value.includes(String(o.value)))
|
.filter((o) => !value.includes(String(o.value)))
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ function SortableImageCell({
|
|||||||
src={src}
|
src={src}
|
||||||
alt={`Image ${image.iid}`}
|
alt={`Image ${image.iid}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-full object-cover pointer-events-none select-none",
|
"w-full h-full object-contain pointer-events-none select-none",
|
||||||
isMain ? "rounded-lg" : "rounded-md"
|
isMain ? "rounded-lg" : "rounded-md"
|
||||||
)}
|
)}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink } from "lucide-react";
|
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink, Sparkles } from "lucide-react";
|
||||||
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { useInlineAiValidation } from "@/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation";
|
||||||
|
import { AiSuggestionBadge } from "@/components/product-import/steps/ValidationStep/components/AiSuggestionBadge";
|
||||||
|
import { AiDescriptionCompare } from "@/components/ai/AiDescriptionCompare";
|
||||||
|
import { submitProductEdit, submitImageChanges, submitTaxonomySet, type ImageChanges } from "@/services/productEditor";
|
||||||
import { EditableComboboxField } from "./EditableComboboxField";
|
import { EditableComboboxField } from "./EditableComboboxField";
|
||||||
import { EditableInput } from "./EditableInput";
|
import { EditableInput } from "./EditableInput";
|
||||||
import { EditableMultiSelect } from "./EditableMultiSelect";
|
import { EditableMultiSelect } from "./EditableMultiSelect";
|
||||||
|
import { useProductSuggestions } from "./useProductSuggestions";
|
||||||
import { ImageManager, MiniImagePreview } from "./ImageManager";
|
import { ImageManager, MiniImagePreview } from "./ImageManager";
|
||||||
import type {
|
import type {
|
||||||
SearchProduct,
|
SearchProduct,
|
||||||
@@ -75,6 +80,8 @@ interface FieldConfig {
|
|||||||
showColors?: boolean;
|
showColors?: boolean;
|
||||||
/** Format value for display (editing shows raw value) */
|
/** Format value for display (editing shows raw value) */
|
||||||
formatDisplay?: (val: string) => string;
|
formatDisplay?: (val: string) => string;
|
||||||
|
/** Number of grid columns this field spans (default 1) */
|
||||||
|
colSpan?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FieldGroup {
|
interface FieldGroup {
|
||||||
@@ -103,7 +110,8 @@ const F: Record<string, FieldConfig> = {
|
|||||||
artist: { key: "artist", label: "Artist", type: "combobox", optionsKey: "artists", searchPlaceholder: "Search artists..." },
|
artist: { key: "artist", label: "Artist", type: "combobox", optionsKey: "artists", searchPlaceholder: "Search artists..." },
|
||||||
tax_cat: { key: "tax_cat", label: "Tax Cat", type: "combobox", optionsKey: "taxCategories" },
|
tax_cat: { key: "tax_cat", label: "Tax Cat", type: "combobox", optionsKey: "taxCategories" },
|
||||||
ship: { key: "ship_restrictions", label: "Shipping", type: "combobox", optionsKey: "shippingRestrictions" },
|
ship: { key: "ship_restrictions", label: "Shipping", type: "combobox", optionsKey: "shippingRestrictions" },
|
||||||
msrp: { key: "msrp", label: "MSRP", type: "input" },
|
msrp: { key: "msrp", label: "MSRP", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
||||||
|
cur_price: { key: "current_price", label: "Current", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
||||||
cost: { key: "cost_each", label: "Cost", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
cost: { key: "cost_each", label: "Cost", type: "input", formatDisplay: (v) => { const n = parseFloat(v); return isNaN(n) ? v : n.toFixed(2); } },
|
||||||
min_qty: { key: "qty_per_unit", label: "Min Qty", type: "input" },
|
min_qty: { key: "qty_per_unit", label: "Min Qty", type: "input" },
|
||||||
case_qty: { key: "case_qty", label: "Case Pack", type: "input" },
|
case_qty: { key: "case_qty", label: "Case Pack", type: "input" },
|
||||||
@@ -141,7 +149,7 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
|
|||||||
{ label: "Taxonomy", cols: 2, fields: [F.supplier,F.company, F.line, F.subline] },
|
{ label: "Taxonomy", cols: 2, fields: [F.supplier,F.company, F.line, F.subline] },
|
||||||
{ cols: 2, fields: [F.artist, F.size_cat] },
|
{ cols: 2, fields: [F.artist, F.size_cat] },
|
||||||
{ label: "Description", cols: 1, fields: [F.description] },
|
{ label: "Description", cols: 1, fields: [F.description] },
|
||||||
{ label: "Pricing", cols: 4, fields: [F.msrp, F.cost, F.min_qty, F.case_qty] },
|
{ label: "Pricing", cols: 5, fields: [F.msrp, F.cur_price, F.cost, F.min_qty, F.case_qty] },
|
||||||
{ label: "Dimensions", cols: 4, fields: [F.weight, F.length, F.width, F.height] },
|
{ label: "Dimensions", cols: 4, fields: [F.weight, F.length, F.width, F.height] },
|
||||||
{ cols: 4, fields: [ F.tax_cat, F.ship,F.coo, F.hts_code] },
|
{ cols: 4, fields: [ F.tax_cat, F.ship,F.coo, F.hts_code] },
|
||||||
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
||||||
@@ -152,22 +160,21 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
|
|||||||
sidebarGroups: 3,
|
sidebarGroups: 3,
|
||||||
descriptionRows: 8,
|
descriptionRows: 8,
|
||||||
groups: [
|
groups: [
|
||||||
{ label: "Taxonomy", cols: 2, fields: [F.company, F.msrp, F.line, F.subline] },
|
{ label: "Taxonomy", cols: 7, fields: [{ ...F.company, colSpan: 3 }, { ...F.msrp, colSpan: 2 }, { ...F.cur_price, colSpan: 2 }] },
|
||||||
{ cols: 2, fields: [F.artist, F.size_cat] },
|
{ cols: 2, fields: [F.line, F.subline, F.artist, F.size_cat] },
|
||||||
{ label: "Description", cols: 1, fields: [F.description] },
|
{ label: "Description", cols: 1, fields: [F.description] },
|
||||||
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
backend: {
|
backend: {
|
||||||
sidebarGroups: 5,
|
sidebarGroups: 6,
|
||||||
groups: [
|
groups: [
|
||||||
{ label: "Pricing", cols: 2, fields: [F.supplier, F.min_qty, F.cost, F.msrp] },
|
{ label: "Pricing", cols: 2, fields: [F.supplier, F.min_qty] },
|
||||||
|
{ cols: 3, fields: [F.cost, F.cur_price, F.msrp] },
|
||||||
{ cols: 3, fields: [F.case_qty, F.size_cat, F.weight] },
|
{ cols: 3, fields: [F.case_qty, F.size_cat, F.weight] },
|
||||||
{ label: "Dimensions", cols: 3, fields: [ F.length, F.width, F.height] },
|
{ label: "Dimensions", cols: 3, fields: [F.length, F.width, F.height] },
|
||||||
{ cols: 2, fields: [F.tax_cat, F.ship, F.coo, F.hts_code] },
|
{ cols: 2, fields: [F.tax_cat, F.ship, F.coo, F.hts_code] },
|
||||||
{ label: "Notes", cols: 1, fields: [F.priv_notes] },
|
{ label: "Notes", cols: 1, fields: [F.priv_notes] },
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
minimal: {
|
minimal: {
|
||||||
@@ -207,11 +214,14 @@ export function ProductEditForm({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
watch,
|
watch,
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
formState: { dirtyFields },
|
formState: { dirtyFields },
|
||||||
} = useForm<ProductFormValues>();
|
} = useForm<ProductFormValues>();
|
||||||
|
|
||||||
const watchCompany = watch("company");
|
const watchCompany = watch("company");
|
||||||
const watchLine = watch("line");
|
const watchLine = watch("line");
|
||||||
|
const watchDescription = watch("description");
|
||||||
|
|
||||||
// Populate form on mount
|
// Populate form on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -226,6 +236,7 @@ export function ProductEditForm({
|
|||||||
supplier_no: product.vendor_reference ?? "",
|
supplier_no: product.vendor_reference ?? "",
|
||||||
notions_no: product.notions_reference ?? "",
|
notions_no: product.notions_reference ?? "",
|
||||||
msrp: String(product.regular_price ?? ""),
|
msrp: String(product.regular_price ?? ""),
|
||||||
|
current_price: String(product.price ?? ""),
|
||||||
cost_each: String(product.cost_price ?? ""),
|
cost_each: String(product.cost_price ?? ""),
|
||||||
qty_per_unit: String(product.moq ?? ""),
|
qty_per_unit: String(product.moq ?? ""),
|
||||||
case_qty: String(product.case_qty ?? ""),
|
case_qty: String(product.case_qty ?? ""),
|
||||||
@@ -317,12 +328,13 @@ export function ProductEditForm({
|
|||||||
const originalIds = original.map((img) => img.iid);
|
const originalIds = original.map((img) => img.iid);
|
||||||
const currentIds = current.map((img) => img.iid);
|
const currentIds = current.map((img) => img.iid);
|
||||||
|
|
||||||
const deleted = originalIds.filter((id) => !currentIds.includes(id)) as number[];
|
const toDelete = originalIds.filter((id) => !currentIds.includes(id)) as number[];
|
||||||
const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
||||||
const added: Record<string, string> = {};
|
const show = current.filter((img) => !img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
||||||
|
const add: Record<string, string> = {};
|
||||||
for (const img of current) {
|
for (const img of current) {
|
||||||
if (img.isNew && img.imageUrl) {
|
if (img.isNew && img.imageUrl) {
|
||||||
added[String(img.iid)] = img.imageUrl;
|
add[String(img.iid)] = img.imageUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,14 +343,14 @@ export function ProductEditForm({
|
|||||||
const originalHidden = original.filter((img) => img.hidden).map((img) => img.iid);
|
const originalHidden = original.filter((img) => img.hidden).map((img) => img.iid);
|
||||||
const orderChanged = JSON.stringify(order.filter((id) => typeof id === "number")) !== JSON.stringify(originalIds);
|
const orderChanged = JSON.stringify(order.filter((id) => typeof id === "number")) !== JSON.stringify(originalIds);
|
||||||
const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
|
const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
|
||||||
const hasDeleted = deleted.length > 0;
|
const hasDeleted = toDelete.length > 0;
|
||||||
const hasAdded = Object.keys(added).length > 0;
|
const hasAdded = Object.keys(add).length > 0;
|
||||||
|
|
||||||
if (!orderChanged && !hiddenChanged && !hasDeleted && !hasAdded) {
|
if (!orderChanged && !hiddenChanged && !hasDeleted && !hasAdded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { order, hidden, deleted, added };
|
return { order, hidden, show, delete: toDelete, add };
|
||||||
}, [productImages]);
|
}, [productImages]);
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
@@ -362,32 +374,57 @@ export function ProductEditForm({
|
|||||||
|
|
||||||
const imageChanges = computeImageChanges();
|
const imageChanges = computeImageChanges();
|
||||||
|
|
||||||
if (Object.keys(changes).length === 0 && !imageChanges) {
|
// Extract taxonomy changes for separate API calls
|
||||||
|
const taxonomyCalls: { type: "cats" | "themes" | "colors"; ids: number[] }[] = [];
|
||||||
|
if ("categories" in changes) {
|
||||||
|
taxonomyCalls.push({ type: "cats", ids: (changes.categories as string[]).map(Number) });
|
||||||
|
delete changes.categories;
|
||||||
|
}
|
||||||
|
if ("themes" in changes) {
|
||||||
|
taxonomyCalls.push({ type: "themes", ids: (changes.themes as string[]).map(Number) });
|
||||||
|
delete changes.themes;
|
||||||
|
}
|
||||||
|
if ("colors" in changes) {
|
||||||
|
taxonomyCalls.push({ type: "colors", ids: (changes.colors as string[]).map(Number) });
|
||||||
|
delete changes.colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFieldChanges = Object.keys(changes).length > 0;
|
||||||
|
|
||||||
|
if (!hasFieldChanges && !imageChanges && taxonomyCalls.length === 0) {
|
||||||
toast.info("No changes to submit");
|
toast.info("No changes to submit");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await submitProductEdit({
|
const promises: Promise<{ success: boolean; error?: unknown; message?: string }>[] = [];
|
||||||
pid: product.pid,
|
|
||||||
changes,
|
|
||||||
environment: "prod",
|
|
||||||
imageChanges: imageChanges ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
if (hasFieldChanges) {
|
||||||
|
promises.push(submitProductEdit({ pid: product.pid, changes, environment: "prod" }));
|
||||||
|
}
|
||||||
|
if (imageChanges) {
|
||||||
|
promises.push(submitImageChanges({ pid: product.pid, imageChanges, environment: "prod" }));
|
||||||
|
}
|
||||||
|
for (const { type, ids } of taxonomyCalls) {
|
||||||
|
promises.push(submitTaxonomySet({ pid: product.pid, type, ids, environment: "prod" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
const failed = results.find((r) => !r.success);
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
const errorDetail = Array.isArray(failed.error)
|
||||||
|
? failed.error.filter((e) => e !== "Errors").join("; ")
|
||||||
|
: typeof failed.error === "string"
|
||||||
|
? failed.error
|
||||||
|
: null;
|
||||||
|
toast.error(errorDetail || failed.message || "Failed to update product");
|
||||||
|
} else {
|
||||||
toast.success("Product updated successfully");
|
toast.success("Product updated successfully");
|
||||||
originalValuesRef.current = { ...data };
|
originalValuesRef.current = { ...data };
|
||||||
originalImagesRef.current = [...productImages];
|
originalImagesRef.current = [...productImages];
|
||||||
reset(data);
|
reset(data);
|
||||||
} else {
|
|
||||||
const errorDetail = Array.isArray(result.error)
|
|
||||||
? result.error.filter((e) => e !== "Errors").join("; ")
|
|
||||||
: typeof result.error === "string"
|
|
||||||
? result.error
|
|
||||||
: null;
|
|
||||||
toast.error(errorDetail || result.message || "Failed to update product");
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -411,6 +448,76 @@ export function ProductEditForm({
|
|||||||
[fieldOptions, lineOptions, sublineOptions]
|
[fieldOptions, lineOptions, sublineOptions]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- AI inline validation ---
|
||||||
|
const [validatingField, setValidatingField] = useState<"name" | "description" | null>(null);
|
||||||
|
const [descDialogOpen, setDescDialogOpen] = useState(false);
|
||||||
|
const {
|
||||||
|
validateName,
|
||||||
|
validateDescription,
|
||||||
|
nameResult,
|
||||||
|
descriptionResult,
|
||||||
|
clearNameResult,
|
||||||
|
clearDescriptionResult,
|
||||||
|
} = useInlineAiValidation();
|
||||||
|
|
||||||
|
const handleValidateName = useCallback(async () => {
|
||||||
|
const values = getValues();
|
||||||
|
if (!values.name?.trim()) return;
|
||||||
|
clearNameResult();
|
||||||
|
setValidatingField("name");
|
||||||
|
const companyLabel = fieldOptions.companies.find((c) => c.value === values.company)?.label;
|
||||||
|
const lineLabel = lineOptions.find((l) => l.value === values.line)?.label;
|
||||||
|
const sublineLabel = sublineOptions.find((s) => s.value === values.subline)?.label;
|
||||||
|
const result = await validateName({
|
||||||
|
name: values.name,
|
||||||
|
company_name: companyLabel,
|
||||||
|
company_id: values.company,
|
||||||
|
line_name: lineLabel,
|
||||||
|
subline_name: sublineLabel,
|
||||||
|
});
|
||||||
|
setValidatingField((prev) => (prev === "name" ? null : prev));
|
||||||
|
if (result && result.isValid && !result.suggestion) {
|
||||||
|
toast.success("Name looks good!");
|
||||||
|
}
|
||||||
|
}, [getValues, fieldOptions, lineOptions, sublineOptions, validateName, clearNameResult]);
|
||||||
|
|
||||||
|
const handleValidateDescription = useCallback(async () => {
|
||||||
|
const values = getValues();
|
||||||
|
if (!values.description?.trim() || values.description.trim().length < 10) return;
|
||||||
|
clearDescriptionResult();
|
||||||
|
setValidatingField("description");
|
||||||
|
const companyLabel = fieldOptions.companies.find((c) => c.value === values.company)?.label;
|
||||||
|
const categoryLabels = values.categories
|
||||||
|
?.map((id) => fieldOptions.categories.find((c) => c.value === id)?.label)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
const result = await validateDescription({
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
company_name: companyLabel,
|
||||||
|
company_id: values.company,
|
||||||
|
categories: categoryLabels || undefined,
|
||||||
|
});
|
||||||
|
setValidatingField((prev) => (prev === "description" ? null : prev));
|
||||||
|
if (result && result.isValid && !result.suggestion) {
|
||||||
|
toast.success("Description looks good!");
|
||||||
|
}
|
||||||
|
}, [getValues, fieldOptions, validateDescription, clearDescriptionResult]);
|
||||||
|
|
||||||
|
// --- Embedding-based taxonomy suggestions ---
|
||||||
|
const {
|
||||||
|
categories: categorySuggestions,
|
||||||
|
themes: themeSuggestions,
|
||||||
|
colors: colorSuggestions,
|
||||||
|
isLoading: isSuggestionsLoading,
|
||||||
|
triggerFetch: triggerSuggestions,
|
||||||
|
} = useProductSuggestions({
|
||||||
|
name: product.title,
|
||||||
|
description: product.description,
|
||||||
|
company_name: product.brand,
|
||||||
|
line_name: product.line,
|
||||||
|
});
|
||||||
|
|
||||||
const hasImageChanges = computeImageChanges() !== null;
|
const hasImageChanges = computeImageChanges() !== null;
|
||||||
const changedCount = Object.keys(dirtyFields).length;
|
const changedCount = Object.keys(dirtyFields).length;
|
||||||
|
|
||||||
@@ -421,8 +528,13 @@ export function ProductEditForm({
|
|||||||
style={{ gridTemplateColumns: `repeat(${group.cols}, minmax(0, 1fr))` }}
|
style={{ gridTemplateColumns: `repeat(${group.cols}, minmax(0, 1fr))` }}
|
||||||
>
|
>
|
||||||
{group.fields.map((fc) => {
|
{group.fields.map((fc) => {
|
||||||
|
const wrapSpan = (node: React.ReactNode) =>
|
||||||
|
fc.colSpan && fc.colSpan > 1
|
||||||
|
? <div key={fc.key} style={{ gridColumn: `span ${fc.colSpan}` }}>{node}</div>
|
||||||
|
: node;
|
||||||
|
|
||||||
if (fc.type === "input") {
|
if (fc.type === "input") {
|
||||||
return (
|
return wrapSpan(
|
||||||
<Controller
|
<Controller
|
||||||
key={fc.key}
|
key={fc.key}
|
||||||
name={fc.key}
|
name={fc.key}
|
||||||
@@ -443,7 +555,7 @@ export function ProductEditForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (fc.type === "combobox") {
|
if (fc.type === "combobox") {
|
||||||
return (
|
return wrapSpan(
|
||||||
<Controller
|
<Controller
|
||||||
key={fc.key}
|
key={fc.key}
|
||||||
name={fc.key}
|
name={fc.key}
|
||||||
@@ -463,7 +575,12 @@ export function ProductEditForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (fc.type === "multiselect") {
|
if (fc.type === "multiselect") {
|
||||||
return (
|
const fieldSuggestions =
|
||||||
|
fc.key === "categories" ? categorySuggestions :
|
||||||
|
fc.key === "themes" ? themeSuggestions :
|
||||||
|
fc.key === "colors" ? colorSuggestions :
|
||||||
|
undefined;
|
||||||
|
return wrapSpan(
|
||||||
<Controller
|
<Controller
|
||||||
key={fc.key}
|
key={fc.key}
|
||||||
name={fc.key}
|
name={fc.key}
|
||||||
@@ -477,15 +594,51 @@ export function ProductEditForm({
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
searchPlaceholder={fc.searchPlaceholder}
|
searchPlaceholder={fc.searchPlaceholder}
|
||||||
showColors={fc.showColors}
|
showColors={fc.showColors}
|
||||||
|
suggestions={fieldSuggestions}
|
||||||
|
isLoadingSuggestions={isSuggestionsLoading}
|
||||||
|
onOpen={triggerSuggestions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (fc.type === "textarea") {
|
if (fc.type === "textarea") {
|
||||||
|
const isDescription = fc.key === "description";
|
||||||
return (
|
return (
|
||||||
<div key={fc.key} className="col-span-full flex flex-col gap-0.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm hover:border-input hover:bg-muted/50 transition-colors">
|
<div key={fc.key} className="col-span-full flex flex-col gap-0.5 rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm hover:border-input hover:bg-muted/50 transition-colors">
|
||||||
<span className="text-muted-foreground text-xs shrink-0">{fc.label}</span>
|
<div className="flex items-center justify-between relative">
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">{fc.label}</span>
|
||||||
|
{isDescription && (watchDescription?.trim().length ?? 0) >= 10 && (
|
||||||
|
descriptionResult?.suggestion ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDescDialogOpen(true)}
|
||||||
|
className="flex items-center gap-1 px-1.5 -mr-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors shrink-0"
|
||||||
|
title="View AI suggestion"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
<span>{descriptionResult.issues.length}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 absolute top-0.5 -right-1 text-purple-500 hover:text-purple-600 transition-colors p-0.5"
|
||||||
|
onClick={handleValidateDescription}
|
||||||
|
disabled={validatingField === "description"}
|
||||||
|
>
|
||||||
|
{validatingField === "description"
|
||||||
|
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
: <Sparkles className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">AI validate description</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Textarea {...register(fc.key)} rows={(fc.key === "description" && MODE_LAYOUTS[layoutMode].descriptionRows) || fc.rows || 3} className="border-0 p-0 h-auto shadow-none focus-visible:ring-0 resize-y text-sm min-h-0" />
|
<Textarea {...register(fc.key)} rows={(fc.key === "description" && MODE_LAYOUTS[layoutMode].descriptionRows) || fc.rows || 3} className="border-0 p-0 h-auto shadow-none focus-visible:ring-0 resize-y text-sm min-h-0" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -499,7 +652,7 @@ export function ProductEditForm({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0 flex items-center gap-1">
|
<div className="flex-1 min-w-0 flex items-start gap-1">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Controller
|
<Controller
|
||||||
name="name"
|
name="name"
|
||||||
@@ -512,9 +665,42 @@ export function ProductEditForm({
|
|||||||
placeholder="Product name"
|
placeholder="Product name"
|
||||||
className="text-base font-semibold"
|
className="text-base font-semibold"
|
||||||
inputClassName="text-base font-semibold"
|
inputClassName="text-base font-semibold"
|
||||||
|
rightAction={
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 text-purple-500 hover:text-purple-600 transition-colors"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleValidateName(); }}
|
||||||
|
disabled={validatingField === "name"}
|
||||||
|
>
|
||||||
|
{validatingField === "name"
|
||||||
|
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
: <Sparkles className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">AI validate name</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{nameResult?.suggestion && (
|
||||||
|
<AiSuggestionBadge
|
||||||
|
suggestion={nameResult.suggestion}
|
||||||
|
issues={nameResult.issues}
|
||||||
|
onAccept={(editedValue) => {
|
||||||
|
setValue("name", editedValue, { shouldDirty: true });
|
||||||
|
clearNameResult();
|
||||||
|
}}
|
||||||
|
onDismiss={clearNameResult}
|
||||||
|
onRevalidate={handleValidateName}
|
||||||
|
isRevalidating={validatingField === "name"}
|
||||||
|
compact
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -611,6 +797,38 @@ export function ProductEditForm({
|
|||||||
renderFieldGroup(group, gi + MODE_LAYOUTS[layoutMode].sidebarGroups)
|
renderFieldGroup(group, gi + MODE_LAYOUTS[layoutMode].sidebarGroups)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI Description Review Dialog */}
|
||||||
|
{descriptionResult?.suggestion && (
|
||||||
|
<Dialog open={descDialogOpen} onOpenChange={setDescDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||||
|
AI Description Review
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<AiDescriptionCompare
|
||||||
|
currentValue={getValues("description")}
|
||||||
|
onCurrentChange={(v) => setValue("description", v, { shouldDirty: true })}
|
||||||
|
suggestion={descriptionResult.suggestion}
|
||||||
|
issues={descriptionResult.issues}
|
||||||
|
productName={getValues("name")}
|
||||||
|
onAccept={(text) => {
|
||||||
|
setValue("description", text, { shouldDirty: true });
|
||||||
|
clearDescriptionResult();
|
||||||
|
setDescDialogOpen(false);
|
||||||
|
}}
|
||||||
|
onDismiss={() => {
|
||||||
|
clearDescriptionResult();
|
||||||
|
setDescDialogOpen(false);
|
||||||
|
}}
|
||||||
|
onRevalidate={handleValidateDescription}
|
||||||
|
isRevalidating={validatingField === "description"}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<div className="flex items-center justify-end gap-3 pt-2">
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import axios from "axios";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -12,10 +11,16 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import {
|
||||||
import { Loader2, Search } from "lucide-react";
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { Check, ChevronDown, Loader2, Search } from "lucide-react";
|
||||||
import type { SearchProduct } from "./types";
|
import type { SearchProduct } from "./types";
|
||||||
|
|
||||||
|
const SEARCH_LIMIT = 100;
|
||||||
|
|
||||||
interface QuickSearchResult {
|
interface QuickSearchResult {
|
||||||
pid: number;
|
pid: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -29,31 +34,44 @@ interface QuickSearchResult {
|
|||||||
|
|
||||||
export function ProductSearch({
|
export function ProductSearch({
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onLoadAll,
|
||||||
|
onNewSearch,
|
||||||
|
loadedPids,
|
||||||
}: {
|
}: {
|
||||||
onSelect: (product: SearchProduct) => void;
|
onSelect: (product: SearchProduct) => void;
|
||||||
|
onLoadAll: (pids: number[]) => void;
|
||||||
|
onNewSearch: () => void;
|
||||||
|
loadedPids: Set<number>;
|
||||||
}) {
|
}) {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
|
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
|
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
|
||||||
|
const [resultsOpen, setResultsOpen] = useState(false);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
const handleSearch = useCallback(async () => {
|
const handleSearch = useCallback(async () => {
|
||||||
if (!searchTerm.trim()) return;
|
if (!searchTerm.trim()) return;
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
|
onNewSearch();
|
||||||
try {
|
try {
|
||||||
const res = await axios.get("/api/products/search", {
|
const res = await axios.get("/api/products/search", {
|
||||||
params: { q: searchTerm },
|
params: { q: searchTerm },
|
||||||
});
|
});
|
||||||
setSearchResults(res.data);
|
setSearchResults(res.data.results);
|
||||||
|
setTotalCount(res.data.total);
|
||||||
|
setResultsOpen(true);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Search failed");
|
toast.error("Search failed");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
}, [searchTerm]);
|
}, [searchTerm, onNewSearch]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
async (product: QuickSearchResult) => {
|
async (product: QuickSearchResult) => {
|
||||||
|
if (loadedPids.has(Number(product.pid))) return;
|
||||||
setIsLoadingProduct(product.pid);
|
setIsLoadingProduct(product.pid);
|
||||||
try {
|
try {
|
||||||
const res = await axios.get("/api/import/search-products", {
|
const res = await axios.get("/api/import/search-products", {
|
||||||
@@ -62,7 +80,7 @@ export function ProductSearch({
|
|||||||
const full = (res.data as SearchProduct[])[0];
|
const full = (res.data as SearchProduct[])[0];
|
||||||
if (full) {
|
if (full) {
|
||||||
onSelect(full);
|
onSelect(full);
|
||||||
setSearchResults([]);
|
setResultsOpen(false);
|
||||||
} else {
|
} else {
|
||||||
toast.error("Could not load full product details");
|
toast.error("Could not load full product details");
|
||||||
}
|
}
|
||||||
@@ -72,73 +90,124 @@ export function ProductSearch({
|
|||||||
setIsLoadingProduct(null);
|
setIsLoadingProduct(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onSelect]
|
[onSelect, loadedPids]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const handleLoadAll = useCallback(() => {
|
||||||
<Card>
|
const pids = searchResults
|
||||||
<CardHeader>
|
.map((r) => r.pid)
|
||||||
<CardTitle className="text-lg">Search Products</CardTitle>
|
.filter((pid) => !loadedPids.has(Number(pid)));
|
||||||
</CardHeader>
|
if (pids.length === 0) return;
|
||||||
<CardContent>
|
onLoadAll(pids);
|
||||||
<div className="flex gap-2">
|
setResultsOpen(false);
|
||||||
<Input
|
}, [searchResults, loadedPids, onLoadAll]);
|
||||||
placeholder="Search by name, SKU, UPC, brand..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
|
||||||
/>
|
|
||||||
<Button onClick={handleSearch} disabled={isSearching}>
|
|
||||||
{isSearching ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{searchResults.length > 0 && (
|
const unloadedCount = searchResults.filter(
|
||||||
<div className="mt-4 border rounded-md">
|
(r) => !loadedPids.has(Number(r.pid))
|
||||||
<ScrollArea className="max-h-80">
|
).length;
|
||||||
|
const isTruncated = totalCount > SEARCH_LIMIT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search products…"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSearch} disabled={isSearching}>
|
||||||
|
{isSearching ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(isFocused || searchResults.length === 0) && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 ml-3">
|
||||||
|
Search by name, item number, UPC, company, supplier, supplier id, notions #, line, subline, artist
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<Collapsible open={resultsOpen} onOpenChange={setResultsOpen} className="mt-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="gap-1 px-2 text-muted-foreground">
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 transition-transform ${resultsOpen ? "" : "-rotate-90"}`}
|
||||||
|
/>
|
||||||
|
{isTruncated
|
||||||
|
? `Showing ${SEARCH_LIMIT} of ${totalCount} results`
|
||||||
|
: `${totalCount} ${totalCount === 1 ? "result" : "results"}`}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
{unloadedCount > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleLoadAll}>
|
||||||
|
Load all results
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border rounded-md mt-2 max-h-80 overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead className="sticky top-0 bg-background">Name</TableHead>
|
||||||
<TableHead>SKU</TableHead>
|
<TableHead className="sticky top-0 bg-background">Item Number</TableHead>
|
||||||
<TableHead>Brand</TableHead>
|
<TableHead className="sticky top-0 bg-background">Brand</TableHead>
|
||||||
<TableHead>Line</TableHead>
|
<TableHead className="sticky top-0 bg-background">Line</TableHead>
|
||||||
<TableHead className="text-right">Price</TableHead>
|
<TableHead className="sticky top-0 bg-background text-right">
|
||||||
|
Price
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{searchResults.map((product) => (
|
{searchResults.map((product) => {
|
||||||
<TableRow
|
const isLoaded = loadedPids.has(Number(product.pid));
|
||||||
key={product.pid}
|
return (
|
||||||
className={`cursor-pointer hover:bg-muted/50 ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
|
<TableRow
|
||||||
onClick={() => !isLoadingProduct && handleSelect(product)}
|
key={product.pid}
|
||||||
>
|
className={`${isLoaded ? "opacity-50" : "cursor-pointer hover:bg-muted/50"} ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
|
||||||
<TableCell className="max-w-[300px] truncate">
|
onClick={() =>
|
||||||
{isLoadingProduct === product.pid && (
|
!isLoadingProduct && !isLoaded && handleSelect(product)
|
||||||
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
|
}
|
||||||
)}
|
>
|
||||||
{product.title}
|
<TableCell className="max-w-[300px] truncate">
|
||||||
</TableCell>
|
{isLoadingProduct === product.pid && (
|
||||||
<TableCell>{product.sku}</TableCell>
|
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
|
||||||
<TableCell>{product.brand}</TableCell>
|
)}
|
||||||
<TableCell>{product.line}</TableCell>
|
{isLoaded && (
|
||||||
<TableCell className="text-right">
|
<Check className="h-3 w-3 inline mr-2 text-green-600" />
|
||||||
$
|
)}
|
||||||
{Number(product.regular_price)?.toFixed(2) ??
|
{product.title}
|
||||||
product.regular_price}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>{product.sku}</TableCell>
|
||||||
</TableRow>
|
<TableCell>{product.brand}</TableCell>
|
||||||
))}
|
<TableCell>{product.line}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
$
|
||||||
|
{Number(product.regular_price)?.toFixed(2) ??
|
||||||
|
product.regular_price}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
{isTruncated && (
|
||||||
)}
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
</CardContent>
|
Showing only the first {SEARCH_LIMIT} of {totalCount} matches. Refine your search to find specific products.
|
||||||
</Card>
|
</p>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export interface ProductFormValues {
|
|||||||
supplier_no: string;
|
supplier_no: string;
|
||||||
notions_no: string;
|
notions_no: string;
|
||||||
msrp: string;
|
msrp: string;
|
||||||
|
current_price: string;
|
||||||
cost_each: string;
|
cost_each: string;
|
||||||
qty_per_unit: string;
|
qty_per_unit: string;
|
||||||
case_qty: string;
|
case_qty: string;
|
||||||
|
|||||||
107
inventory/src/components/product-editor/useProductSuggestions.ts
Normal file
107
inventory/src/components/product-editor/useProductSuggestions.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* useProductSuggestions Hook
|
||||||
|
*
|
||||||
|
* Lazily fetches embedding-based taxonomy suggestions (categories, themes, colors)
|
||||||
|
* for a product in the product editor.
|
||||||
|
*
|
||||||
|
* Mirrors the logic in AiSuggestionsContext but simplified for single-product use:
|
||||||
|
* - Fetches once on first triggerFetch() call (no eager batch loading)
|
||||||
|
* - Caches results in local state for the lifetime of the component
|
||||||
|
* - Module-level init promise shared across all instances
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import type { TaxonomySuggestion, ProductSuggestions } from '@/components/product-import/steps/ValidationStep/store/types';
|
||||||
|
|
||||||
|
const API_BASE = '/api/ai';
|
||||||
|
|
||||||
|
// Module-level init promise — shared so we only call /initialize once
|
||||||
|
let initPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
async function ensureInitialized(): Promise<boolean> {
|
||||||
|
if (!initPromise) {
|
||||||
|
initPromise = fetch(`${API_BASE}/initialize`, { method: 'POST' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => Boolean(d.success))
|
||||||
|
.catch(() => {
|
||||||
|
initPromise = null; // allow retry on next call
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
company_name?: string;
|
||||||
|
line_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSuggestionResults {
|
||||||
|
categories: TaxonomySuggestion[];
|
||||||
|
themes: TaxonomySuggestion[];
|
||||||
|
colors: TaxonomySuggestion[];
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Call when a taxonomy dropdown opens to trigger a lazy fetch */
|
||||||
|
triggerFetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProductSuggestions(product: ProductInput): ProductSuggestionResults {
|
||||||
|
const [categories, setCategories] = useState<TaxonomySuggestion[]>([]);
|
||||||
|
const [themes, setThemes] = useState<TaxonomySuggestion[]>([]);
|
||||||
|
const [colors, setColors] = useState<TaxonomySuggestion[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Store current product in a ref so triggerFetch can read it without being re-created
|
||||||
|
const productRef = useRef(product);
|
||||||
|
productRef.current = product;
|
||||||
|
|
||||||
|
// Pre-warm: start initialization as soon as the form mounts so it's ready before
|
||||||
|
// the first dropdown opens. With the disk cache this completes in < 1 second.
|
||||||
|
useEffect(() => {
|
||||||
|
ensureInitialized();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Prevent duplicate fetches
|
||||||
|
const hasFetchedRef = useRef(false);
|
||||||
|
|
||||||
|
const triggerFetch = useCallback(async () => {
|
||||||
|
if (hasFetchedRef.current) return;
|
||||||
|
const p = productRef.current;
|
||||||
|
if (!p.name && !p.company_name) return;
|
||||||
|
|
||||||
|
hasFetchedRef.current = true;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await ensureInitialized();
|
||||||
|
if (!ready) {
|
||||||
|
hasFetchedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/suggestions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ product: p }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
hasFetchedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ProductSuggestions = await response.json();
|
||||||
|
setCategories(data.categories ?? []);
|
||||||
|
setThemes(data.themes ?? []);
|
||||||
|
setColors(data.colors ?? []);
|
||||||
|
} catch {
|
||||||
|
hasFetchedRef.current = false;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { categories, themes, colors, isLoading, triggerFetch };
|
||||||
|
}
|
||||||
@@ -193,18 +193,6 @@ export const BASE_IMPORT_FIELDS = [
|
|||||||
fieldType: { type: "input" },
|
fieldType: { type: "input" },
|
||||||
width: 120,
|
width: 120,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Weight",
|
|
||||||
key: "weight",
|
|
||||||
description: "Product weight (in lbs)",
|
|
||||||
alternateMatches: ["weight (lbs.)"],
|
|
||||||
fieldType: { type: "input" },
|
|
||||||
width: 100,
|
|
||||||
validations: [
|
|
||||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
|
||||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Length",
|
label: "Length",
|
||||||
key: "length",
|
key: "length",
|
||||||
@@ -238,6 +226,18 @@ export const BASE_IMPORT_FIELDS = [
|
|||||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Weight",
|
||||||
|
key: "weight",
|
||||||
|
description: "Product weight (in lbs)",
|
||||||
|
alternateMatches: ["weight (lbs.)"],
|
||||||
|
fieldType: { type: "input" },
|
||||||
|
width: 100,
|
||||||
|
validations: [
|
||||||
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
|
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Shipping Restrictions",
|
label: "Shipping Restrictions",
|
||||||
key: "ship_restrictions",
|
key: "ship_restrictions",
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
* Used for inline validation suggestions on Name and Description fields.
|
* Used for inline validation suggestions on Name and Description fields.
|
||||||
*
|
*
|
||||||
* For description fields, starts collapsed (just icon + count) and expands on click.
|
* For description fields, starts collapsed (just icon + count) and expands on click.
|
||||||
* For name fields, uses compact inline mode.
|
* For name fields, uses compact inline mode with an editable suggestion.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info } from 'lucide-react';
|
import { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info, RefreshCw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -24,10 +24,14 @@ interface AiSuggestionBadgeProps {
|
|||||||
suggestion: string;
|
suggestion: string;
|
||||||
/** List of issues found (optional) */
|
/** List of issues found (optional) */
|
||||||
issues?: string[];
|
issues?: string[];
|
||||||
/** Called when user accepts the suggestion */
|
/** Called when user accepts the suggestion (receives the possibly-edited value) */
|
||||||
onAccept: () => void;
|
onAccept: (editedValue: string) => void;
|
||||||
/** Called when user dismisses the suggestion */
|
/** Called when user dismisses the suggestion */
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
|
/** Called to refresh (re-run) the AI validation */
|
||||||
|
onRevalidate?: () => void;
|
||||||
|
/** Whether re-validation is in progress */
|
||||||
|
isRevalidating?: boolean;
|
||||||
/** Additional CSS classes */
|
/** Additional CSS classes */
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Whether to show the suggestion as compact (inline) - used for name field */
|
/** Whether to show the suggestion as compact (inline) - used for name field */
|
||||||
@@ -41,13 +45,21 @@ export function AiSuggestionBadge({
|
|||||||
issues = [],
|
issues = [],
|
||||||
onAccept,
|
onAccept,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
onRevalidate,
|
||||||
|
isRevalidating = false,
|
||||||
className,
|
className,
|
||||||
compact = false,
|
compact = false,
|
||||||
collapsible = false
|
collapsible = false
|
||||||
}: AiSuggestionBadgeProps) {
|
}: AiSuggestionBadgeProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [editedValue, setEditedValue] = useState(suggestion);
|
||||||
|
|
||||||
// Compact mode for name fields - inline suggestion with accept/dismiss
|
// Reset edited value when suggestion changes (e.g. after refresh)
|
||||||
|
useEffect(() => {
|
||||||
|
setEditedValue(suggestion);
|
||||||
|
}, [suggestion]);
|
||||||
|
|
||||||
|
// Compact mode for name fields - inline editable suggestion with accept/dismiss
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -58,24 +70,27 @@ export function AiSuggestionBadge({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-1.5">
|
<div className="flex items-start gap-1.5 flex-1 min-w-0">
|
||||||
<Sparkles className="h-3 w-3 text-purple-500 flex-shrink-0 mt-0.5" />
|
<Sparkles className="h-3 w-3 text-purple-500 flex-shrink-0 mt-0.5" />
|
||||||
|
|
||||||
<span className="text-purple-700 dark:text-purple-300">
|
<input
|
||||||
{suggestion}
|
type="text"
|
||||||
</span>
|
value={editedValue}
|
||||||
|
onChange={(e) => setEditedValue(e.target.value)}
|
||||||
|
className="flex-1 min-w-0 bg-transparent text-purple-700 dark:text-purple-300 text-xs outline-none border-b border-transparent focus:border-purple-300 dark:focus:border-purple-600 transition-colors"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
<div className="flex items-center gap-[0px] flex-shrink-0">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={200}>
|
<Tooltip delayDuration={200}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-100"
|
className="h-4 w-4 p-0 [&_svg]:size-3.5 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onAccept();
|
onAccept(editedValue);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
@@ -92,7 +107,7 @@ export function AiSuggestionBadge({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-5 w-5 p-0 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
className="h-4 w-4 p-0 [&_svg]:size-3.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDismiss();
|
onDismiss();
|
||||||
@@ -106,19 +121,46 @@ export function AiSuggestionBadge({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
{/* Refresh button */}
|
||||||
|
{onRevalidate && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-4 w-4 p-0 mr-[1px] [&_svg]:size-3 text-purple-400 hover:text-purple-600 hover:bg-purple-100"
|
||||||
|
disabled={isRevalidating}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRevalidate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn(isRevalidating && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
<p>Refresh suggestion</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
{/* Info icon with issues tooltip */}
|
{/* Info icon with issues tooltip */}
|
||||||
{issues.length > 0 && (
|
{issues.length > 0 && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={200}>
|
<Tooltip delayDuration={200}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
className="flex-shrink-0 text-purple-400 hover:text-purple-600 transition-colors"
|
size="sm"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 p-0 [&_svg]:size-3.5 text-purple-400 hover:text-purple-600 hover:bg-purple-100"
|
||||||
>
|
>
|
||||||
<Info className="h-3.5 w-3.5" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
</button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="top"
|
side="top"
|
||||||
align="start"
|
align="start"
|
||||||
@@ -246,7 +288,7 @@ export function AiSuggestionBadge({
|
|||||||
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onAccept();
|
onAccept(suggestion);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check className="h-3 w-3 mr-1" />
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
|||||||
@@ -588,9 +588,9 @@ const CellWrapper = memo(({
|
|||||||
// Check if description should be validated
|
// Check if description should be validated
|
||||||
const descIsDismissed = nameSuggestion?.dismissed?.description;
|
const descIsDismissed = nameSuggestion?.dismissed?.description;
|
||||||
const descIsValidating = inlineAi.validating.has(`${contextProductIndex}-description`);
|
const descIsValidating = inlineAi.validating.has(`${contextProductIndex}-description`);
|
||||||
const descValue = currentRowForContext.description && String(currentRowForContext.description).trim();
|
const descValue = currentRowForContext.description ? String(currentRowForContext.description).trim() : '';
|
||||||
|
|
||||||
if (descValue && !descIsDismissed && !descIsValidating) {
|
if (descValue.length >= 10 && !descIsDismissed && !descIsValidating) {
|
||||||
// Trigger description validation
|
// Trigger description validation
|
||||||
setInlineAiValidating(`${contextProductIndex}-description`, true);
|
setInlineAiValidating(`${contextProductIndex}-description`, true);
|
||||||
|
|
||||||
@@ -687,7 +687,9 @@ const CellWrapper = memo(({
|
|||||||
// Trigger inline AI validation for name/description fields
|
// Trigger inline AI validation for name/description fields
|
||||||
// This validates spelling, grammar, and naming conventions using Groq
|
// This validates spelling, grammar, and naming conventions using Groq
|
||||||
// Only trigger if value actually changed to avoid unnecessary API calls
|
// Only trigger if value actually changed to avoid unnecessary API calls
|
||||||
if (isInlineAiField && valueChanged && valueToSave && String(valueToSave).trim()) {
|
const trimmedValue = valueToSave ? String(valueToSave).trim() : '';
|
||||||
|
const meetsMinLength = field.key === 'description' ? trimmedValue.length >= 10 : trimmedValue.length > 0;
|
||||||
|
if (isInlineAiField && valueChanged && meetsMinLength) {
|
||||||
const currentRow = useValidationStore.getState().rows[rowIndex];
|
const currentRow = useValidationStore.getState().rows[rowIndex];
|
||||||
const fields = useValidationStore.getState().fields;
|
const fields = useValidationStore.getState().fields;
|
||||||
if (currentRow) {
|
if (currentRow) {
|
||||||
@@ -751,6 +753,66 @@ const CellWrapper = memo(({
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, [rowIndex, field.key, isEmbeddingField, aiSuggestions, isInlineAiField, productIndex]);
|
}, [rowIndex, field.key, isEmbeddingField, aiSuggestions, isInlineAiField, productIndex]);
|
||||||
|
|
||||||
|
// Manual re-validate: triggers inline AI validation regardless of value changes
|
||||||
|
const handleRevalidate = useCallback(() => {
|
||||||
|
if (!isInlineAiField) return;
|
||||||
|
const state = useValidationStore.getState();
|
||||||
|
const currentRow = state.rows[rowIndex];
|
||||||
|
if (!currentRow) return;
|
||||||
|
|
||||||
|
const fieldKey = field.key as 'name' | 'description';
|
||||||
|
const currentValue = String(currentRow[fieldKey] ?? '').trim();
|
||||||
|
|
||||||
|
// Name requires non-empty, description requires ≥10 chars
|
||||||
|
if (fieldKey === 'name' && !currentValue) return;
|
||||||
|
if (fieldKey === 'description' && currentValue.length < 10) return;
|
||||||
|
|
||||||
|
const validationKey = `${productIndex}-${fieldKey}`;
|
||||||
|
if (state.inlineAi.validating.has(validationKey)) return;
|
||||||
|
|
||||||
|
const { setInlineAiValidating, setInlineAiSuggestion, markInlineAiAutoValidated, fields: storeFields, rows } = state;
|
||||||
|
setInlineAiValidating(validationKey, true);
|
||||||
|
markInlineAiAutoValidated(productIndex, fieldKey);
|
||||||
|
|
||||||
|
// Clear dismissed state so new result shows
|
||||||
|
const suggestions = state.inlineAi.suggestions.get(productIndex);
|
||||||
|
if (suggestions?.dismissed?.[fieldKey]) {
|
||||||
|
// Reset dismissed by re-setting suggestion (will be overwritten by API result)
|
||||||
|
setInlineAiSuggestion(productIndex, fieldKey, {
|
||||||
|
isValid: true,
|
||||||
|
suggestion: undefined,
|
||||||
|
issues: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = fieldKey === 'name'
|
||||||
|
? buildNameValidationPayload(currentRow, storeFields, rows)
|
||||||
|
: buildDescriptionValidationPayload(currentRow, storeFields);
|
||||||
|
|
||||||
|
const endpoint = fieldKey === 'name'
|
||||||
|
? '/api/ai/validate/inline/name'
|
||||||
|
: '/api/ai/validate/inline/description';
|
||||||
|
|
||||||
|
fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ product: payload }),
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success !== false) {
|
||||||
|
setInlineAiSuggestion(productIndex, fieldKey, {
|
||||||
|
isValid: result.isValid ?? true,
|
||||||
|
suggestion: result.suggestion,
|
||||||
|
issues: result.issues || [],
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error(`[InlineAI] manual ${fieldKey} revalidation error:`, err))
|
||||||
|
.finally(() => setInlineAiValidating(validationKey, false));
|
||||||
|
}, [rowIndex, field.key, isInlineAiField, productIndex]);
|
||||||
|
|
||||||
// Stable callback for fetching options (for line/subline dropdowns)
|
// Stable callback for fetching options (for line/subline dropdowns)
|
||||||
const handleFetchOptions = useCallback(async () => {
|
const handleFetchOptions = useCallback(async () => {
|
||||||
const state = useValidationStore.getState();
|
const state = useValidationStore.getState();
|
||||||
@@ -854,6 +916,7 @@ const CellWrapper = memo(({
|
|||||||
onDismissAiSuggestion: () => {
|
onDismissAiSuggestion: () => {
|
||||||
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
|
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
|
||||||
},
|
},
|
||||||
|
onRevalidate: handleRevalidate,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -925,12 +988,18 @@ const CellWrapper = memo(({
|
|||||||
<AiSuggestionBadge
|
<AiSuggestionBadge
|
||||||
suggestion={fieldSuggestion.suggestion!}
|
suggestion={fieldSuggestion.suggestion!}
|
||||||
issues={fieldSuggestion.issues}
|
issues={fieldSuggestion.issues}
|
||||||
onAccept={() => {
|
onAccept={(editedValue) => {
|
||||||
useValidationStore.getState().acceptInlineAiSuggestion(productIndex, 'name');
|
const state = useValidationStore.getState();
|
||||||
|
// Update the cell with the (possibly edited) value
|
||||||
|
state.updateCell(rowIndex, 'name', editedValue);
|
||||||
|
// Dismiss the suggestion
|
||||||
|
state.dismissInlineAiSuggestion(productIndex, 'name');
|
||||||
}}
|
}}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name');
|
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name');
|
||||||
}}
|
}}
|
||||||
|
onRevalidate={handleRevalidate}
|
||||||
|
isRevalidating={isInlineAiValidating}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { X, Loader2, Sparkles, AlertCircle, Check } from 'lucide-react';
|
import { X, Loader2, Sparkles } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { AiDescriptionCompare } from '@/components/ai/AiDescriptionCompare';
|
||||||
import type { Field, SelectOption } from '../../../../types';
|
import type { Field, SelectOption } from '../../../../types';
|
||||||
import type { ValidationError } from '../../store/types';
|
import type { ValidationError } from '../../store/types';
|
||||||
import { useValidationStore } from '../../store/validationStore';
|
import { useValidationStore } from '../../store/validationStore';
|
||||||
@@ -50,6 +51,8 @@ interface MultilineInputProps {
|
|||||||
isAiValidating?: boolean;
|
isAiValidating?: boolean;
|
||||||
/** Called when user dismisses/clears the AI suggestion (also called after applying) */
|
/** Called when user dismisses/clears the AI suggestion (also called after applying) */
|
||||||
onDismissAiSuggestion?: () => void;
|
onDismissAiSuggestion?: () => void;
|
||||||
|
/** Called to manually trigger AI re-validation */
|
||||||
|
onRevalidate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MultilineInputComponent = ({
|
const MultilineInputComponent = ({
|
||||||
@@ -63,12 +66,11 @@ const MultilineInputComponent = ({
|
|||||||
aiSuggestion,
|
aiSuggestion,
|
||||||
isAiValidating,
|
isAiValidating,
|
||||||
onDismissAiSuggestion,
|
onDismissAiSuggestion,
|
||||||
|
onRevalidate,
|
||||||
}: MultilineInputProps) => {
|
}: MultilineInputProps) => {
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
||||||
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
|
|
||||||
const [editedSuggestion, setEditedSuggestion] = useState('');
|
|
||||||
const [popoverWidth, setPopoverWidth] = useState(400);
|
const [popoverWidth, setPopoverWidth] = useState(400);
|
||||||
const [popoverHeight, setPopoverHeight] = useState<number | undefined>(undefined);
|
const [popoverHeight, setPopoverHeight] = useState<number | undefined>(undefined);
|
||||||
const resizeContainerRef = useRef<HTMLDivElement>(null);
|
const resizeContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -77,12 +79,8 @@ const MultilineInputComponent = ({
|
|||||||
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
|
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
|
||||||
const intentionalCloseRef = useRef(false);
|
const intentionalCloseRef = useRef(false);
|
||||||
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
// Tracks the value when popover opened, to detect actual changes
|
// Tracks the value when popover opened, to detect actual changes
|
||||||
const initialEditValueRef = useRef('');
|
const initialEditValueRef = useRef('');
|
||||||
// Ref for the right-side header+issues area to measure its height for left-side spacer
|
|
||||||
const aiHeaderRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [aiHeaderHeight, setAiHeaderHeight] = useState(0);
|
|
||||||
|
|
||||||
// Get the product name for this row from the store
|
// Get the product name for this row from the store
|
||||||
const productName = useValidationStore(
|
const productName = useValidationStore(
|
||||||
@@ -121,13 +119,6 @@ const MultilineInputComponent = ({
|
|||||||
}
|
}
|
||||||
}, [value, localDisplayValue]);
|
}, [value, localDisplayValue]);
|
||||||
|
|
||||||
// Initialize edited suggestion when AI suggestion changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (aiSuggestion?.suggestion) {
|
|
||||||
setEditedSuggestion(aiSuggestion.suggestion);
|
|
||||||
}
|
|
||||||
}, [aiSuggestion?.suggestion]);
|
|
||||||
|
|
||||||
// Auto-resize a textarea to fit its content
|
// Auto-resize a textarea to fit its content
|
||||||
const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => {
|
const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => {
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
@@ -145,61 +136,25 @@ const MultilineInputComponent = ({
|
|||||||
}
|
}
|
||||||
}, [popoverOpen, editValue, autoResizeTextarea]);
|
}, [popoverOpen, editValue, autoResizeTextarea]);
|
||||||
|
|
||||||
// Auto-resize suggestion textarea when expanded/visible or value changes
|
// Set initial popover height to fit the textarea content, capped by window height.
|
||||||
|
// Only applies on desktop (lg breakpoint) and non-AI mode (AI mode uses AiDescriptionCompare's own sizing).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (aiSuggestionExpanded || (popoverOpen && hasAiSuggestion)) {
|
if (!popoverOpen || hasAiSuggestion) { setPopoverHeight(undefined); return; }
|
||||||
requestAnimationFrame(() => {
|
|
||||||
autoResizeTextarea(suggestionTextareaRef.current);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [aiSuggestionExpanded, popoverOpen, hasAiSuggestion, editedSuggestion, autoResizeTextarea]);
|
|
||||||
|
|
||||||
// Set initial popover height to fit the tallest textarea content, capped by window height.
|
|
||||||
// Only applies on desktop (lg breakpoint) — mobile uses natural flow with individually resizable textareas.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!popoverOpen) { setPopoverHeight(undefined); return; }
|
|
||||||
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
|
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
|
||||||
if (!isDesktop) { setPopoverHeight(undefined); return; }
|
if (!isDesktop) { setPopoverHeight(undefined); return; }
|
||||||
const rafId = requestAnimationFrame(() => {
|
const rafId = requestAnimationFrame(() => {
|
||||||
const main = mainTextareaRef.current;
|
const main = mainTextareaRef.current;
|
||||||
const suggestion = suggestionTextareaRef.current;
|
|
||||||
const container = resizeContainerRef.current;
|
const container = resizeContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Get textarea natural content heights
|
|
||||||
const mainScrollH = main ? main.scrollHeight : 0;
|
const mainScrollH = main ? main.scrollHeight : 0;
|
||||||
const suggestionScrollH = suggestion ? suggestion.scrollHeight : 0;
|
|
||||||
const tallestTextarea = Math.max(mainScrollH, suggestionScrollH);
|
|
||||||
|
|
||||||
// Measure chrome for both columns (everything except the textarea)
|
|
||||||
const leftChrome = main ? (main.closest('[data-col="left"]')?.scrollHeight ?? 0) - main.offsetHeight : 0;
|
const leftChrome = main ? (main.closest('[data-col="left"]')?.scrollHeight ?? 0) - main.offsetHeight : 0;
|
||||||
const rightChrome = suggestion ? (suggestion.closest('[data-col="right"]')?.scrollHeight ?? 0) - suggestion.offsetHeight : 0;
|
|
||||||
const chrome = Math.max(leftChrome, rightChrome);
|
|
||||||
|
|
||||||
const naturalHeight = chrome + tallestTextarea;
|
const naturalHeight = leftChrome + mainScrollH;
|
||||||
const maxHeight = Math.floor(window.innerHeight * 0.7);
|
const maxHeight = Math.floor(window.innerHeight * 0.7);
|
||||||
setPopoverHeight(Math.max(Math.min(naturalHeight, maxHeight), 200));
|
setPopoverHeight(Math.max(Math.min(naturalHeight, maxHeight), 200));
|
||||||
});
|
});
|
||||||
return () => cancelAnimationFrame(rafId);
|
return () => cancelAnimationFrame(rafId);
|
||||||
}, [popoverOpen]);
|
|
||||||
|
|
||||||
// Measure the right-side header+issues area so the left spacer matches.
|
|
||||||
// Uses rAF because Radix portals mount asynchronously, so the ref is null on the first synchronous run.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!popoverOpen || !hasAiSuggestion) { setAiHeaderHeight(0); return; }
|
|
||||||
let observer: ResizeObserver | null = null;
|
|
||||||
const rafId = requestAnimationFrame(() => {
|
|
||||||
const el = aiHeaderRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
observer = new ResizeObserver(([entry]) => {
|
|
||||||
setAiHeaderHeight(entry.contentRect.height-7);
|
|
||||||
});
|
|
||||||
observer.observe(el);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
observer?.disconnect();
|
|
||||||
};
|
|
||||||
}, [popoverOpen, hasAiSuggestion]);
|
}, [popoverOpen, hasAiSuggestion]);
|
||||||
|
|
||||||
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
|
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
|
||||||
@@ -261,7 +216,6 @@ const MultilineInputComponent = ({
|
|||||||
|
|
||||||
// Immediately close popover
|
// Immediately close popover
|
||||||
setPopoverOpen(false);
|
setPopoverOpen(false);
|
||||||
setAiSuggestionExpanded(false);
|
|
||||||
|
|
||||||
// Prevent reopening this same cell
|
// Prevent reopening this same cell
|
||||||
preventReopenRef.current = true;
|
preventReopenRef.current = true;
|
||||||
@@ -291,7 +245,6 @@ const MultilineInputComponent = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPopoverOpen(false);
|
setPopoverOpen(false);
|
||||||
setAiSuggestionExpanded(false);
|
|
||||||
|
|
||||||
// Signal to other cells that a popover just closed via click-outside
|
// Signal to other cells that a popover just closed via click-outside
|
||||||
setCellPopoverClosed();
|
setCellPopoverClosed();
|
||||||
@@ -322,23 +275,19 @@ const MultilineInputComponent = ({
|
|||||||
autoResizeTextarea(e.target);
|
autoResizeTextarea(e.target);
|
||||||
}, [autoResizeTextarea]);
|
}, [autoResizeTextarea]);
|
||||||
|
|
||||||
// Handle accepting the AI suggestion (possibly edited)
|
// Handle accepting the AI suggestion (possibly edited) via AiDescriptionCompare
|
||||||
const handleAcceptSuggestion = useCallback(() => {
|
const handleAcceptSuggestion = useCallback((text: string) => {
|
||||||
// Use the edited suggestion
|
setEditValue(text);
|
||||||
setEditValue(editedSuggestion);
|
setLocalDisplayValue(text);
|
||||||
setLocalDisplayValue(editedSuggestion);
|
onBlur(text);
|
||||||
// onBlur handles both cell update and validation
|
onDismissAiSuggestion?.();
|
||||||
onBlur(editedSuggestion);
|
|
||||||
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
|
|
||||||
setAiSuggestionExpanded(false);
|
|
||||||
intentionalCloseRef.current = true;
|
intentionalCloseRef.current = true;
|
||||||
setPopoverOpen(false);
|
setPopoverOpen(false);
|
||||||
}, [editedSuggestion, onBlur, onDismissAiSuggestion]);
|
}, [onBlur, onDismissAiSuggestion]);
|
||||||
|
|
||||||
// Handle dismissing the AI suggestion
|
// Handle dismissing the AI suggestion via AiDescriptionCompare
|
||||||
const handleDismissSuggestion = useCallback(() => {
|
const handleDismissSuggestion = useCallback(() => {
|
||||||
onDismissAiSuggestion?.();
|
onDismissAiSuggestion?.();
|
||||||
setAiSuggestionExpanded(false);
|
|
||||||
intentionalCloseRef.current = true;
|
intentionalCloseRef.current = true;
|
||||||
setPopoverOpen(false);
|
setPopoverOpen(false);
|
||||||
}, [onDismissAiSuggestion]);
|
}, [onDismissAiSuggestion]);
|
||||||
@@ -380,7 +329,6 @@ const MultilineInputComponent = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updatePopoverWidth();
|
updatePopoverWidth();
|
||||||
setAiSuggestionExpanded(true);
|
|
||||||
setPopoverOpen(true);
|
setPopoverOpen(true);
|
||||||
// Initialize edit value and track it for change detection
|
// Initialize edit value and track it for change detection
|
||||||
const initValue = localDisplayValue || String(value ?? '');
|
const initValue = localDisplayValue || String(value ?? '');
|
||||||
@@ -436,7 +384,12 @@ const MultilineInputComponent = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={resizeContainerRef}
|
ref={resizeContainerRef}
|
||||||
className="flex flex-col lg:flex-row items-stretch lg:resize-y lg:overflow-auto lg:min-h-[120px] max-h-[85vh] overflow-y-auto lg:max-h-none"
|
className={cn(
|
||||||
|
"flex flex-col lg:flex-row items-stretch max-h-[85vh]",
|
||||||
|
hasAiSuggestion
|
||||||
|
? "overflow-y-auto lg:overflow-hidden"
|
||||||
|
: "lg:resize-y lg:overflow-auto lg:min-h-[120px] overflow-y-auto lg:max-h-none"
|
||||||
|
)}
|
||||||
style={popoverHeight ? { height: popoverHeight } : undefined}
|
style={popoverHeight ? { height: popoverHeight } : undefined}
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
@@ -449,116 +402,29 @@ const MultilineInputComponent = ({
|
|||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Main textarea */}
|
{hasAiSuggestion ? (
|
||||||
<div data-col="left" className={cn("flex flex-col min-h-0 w-full", hasAiSuggestion && "lg:w-1/2")}>
|
<AiDescriptionCompare
|
||||||
<div className={cn(hasAiSuggestion ? 'px-3 py-2 bg-accent' : '', 'flex flex-col flex-1 min-h-0')}>
|
currentValue={editValue}
|
||||||
{/* Product name - shown inline on mobile, in measured spacer on desktop */}
|
onCurrentChange={setEditValue}
|
||||||
{hasAiSuggestion && productName && (
|
suggestion={aiSuggestion.suggestion!}
|
||||||
<div className="flex-shrink-0 flex flex-col lg:hidden px-1 mb-2">
|
issues={aiIssues}
|
||||||
<div className="text-sm font-medium text-foreground mb-1">Editing description for:</div>
|
productName={productName}
|
||||||
<div className="text-md font-semibold text-foreground line-clamp-1">{productName}</div>
|
onAccept={handleAcceptSuggestion}
|
||||||
</div>
|
onDismiss={handleDismissSuggestion}
|
||||||
)}
|
onRevalidate={onRevalidate}
|
||||||
{hasAiSuggestion && aiHeaderHeight > 0 && (
|
isRevalidating={isAiValidating}
|
||||||
<div className="flex-shrink-0 hidden lg:flex items-start" style={{ height: aiHeaderHeight }}>
|
/>
|
||||||
{productName && (
|
) : (
|
||||||
<div className="flex flex-col">
|
<div data-col="left" className="flex flex-col min-h-0 w-full">
|
||||||
<div className="text-sm font-medium text-foreground px-1 mb-1">Editing description for:</div>
|
<Textarea
|
||||||
<div className="text-md font-semibold text-foreground line-clamp-1 px-1">{productName}</div>
|
ref={mainTextareaRef}
|
||||||
</div>
|
value={editValue}
|
||||||
)}
|
onChange={handleChange}
|
||||||
</div>
|
onWheel={handleTextareaWheel}
|
||||||
)}
|
className="overflow-y-auto overscroll-contain text-sm lg:flex-1 resize-y lg:resize-none bg-white min-h-[120px] lg:min-h-0"
|
||||||
{hasAiSuggestion && <div className="text-sm mb-1 font-medium flex items-center gap-2 flex-shrink-0">
|
placeholder={`Enter ${field.label || 'text'}...`}
|
||||||
Current Description:
|
autoFocus
|
||||||
</div>}
|
/>
|
||||||
{/* Dynamic spacer matching the right-side header+issues height */}
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
ref={mainTextareaRef}
|
|
||||||
value={editValue}
|
|
||||||
onChange={handleChange}
|
|
||||||
onWheel={handleTextareaWheel}
|
|
||||||
className={cn("overflow-y-auto overscroll-contain text-sm lg:flex-1 resize-y lg:resize-none bg-white min-h-[120px] lg:min-h-0")}
|
|
||||||
placeholder={`Enter ${field.label || 'text'}...`}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{hasAiSuggestion && <div className="h-[43px] flex-shrink-0 hidden lg:block" />}
|
|
||||||
</div></div>
|
|
||||||
{/* AI Suggestion section */}
|
|
||||||
{hasAiSuggestion && (
|
|
||||||
<div data-col="right" className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
|
|
||||||
{/* Measured header + issues area (mirrored as spacer on the left) */}
|
|
||||||
<div ref={aiHeaderRef} className="flex-shrink-0">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="w-full flex items-center justify-between px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
|
||||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
|
||||||
AI Suggestion
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-purple-500 dark:text-purple-400">
|
|
||||||
({aiIssues.length} {aiIssues.length === 1 ? 'issue' : 'issues'})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Issues list */}
|
|
||||||
{aiIssues.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-1 px-3 pb-3">
|
|
||||||
{aiIssues.map((issue, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
|
|
||||||
>
|
|
||||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
|
||||||
<span>{issue}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
|
|
||||||
{/* Editable suggestion */}
|
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
<div className="text-sm text-purple-500 dark:text-purple-400 mb-1 font-medium flex-shrink-0">
|
|
||||||
Suggested (editable):
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
ref={suggestionTextareaRef}
|
|
||||||
value={editedSuggestion}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEditedSuggestion(e.target.value);
|
|
||||||
autoResizeTextarea(e.target);
|
|
||||||
}}
|
|
||||||
onWheel={handleTextareaWheel}
|
|
||||||
className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y lg:resize-none lg:flex-1 min-h-[120px] lg:min-h-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
|
||||||
onClick={handleAcceptSuggestion}
|
|
||||||
>
|
|
||||||
<Check className="h-3 w-3 mr-1" />
|
|
||||||
Replace With Suggestion
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
|
||||||
onClick={handleDismissSuggestion}
|
|
||||||
>
|
|
||||||
Ignore
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ export function useAutoInlineAiValidation() {
|
|||||||
typeof row.name === 'string' &&
|
typeof row.name === 'string' &&
|
||||||
row.name.trim();
|
row.name.trim();
|
||||||
|
|
||||||
// Check description context: company + line + name (description can be empty)
|
// Check description context: company + line + name + description with ≥10 chars
|
||||||
// We want to validate descriptions even when empty so AI can suggest one
|
const descriptionValue = typeof row.description === 'string' ? row.description.trim() : '';
|
||||||
const hasDescContext = hasNameContext;
|
const hasDescContext = hasNameContext && descriptionValue.length >= 10;
|
||||||
|
|
||||||
// Skip if already auto-validated (shouldn't happen on first run, but be safe)
|
// Skip if already auto-validated (shouldn't happen on first run, but be safe)
|
||||||
const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`);
|
const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
InlineAiValidationResult,
|
InlineAiValidationResult,
|
||||||
} from './types';
|
} from './types';
|
||||||
import type { Field, SelectOption } from '../../../types';
|
import type { Field, SelectOption } from '../../../types';
|
||||||
|
import { stripPriceFormatting } from '../utils/priceUtils';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Initial State
|
// Initial State
|
||||||
@@ -165,11 +166,24 @@ export const useValidationStore = create<ValidationStore>()(
|
|||||||
// Apply fresh state first (clean slate)
|
// Apply fresh state first (clean slate)
|
||||||
Object.assign(state, freshState);
|
Object.assign(state, freshState);
|
||||||
|
|
||||||
// Then set up with new data
|
// Identify price fields to clean on ingestion (strips $, commas, whitespace)
|
||||||
state.rows = data.map((row) => ({
|
const priceFieldKeys = fields
|
||||||
...row,
|
.filter((f) => f.fieldType.type === 'input' && 'price' in f.fieldType && f.fieldType.price)
|
||||||
__index: row.__index || uuidv4(),
|
.map((f) => f.key);
|
||||||
}));
|
|
||||||
|
// Then set up with new data, cleaning price fields
|
||||||
|
state.rows = data.map((row) => {
|
||||||
|
const cleanedRow: RowData = {
|
||||||
|
...row,
|
||||||
|
__index: row.__index || uuidv4(),
|
||||||
|
};
|
||||||
|
for (const key of priceFieldKeys) {
|
||||||
|
if (typeof cleanedRow[key] === 'string' && cleanedRow[key] !== '') {
|
||||||
|
cleanedRow[key] = stripPriceFormatting(cleanedRow[key] as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleanedRow;
|
||||||
|
});
|
||||||
state.originalRows = JSON.parse(JSON.stringify(state.rows));
|
state.originalRows = JSON.parse(JSON.stringify(state.rows));
|
||||||
// Cast to bypass immer's strict readonly type checking
|
// Cast to bypass immer's strict readonly type checking
|
||||||
state.fields = fields as unknown as typeof state.fields;
|
state.fields = fields as unknown as typeof state.fields;
|
||||||
|
|||||||
@@ -2,10 +2,84 @@
|
|||||||
* Price field cleaning and formatting utilities
|
* Price field cleaning and formatting utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a numeric string that may use US or European formatting conventions.
|
||||||
|
*
|
||||||
|
* Handles the ambiguity between comma-as-thousands (US: "1,234.56") and
|
||||||
|
* comma-as-decimal (European: "1.234,56" or "1,50") using these heuristics:
|
||||||
|
*
|
||||||
|
* 1. Both comma AND period present → last one is the decimal separator
|
||||||
|
* - "1,234.56" → period last → US → "1234.56"
|
||||||
|
* - "1.234,56" → comma last → EU → "1234.56"
|
||||||
|
*
|
||||||
|
* 2. Only comma, no period → check digit count after last comma:
|
||||||
|
* - 1-2 digits → decimal comma: "1,50" → "1.50"
|
||||||
|
* - 3 digits → thousands: "1,500" → "1500"
|
||||||
|
*
|
||||||
|
* 3. Only period or neither → return as-is
|
||||||
|
*/
|
||||||
|
function normalizeNumericSeparators(value: string): string {
|
||||||
|
if (value.includes(".") && value.includes(",")) {
|
||||||
|
const lastComma = value.lastIndexOf(",");
|
||||||
|
const lastPeriod = value.lastIndexOf(".");
|
||||||
|
if (lastPeriod > lastComma) {
|
||||||
|
// US: "1,234.56" → remove commas
|
||||||
|
return value.replace(/,/g, "");
|
||||||
|
} else {
|
||||||
|
// European: "1.234,56" → remove periods, comma→period
|
||||||
|
return value.replace(/\./g, "").replace(",", ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes(",")) {
|
||||||
|
const match = value.match(/,(\d+)$/);
|
||||||
|
if (match && match[1].length <= 2) {
|
||||||
|
// Decimal comma: "1,50" → "1.50", "1,5" → "1.5"
|
||||||
|
return value.replace(",", ".");
|
||||||
|
}
|
||||||
|
// Thousands comma(s): "1,500" or "1,000,000" → remove all
|
||||||
|
return value.replace(/,/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips currency formatting from a price string without rounding.
|
||||||
|
*
|
||||||
|
* Removes currency symbols and whitespace, normalizes European decimal commas,
|
||||||
|
* and returns the raw numeric string. Full precision is preserved.
|
||||||
|
*
|
||||||
|
* @returns Stripped numeric string, or original value if not a valid number
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* stripPriceFormatting(" $ 1.50") // "1.50"
|
||||||
|
* stripPriceFormatting("$1,234.56") // "1234.56"
|
||||||
|
* stripPriceFormatting("1.234,56") // "1234.56"
|
||||||
|
* stripPriceFormatting("1,50") // "1.50"
|
||||||
|
* stripPriceFormatting("3.625") // "3.625"
|
||||||
|
* stripPriceFormatting("invalid") // "invalid"
|
||||||
|
*/
|
||||||
|
export function stripPriceFormatting(value: string): string {
|
||||||
|
// Step 1: Strip whitespace and currency symbols (keep commas/periods for separator detection)
|
||||||
|
let cleaned = value.replace(/[\s$€£¥]/g, "");
|
||||||
|
|
||||||
|
// Step 2: Normalize decimal/thousands separators
|
||||||
|
cleaned = normalizeNumericSeparators(cleaned);
|
||||||
|
|
||||||
|
// Verify it's actually a number after normalization
|
||||||
|
const numValue = parseFloat(cleaned);
|
||||||
|
if (!isNaN(numValue) && cleaned !== "") {
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleans a price field by removing currency symbols and formatting to 2 decimal places
|
* Cleans a price field by removing currency symbols and formatting to 2 decimal places
|
||||||
*
|
*
|
||||||
* - Removes dollar signs ($) and commas (,)
|
* - Removes currency symbols and whitespace
|
||||||
|
* - Normalizes European decimal commas
|
||||||
* - Converts to number and formats with 2 decimal places
|
* - Converts to number and formats with 2 decimal places
|
||||||
* - Returns original value if conversion fails
|
* - Returns original value if conversion fails
|
||||||
*
|
*
|
||||||
@@ -14,13 +88,14 @@
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* cleanPriceField("$1,234.56") // "1234.56"
|
* cleanPriceField("$1,234.56") // "1234.56"
|
||||||
* cleanPriceField("$99.9") // "99.90"
|
* cleanPriceField(" $ 99.9") // "99.90"
|
||||||
* cleanPriceField(123.456) // "123.46"
|
* cleanPriceField("1,50") // "1.50"
|
||||||
* cleanPriceField("invalid") // "invalid"
|
* cleanPriceField(123.456) // "123.46"
|
||||||
|
* cleanPriceField("invalid") // "invalid"
|
||||||
*/
|
*/
|
||||||
export function cleanPriceField(value: string | number): string {
|
export function cleanPriceField(value: string | number): string {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const cleaned = value.replace(/[$,]/g, "");
|
const cleaned = stripPriceFormatting(value);
|
||||||
const numValue = parseFloat(cleaned);
|
const numValue = parseFloat(cleaned);
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue)) {
|
||||||
return numValue.toFixed(2);
|
return numValue.toFixed(2);
|
||||||
@@ -59,4 +134,4 @@ export function cleanPriceFields<T extends Record<string, any>>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
export function cleanPriceField(value: string | number): string {
|
export function cleanPriceField(value: string | number): string {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const cleaned = value.replace(/[$,]/g, "");
|
const cleaned = value.replace(/[\s$,]/g, "");
|
||||||
const numValue = parseFloat(cleaned);
|
const numValue = parseFloat(cleaned);
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue)) {
|
||||||
return numValue.toFixed(2);
|
return numValue.toFixed(2);
|
||||||
|
|||||||
908
inventory/src/pages/BulkEdit.tsx
Normal file
908
inventory/src/pages/BulkEdit.tsx
Normal file
@@ -0,0 +1,908 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2, Sparkles, Save } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationEllipsis,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import { ProductSearch } from "@/components/product-editor/ProductSearch";
|
||||||
|
import {
|
||||||
|
BulkEditRow,
|
||||||
|
FIELD_OPTIONS,
|
||||||
|
AI_FIELDS,
|
||||||
|
INITIAL_ROW_STATE,
|
||||||
|
getFieldValue,
|
||||||
|
getSubmitFieldKey,
|
||||||
|
type BulkEditFieldChoice,
|
||||||
|
type RowAiState,
|
||||||
|
} from "@/components/bulk-edit/BulkEditRow";
|
||||||
|
import { submitProductEdit } from "@/services/productEditor";
|
||||||
|
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types";
|
||||||
|
|
||||||
|
const PER_PAGE = 20;
|
||||||
|
const PROD_IMG_HOST = "https://sbing.com";
|
||||||
|
|
||||||
|
/** Strip all HTML tags for use in plain text contexts */
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BulkEdit() {
|
||||||
|
// Product loading state (mirrors ProductEditor)
|
||||||
|
const [allProducts, setAllProducts] = useState<SearchProduct[]>([]);
|
||||||
|
const [fieldOptions, setFieldOptions] = useState<FieldOptions | null>(null);
|
||||||
|
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
|
||||||
|
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
||||||
|
const [page, _setPage] = useState(1);
|
||||||
|
const topRef = useRef<HTMLDivElement>(null);
|
||||||
|
const setPage = useCallback((v: number | ((p: number) => number)) => {
|
||||||
|
_setPage(v);
|
||||||
|
setTimeout(() => topRef.current?.scrollIntoView({ behavior: "smooth" }), 0);
|
||||||
|
}, []);
|
||||||
|
const [activeTab, setActiveTab] = useState("new");
|
||||||
|
const [loadedTab, setLoadedTab] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Line picker state
|
||||||
|
const [lineCompany, setLineCompany] = useState<string>("");
|
||||||
|
const [lineLine, setLineLine] = useState<string>("");
|
||||||
|
const [lineSubline, setLineSubline] = useState<string>("");
|
||||||
|
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
|
||||||
|
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
|
||||||
|
const [isLoadingLines, setIsLoadingLines] = useState(false);
|
||||||
|
const [isLoadingSublines, setIsLoadingSublines] = useState(false);
|
||||||
|
|
||||||
|
// Landing extras state
|
||||||
|
const [landingExtras, setLandingExtras] = useState<Record<string, LandingExtra[]>>({});
|
||||||
|
const [isLoadingExtras, setIsLoadingExtras] = useState(false);
|
||||||
|
const [activeLandingItem, setActiveLandingItem] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Abort controller
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Bulk edit state
|
||||||
|
const [selectedField, setSelectedField] = useState<BulkEditFieldChoice>("description");
|
||||||
|
const [aiStates, setAiStates] = useState<Map<number, RowAiState>>(new Map());
|
||||||
|
const [productImages, setProductImages] = useState<Map<number, string | null>>(new Map());
|
||||||
|
|
||||||
|
// Validation progress
|
||||||
|
const [validationProgress, setValidationProgress] = useState<{
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Save progress
|
||||||
|
const [saveProgress, setSaveProgress] = useState<{
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const isAiField = AI_FIELDS.includes(selectedField);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(allProducts.length / PER_PAGE);
|
||||||
|
const pageProducts = useMemo(
|
||||||
|
() => allProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE),
|
||||||
|
[allProducts, page]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get select options for the current field
|
||||||
|
const currentFieldSelectOptions = useMemo((): FieldOption[] | undefined => {
|
||||||
|
if (!fieldOptions) return undefined;
|
||||||
|
switch (selectedField) {
|
||||||
|
case "tax_cat": return fieldOptions.taxCategories;
|
||||||
|
case "size_cat": return fieldOptions.sizes;
|
||||||
|
case "ship_restrictions": return fieldOptions.shippingRestrictions;
|
||||||
|
default: return undefined;
|
||||||
|
}
|
||||||
|
}, [fieldOptions, selectedField]);
|
||||||
|
|
||||||
|
// Load field options on mount (but don't auto-load products)
|
||||||
|
useEffect(() => {
|
||||||
|
axios
|
||||||
|
.get("/api/import/field-options")
|
||||||
|
.then((res) => setFieldOptions(res.data))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to load field options:", err);
|
||||||
|
toast.error("Failed to load field options");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoadingOptions(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load lines when company changes
|
||||||
|
useEffect(() => {
|
||||||
|
setLineLine("");
|
||||||
|
setLineSubline("");
|
||||||
|
setLineOptions([]);
|
||||||
|
setSublineOptions([]);
|
||||||
|
if (!lineCompany) return;
|
||||||
|
setIsLoadingLines(true);
|
||||||
|
axios
|
||||||
|
.get(`/api/import/product-lines/${lineCompany}`)
|
||||||
|
.then((res) => setLineOptions(res.data))
|
||||||
|
.catch(() => setLineOptions([]))
|
||||||
|
.finally(() => setIsLoadingLines(false));
|
||||||
|
}, [lineCompany]);
|
||||||
|
|
||||||
|
// Load sublines when line changes
|
||||||
|
useEffect(() => {
|
||||||
|
setLineSubline("");
|
||||||
|
setSublineOptions([]);
|
||||||
|
if (!lineLine) return;
|
||||||
|
setIsLoadingSublines(true);
|
||||||
|
axios
|
||||||
|
.get(`/api/import/sublines/${lineLine}`)
|
||||||
|
.then((res) => setSublineOptions(res.data))
|
||||||
|
.catch(() => setSublineOptions([]))
|
||||||
|
.finally(() => setIsLoadingSublines(false));
|
||||||
|
}, [lineLine]);
|
||||||
|
|
||||||
|
const loadedPids = useMemo(
|
||||||
|
() => new Set(allProducts.map((p) => Number(p.pid))),
|
||||||
|
[allProducts]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Product loading (same patterns as ProductEditor) ──
|
||||||
|
|
||||||
|
const handleSearchSelect = useCallback((product: SearchProduct) => {
|
||||||
|
setAllProducts((prev) => {
|
||||||
|
if (prev.some((p) => p.pid === product.pid)) return prev;
|
||||||
|
return [product, ...prev];
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNewSearch = useCallback(() => {
|
||||||
|
setAllProducts([]);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoadAllSearch = useCallback(async (pids: number[]) => {
|
||||||
|
const hadExisting = allProducts.length > 0;
|
||||||
|
setIsLoadingProducts(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get("/api/import/search-products", {
|
||||||
|
params: { pid: pids.join(",") },
|
||||||
|
});
|
||||||
|
const fetched = res.data as SearchProduct[];
|
||||||
|
setAllProducts((prev) => {
|
||||||
|
const existingPids = new Set(prev.map((p) => p.pid));
|
||||||
|
const newProducts = fetched.filter((p) => !existingPids.has(p.pid));
|
||||||
|
return [...prev, ...newProducts];
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
if (fetched.length > 1) {
|
||||||
|
toast.success(
|
||||||
|
hadExisting
|
||||||
|
? `Loaded remaining ${fetched.length} products`
|
||||||
|
: "Loaded all products"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load products");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProducts(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadFeedProducts = useCallback(async (endpoint: string, label: string) => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
setAllProducts([]);
|
||||||
|
setIsLoadingProducts(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/import/${endpoint}`, { signal: controller.signal });
|
||||||
|
setAllProducts(res.data);
|
||||||
|
setPage(1);
|
||||||
|
toast.success(`Loaded ${res.data.length} ${label} products`);
|
||||||
|
} catch (e) {
|
||||||
|
if (!axios.isCancel(e)) toast.error(`Failed to load ${label} products`);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProducts(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadLandingExtras = useCallback(async (catId: number, tabKey: string) => {
|
||||||
|
if (landingExtras[tabKey]) return;
|
||||||
|
setIsLoadingExtras(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get("/api/import/landing-extras", {
|
||||||
|
params: { catId, sid: 0 },
|
||||||
|
});
|
||||||
|
setLandingExtras((prev) => ({ ...prev, [tabKey]: res.data }));
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to load landing extras");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingExtras(false);
|
||||||
|
}
|
||||||
|
}, [landingExtras]);
|
||||||
|
|
||||||
|
const handleLandingClick = useCallback(async (extra: LandingExtra) => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
setActiveLandingItem(extra.path);
|
||||||
|
setAllProducts([]);
|
||||||
|
setIsLoadingProducts(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get("/api/import/path-products", {
|
||||||
|
params: { path: extra.path },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
setAllProducts(res.data);
|
||||||
|
setPage(1);
|
||||||
|
toast.success(`Loaded ${res.data.length} products for ${stripHtml(extra.name)}`);
|
||||||
|
} catch (e) {
|
||||||
|
if (!axios.isCancel(e)) toast.error("Failed to load products for " + stripHtml(extra.name));
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProducts(false);
|
||||||
|
setActiveLandingItem(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback((tab: string) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
if (tab === "new" && loadedTab !== "new") {
|
||||||
|
setLoadedTab("new");
|
||||||
|
loadFeedProducts("new-products", "new");
|
||||||
|
loadLandingExtras(-2, "new");
|
||||||
|
} else if (tab === "preorder" && loadedTab !== "preorder") {
|
||||||
|
setLoadedTab("preorder");
|
||||||
|
loadFeedProducts("preorder-products", "pre-order");
|
||||||
|
loadLandingExtras(-16, "preorder");
|
||||||
|
} else if (tab === "hidden" && loadedTab !== "hidden") {
|
||||||
|
setLoadedTab("hidden");
|
||||||
|
loadFeedProducts("hidden-new-products", "hidden");
|
||||||
|
} else if (tab === "search" || tab === "by-line") {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setAllProducts([]);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, [loadedTab, loadFeedProducts, loadLandingExtras]);
|
||||||
|
|
||||||
|
const loadLineProducts = useCallback(async () => {
|
||||||
|
if (!lineCompany || !lineLine) return;
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
setAllProducts([]);
|
||||||
|
setIsLoadingProducts(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = { company: lineCompany, line: lineLine };
|
||||||
|
if (lineSubline) params.subline = lineSubline;
|
||||||
|
const res = await axios.get("/api/import/line-products", { params, signal: controller.signal });
|
||||||
|
setAllProducts(res.data);
|
||||||
|
setPage(1);
|
||||||
|
toast.success(`Loaded ${res.data.length} products`);
|
||||||
|
} catch (e) {
|
||||||
|
if (!axios.isCancel(e)) toast.error("Failed to load line products");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProducts(false);
|
||||||
|
}
|
||||||
|
}, [lineCompany, lineLine, lineSubline]);
|
||||||
|
|
||||||
|
// ── Image loading ──
|
||||||
|
|
||||||
|
// Load first image for current page products
|
||||||
|
useEffect(() => {
|
||||||
|
const pidsNeedingImages = pageProducts
|
||||||
|
.filter((p) => !productImages.has(p.pid))
|
||||||
|
.map((p) => p.pid);
|
||||||
|
|
||||||
|
if (pidsNeedingImages.length === 0) return;
|
||||||
|
|
||||||
|
pidsNeedingImages.forEach((pid) => {
|
||||||
|
axios
|
||||||
|
.get(`/api/import/product-images/${pid}`)
|
||||||
|
.then((res) => {
|
||||||
|
const images = res.data;
|
||||||
|
let url: string | null = null;
|
||||||
|
if (Array.isArray(images) && images.length > 0) {
|
||||||
|
// Get smallest size for thumbnail
|
||||||
|
const first = images[0];
|
||||||
|
const sizes = first.sizes || {};
|
||||||
|
const smallKey = Object.keys(sizes).find((k) => k.includes("175") || k.includes("small"));
|
||||||
|
const anyKey = Object.keys(sizes)[0];
|
||||||
|
const chosen = sizes[smallKey ?? anyKey];
|
||||||
|
url = chosen?.url ?? null;
|
||||||
|
}
|
||||||
|
setProductImages((prev) => new Map(prev).set(pid, url));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setProductImages((prev) => new Map(prev).set(pid, null));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [pageProducts, productImages]);
|
||||||
|
|
||||||
|
// ── AI Validation ──
|
||||||
|
|
||||||
|
const triggerValidation = useCallback(
|
||||||
|
(products: SearchProduct[]) => {
|
||||||
|
if (!isAiField) return;
|
||||||
|
|
||||||
|
const total = products.length;
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
|
// Mark all as validating
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
products.forEach((p) => {
|
||||||
|
const existing = next.get(p.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(p.pid, { ...existing, status: "validating" });
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidationProgress({ done: 0, total });
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
selectedField === "name"
|
||||||
|
? "/api/ai/validate/inline/name"
|
||||||
|
: "/api/ai/validate/inline/description";
|
||||||
|
|
||||||
|
// Fire all requests at once
|
||||||
|
products.forEach(async (product) => {
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (selectedField === "name") {
|
||||||
|
payload.name = product.title;
|
||||||
|
payload.company_name = product.brand;
|
||||||
|
payload.company_id = product.brand_id;
|
||||||
|
payload.line_name = product.line;
|
||||||
|
payload.subline_name = product.subline;
|
||||||
|
// Gather sibling names from products in same brand + line
|
||||||
|
const siblings = allProducts
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.pid !== product.pid &&
|
||||||
|
p.brand_id === product.brand_id &&
|
||||||
|
p.line_id === product.line_id
|
||||||
|
)
|
||||||
|
.map((p) => p.title)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (siblings.length > 0) payload.siblingNames = siblings;
|
||||||
|
} else {
|
||||||
|
payload.name = product.title;
|
||||||
|
payload.description = product.description ?? "";
|
||||||
|
payload.company_name = product.brand;
|
||||||
|
payload.company_id = product.brand_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ product: payload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(product.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(product.pid, {
|
||||||
|
...existing,
|
||||||
|
status: "done",
|
||||||
|
result: {
|
||||||
|
isValid: result.isValid ?? true,
|
||||||
|
suggestion: result.suggestion || null,
|
||||||
|
issues: result.issues || [],
|
||||||
|
},
|
||||||
|
editedSuggestion: result.suggestion || null,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Validation error for PID ${product.pid}:`, err);
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(product.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(product.pid, {
|
||||||
|
...existing,
|
||||||
|
status: "done",
|
||||||
|
result: { isValid: true, issues: [] },
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
done++;
|
||||||
|
setValidationProgress((prev) =>
|
||||||
|
prev ? { ...prev, done } : null
|
||||||
|
);
|
||||||
|
if (done >= total) {
|
||||||
|
// Clear progress after a short delay
|
||||||
|
setTimeout(() => setValidationProgress(null), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[selectedField, isAiField, allProducts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleValidateAll = useCallback(() => {
|
||||||
|
if (!isAiField) return;
|
||||||
|
triggerValidation(pageProducts);
|
||||||
|
}, [isAiField, pageProducts, triggerValidation]);
|
||||||
|
|
||||||
|
// ── Row actions ──
|
||||||
|
|
||||||
|
const handleAccept = useCallback((pid: number, value: string) => {
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(pid, {
|
||||||
|
...existing,
|
||||||
|
decision: "accepted",
|
||||||
|
editedSuggestion: value,
|
||||||
|
manualEdit: value,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback((pid: number) => {
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(pid, { ...existing, decision: "dismissed" });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleManualEdit = useCallback((pid: number, value: string) => {
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(pid, { ...existing, manualEdit: value });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditSuggestion = useCallback((pid: number, value: string) => {
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(pid, { ...existing, editedSuggestion: value });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Save ──
|
||||||
|
|
||||||
|
const getChangedRows = useCallback((): { pid: number; value: string }[] => {
|
||||||
|
const changed: { pid: number; value: string }[] = [];
|
||||||
|
for (const product of allProducts) {
|
||||||
|
const state = aiStates.get(product.pid);
|
||||||
|
if (!state) continue;
|
||||||
|
|
||||||
|
// Accepted AI suggestion
|
||||||
|
if (state.decision === "accepted" && state.editedSuggestion != null) {
|
||||||
|
if (state.saveStatus !== "saved") {
|
||||||
|
changed.push({ pid: product.pid, value: state.editedSuggestion });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual edit (non-AI fields or user-modified field)
|
||||||
|
if (state.manualEdit != null) {
|
||||||
|
const original = getFieldValue(product, selectedField);
|
||||||
|
if (state.manualEdit !== original && state.saveStatus !== "saved") {
|
||||||
|
changed.push({ pid: product.pid, value: state.manualEdit });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}, [allProducts, aiStates, selectedField]);
|
||||||
|
|
||||||
|
const changedCount = useMemo(() => getChangedRows().length, [getChangedRows]);
|
||||||
|
|
||||||
|
const handleSaveAll = useCallback(async () => {
|
||||||
|
const rows = getChangedRows();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
toast.info("No changes to save");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitKey = getSubmitFieldKey(selectedField);
|
||||||
|
let done = 0;
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
setSaveProgress({ done: 0, total: rows.length });
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
// Mark as saving
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(row.pid, { ...existing, saveStatus: "saving", saveError: null });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await submitProductEdit({
|
||||||
|
pid: row.pid,
|
||||||
|
changes: { [submitKey]: row.value },
|
||||||
|
environment: "prod",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
successCount++;
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(row.pid, { ...existing, saveStatus: "saved" });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
const errorMsg = result.message || "Save failed";
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(row.pid, { ...existing, saveStatus: "error", saveError: errorMsg });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorCount++;
|
||||||
|
const errorMsg = err instanceof Error ? err.message : "Save failed";
|
||||||
|
setAiStates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(row.pid) ?? { ...INITIAL_ROW_STATE };
|
||||||
|
next.set(row.pid, { ...existing, saveStatus: "error", saveError: errorMsg });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
done++;
|
||||||
|
setSaveProgress({ done, total: rows.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => setSaveProgress(null), 500);
|
||||||
|
|
||||||
|
if (errorCount === 0) {
|
||||||
|
toast.success(`Saved ${successCount} product${successCount === 1 ? "" : "s"}`);
|
||||||
|
} else {
|
||||||
|
toast.error(`Saved ${successCount}, failed ${errorCount}`);
|
||||||
|
}
|
||||||
|
}, [getChangedRows, selectedField]);
|
||||||
|
|
||||||
|
// ── Clear AI states when field changes ──
|
||||||
|
|
||||||
|
const handleFieldChange = useCallback((field: BulkEditFieldChoice) => {
|
||||||
|
setSelectedField(field);
|
||||||
|
setAiStates(new Map());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Landing extras render ──
|
||||||
|
|
||||||
|
const renderLandingExtras = (tabKey: string) => {
|
||||||
|
const extras = landingExtras[tabKey];
|
||||||
|
if (!extras || extras.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2 items-start">
|
||||||
|
{extras.map((extra) => (
|
||||||
|
<button
|
||||||
|
key={extra.extra_id}
|
||||||
|
onClick={() => handleLandingClick(extra)}
|
||||||
|
disabled={activeLandingItem === extra.path}
|
||||||
|
className="flex-shrink-0 group relative w-28 text-left"
|
||||||
|
>
|
||||||
|
<div className="aspect-square w-full overflow-hidden rounded-lg border bg-card hover:bg-accent transition-colors relative">
|
||||||
|
{extra.image && (
|
||||||
|
<img
|
||||||
|
src={extra.image.startsWith("/") ? PROD_IMG_HOST + extra.image : extra.image}
|
||||||
|
alt={stripHtml(extra.name)}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeLandingItem === extra.path && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-background/60">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pt-1 text-center">
|
||||||
|
{(() => {
|
||||||
|
const parts = extra.name.split(/<br\s*\/?>/i).map(stripHtml);
|
||||||
|
return (
|
||||||
|
<div className="text-xs leading-snug">
|
||||||
|
{parts[0] && <span className="font-semibold">{parts[0]}</span>}
|
||||||
|
{parts[1] && <><br /><span className="font-normal">{parts[1]}</span></>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pagination ──
|
||||||
|
|
||||||
|
const renderPagination = () => {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const pages: (number | "ellipsis")[] = [];
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(1);
|
||||||
|
if (page > 3) pages.push("ellipsis");
|
||||||
|
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
if (page < totalPages - 2) pages.push("ellipsis");
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
className={page === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
{getPageNumbers().map((p, i) =>
|
||||||
|
p === "ellipsis" ? (
|
||||||
|
<PaginationItem key={`e${i}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem key={p}>
|
||||||
|
<PaginationLink
|
||||||
|
isActive={p === page}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
className={page === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingOptions) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-6 max-w-5xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">Bulk Edit</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">Field:</span>
|
||||||
|
<Select value={selectedField} onValueChange={(v) => handleFieldChange(v as BulkEditFieldChoice)}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FIELD_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{opt.label}
|
||||||
|
{opt.ai && <Sparkles className="h-3 w-3 text-purple-500" />}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{isAiField && (
|
||||||
|
<Button
|
||||||
|
onClick={handleValidateAll}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={pageProducts.length === 0 || validationProgress !== null}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4 mr-1" />
|
||||||
|
Validate Page
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
size="sm"
|
||||||
|
disabled={changedCount === 0 || saveProgress !== null}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4 mr-1" />
|
||||||
|
Save{changedCount > 0 ? ` (${changedCount})` : " All"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product loading tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="new">New</TabsTrigger>
|
||||||
|
<TabsTrigger value="preorder">Pre-Order</TabsTrigger>
|
||||||
|
<TabsTrigger value="hidden">Hidden (New)</TabsTrigger>
|
||||||
|
<TabsTrigger value="by-line">By Line</TabsTrigger>
|
||||||
|
<TabsTrigger value="search">Search</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="search" className="mt-4">
|
||||||
|
<ProductSearch
|
||||||
|
onSelect={handleSearchSelect}
|
||||||
|
onLoadAll={handleLoadAllSearch}
|
||||||
|
onNewSearch={handleNewSearch}
|
||||||
|
loadedPids={loadedPids}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="new" className="mt-4">
|
||||||
|
{isLoadingExtras && !landingExtras["new"] && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading featured lines...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renderLandingExtras("new")}
|
||||||
|
{isLoadingProducts && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading new products...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="preorder" className="mt-4">
|
||||||
|
{isLoadingExtras && !landingExtras["preorder"] && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading featured lines...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renderLandingExtras("preorder")}
|
||||||
|
{isLoadingProducts && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading pre-order products...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="hidden" className="mt-4">
|
||||||
|
{isLoadingProducts && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading hidden recently-created products...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="by-line" className="mt-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={lineCompany} onValueChange={setLineCompany}>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue placeholder="Select company..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fieldOptions?.companies.map((c) => (
|
||||||
|
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={lineLine} onValueChange={setLineLine} disabled={!lineCompany || isLoadingLines}>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue placeholder={isLoadingLines ? "Loading..." : "Select line..."} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{lineOptions.map((l) => (
|
||||||
|
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{sublineOptions.length > 0 && (
|
||||||
|
<Select value={lineSubline} onValueChange={setLineSubline} disabled={isLoadingSublines}>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue placeholder={isLoadingSublines ? "Loading..." : "All sublines"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sublineOptions.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<Button onClick={loadLineProducts} disabled={!lineLine || isLoadingProducts}>
|
||||||
|
{isLoadingProducts && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Progress bars */}
|
||||||
|
{validationProgress && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Validating...</span>
|
||||||
|
<span>
|
||||||
|
{validationProgress.done} / {validationProgress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={(validationProgress.done / validationProgress.total) * 100}
|
||||||
|
className="h-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saveProgress && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Saving...</span>
|
||||||
|
<span>
|
||||||
|
{saveProgress.done} / {saveProgress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={(saveProgress.done / saveProgress.total) * 100}
|
||||||
|
className="h-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={topRef} />
|
||||||
|
{renderPagination()}
|
||||||
|
|
||||||
|
{/* Product rows */}
|
||||||
|
{pageProducts.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pageProducts.map((product) => (
|
||||||
|
<BulkEditRow
|
||||||
|
key={product.pid}
|
||||||
|
product={product}
|
||||||
|
field={selectedField}
|
||||||
|
state={aiStates.get(product.pid) ?? INITIAL_ROW_STATE}
|
||||||
|
imageUrl={productImages.get(product.pid) ?? null}
|
||||||
|
selectOptions={currentFieldSelectOptions}
|
||||||
|
onAccept={handleAccept}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onManualEdit={handleManualEdit}
|
||||||
|
onEditSuggestion={handleEditSuggestion}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderPagination()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
@@ -20,10 +21,117 @@ import { ProductSearch } from "@/components/product-editor/ProductSearch";
|
|||||||
import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm";
|
import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm";
|
||||||
import type { LayoutMode } from "@/components/product-editor/ProductEditForm";
|
import type { LayoutMode } from "@/components/product-editor/ProductEditForm";
|
||||||
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types";
|
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
const PER_PAGE = 20;
|
const PER_PAGE = 20;
|
||||||
const PROD_IMG_HOST = "https://sbing.com";
|
const PROD_IMG_HOST = "https://sbing.com";
|
||||||
|
|
||||||
|
interface FilterSummaryItem {
|
||||||
|
type: string;
|
||||||
|
op: string;
|
||||||
|
v1: string | null;
|
||||||
|
v2: string | null;
|
||||||
|
v1Label: string | null;
|
||||||
|
v2Label: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OP_SYMBOLS: Record<string, string> = {
|
||||||
|
equals: "=",
|
||||||
|
notequals: "≠",
|
||||||
|
greater: ">",
|
||||||
|
greater_equals: "≥",
|
||||||
|
less: "<",
|
||||||
|
less_equals: "≤",
|
||||||
|
contains: "contains",
|
||||||
|
notcontains: "excludes",
|
||||||
|
begins: "starts with",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatFilterBadges(filters: FilterSummaryItem[]): { key: string; text: string }[] {
|
||||||
|
// Group same type+op rows so "line=5 OR line=7" becomes "Line = Lawn Fawn, Carta Bella"
|
||||||
|
const groups = new Map<string, { type: string; op: string; v1s: string[]; v2: string | null }>();
|
||||||
|
for (const f of filters) {
|
||||||
|
const key = `${f.type}:${f.op}`;
|
||||||
|
const display = f.v1Label ?? f.v1;
|
||||||
|
if (groups.has(key)) {
|
||||||
|
if (display) groups.get(key)!.v1s.push(display);
|
||||||
|
} else {
|
||||||
|
groups.set(key, { type: f.type, op: f.op, v1s: display ? [display] : [], v2: f.v2Label ?? f.v2 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).map(([key, g]) => {
|
||||||
|
const label = FILTER_LABELS[g.type] ?? g.type.replace(/_/g, " ");
|
||||||
|
let text: string;
|
||||||
|
if (["true", "true1"].includes(g.op)) {
|
||||||
|
text = label;
|
||||||
|
} else if (g.op === "false") {
|
||||||
|
text = `not ${label}`;
|
||||||
|
} else if (g.op === "isnull") {
|
||||||
|
text = `${label} is empty`;
|
||||||
|
} else if (g.op === "between" && g.v2) {
|
||||||
|
text = `${label}: ${g.v1s[0] ?? ""}–${g.v2}`;
|
||||||
|
} else {
|
||||||
|
const sym = OP_SYMBOLS[g.op] ?? g.op;
|
||||||
|
text = g.v1s.length ? `${label} ${sym} ${g.v1s.join(", ")}` : label;
|
||||||
|
}
|
||||||
|
return { key, text };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_LABELS: Record<string, string> = {
|
||||||
|
company: "company",
|
||||||
|
line: "line",
|
||||||
|
subline: "subline",
|
||||||
|
no_company: "no company",
|
||||||
|
no_line: "no line",
|
||||||
|
no_subline: "no subline",
|
||||||
|
artist: "artist",
|
||||||
|
price: "price",
|
||||||
|
default_price: "default price",
|
||||||
|
price_for_sort: "price for sort",
|
||||||
|
salepercent_for_sort: "sale % for sort",
|
||||||
|
"salepercent_for_sort__clearance": "sale % for sort (clearance)",
|
||||||
|
weight: "weight",
|
||||||
|
weight_price_ratio: "weight/price ratio",
|
||||||
|
price_weight_ratio: "price/weight ratio",
|
||||||
|
length: "length",
|
||||||
|
width: "width",
|
||||||
|
height: "height",
|
||||||
|
no_dim: "no dimensions",
|
||||||
|
size_cat: "size category",
|
||||||
|
dimension: "dimension",
|
||||||
|
yarn_weight: "yarn weight",
|
||||||
|
material: "material",
|
||||||
|
hide: "hidden",
|
||||||
|
hide_in_shop: "hidden in shop",
|
||||||
|
discontinued: "discontinued",
|
||||||
|
force_flag: "force flag",
|
||||||
|
exclusive: "exclusive",
|
||||||
|
lock_quantity: "lock qty",
|
||||||
|
show_notify: "show notify",
|
||||||
|
downloadable: "downloadable",
|
||||||
|
usa_only: "usa only",
|
||||||
|
not_clearance: "not clearance",
|
||||||
|
stat_stop: "stats stopped",
|
||||||
|
notnew: "not new",
|
||||||
|
not_backinstock: "not back-in-stock",
|
||||||
|
reorder: "reorder",
|
||||||
|
score: "score",
|
||||||
|
sold_view_score: "sold/view score",
|
||||||
|
visibility_score: "visibility score",
|
||||||
|
health_score: "health score",
|
||||||
|
tax_code: "tax code",
|
||||||
|
investor: "investor",
|
||||||
|
category: "category",
|
||||||
|
theme: "theme",
|
||||||
|
pid: "product id",
|
||||||
|
basket: "basket",
|
||||||
|
vendor: "vendor",
|
||||||
|
vendor_reference: "supplier id",
|
||||||
|
notions_reference: "notions id",
|
||||||
|
itemnumber: "item number",
|
||||||
|
};
|
||||||
|
|
||||||
/** Strip all HTML except <b>, </b>, and <br> tags */
|
/** Strip all HTML except <b>, </b>, and <br> tags */
|
||||||
function sanitizeHtml(html: string): string {
|
function sanitizeHtml(html: string): string {
|
||||||
return html.replace(/<\/?(?!b>|br\s*\/?>)[^>]*>/gi, "");
|
return html.replace(/<\/?(?!b>|br\s*\/?>)[^>]*>/gi, "");
|
||||||
@@ -40,15 +148,14 @@ export default function ProductEditor() {
|
|||||||
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
|
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
|
||||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
||||||
const [layoutMode, setLayoutMode] = useState<LayoutMode>("full");
|
const [layoutMode, setLayoutMode] = useState<LayoutMode>("full");
|
||||||
const [page, _setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const topRef = useRef<HTMLDivElement>(null);
|
|
||||||
const setPage = useCallback((v: number | ((p: number) => number)) => {
|
|
||||||
_setPage(v);
|
|
||||||
setTimeout(() => topRef.current?.scrollIntoView({ behavior: "smooth" }), 0);
|
|
||||||
}, []);
|
|
||||||
const [activeTab, setActiveTab] = useState("new");
|
const [activeTab, setActiveTab] = useState("new");
|
||||||
const [loadedTab, setLoadedTab] = useState<string | null>(null);
|
const [loadedTab, setLoadedTab] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Query picker state
|
||||||
|
const [queryId, setQueryId] = useState<string>("");
|
||||||
|
const [queryStatus, setQueryStatus] = useState<{ id: string; name: string; count: number; filters: FilterSummaryItem[]; unsupported: string[] } | null>(null);
|
||||||
|
|
||||||
// Line picker state
|
// Line picker state
|
||||||
const [lineCompany, setLineCompany] = useState<string>("");
|
const [lineCompany, setLineCompany] = useState<string>("");
|
||||||
const [lineLine, setLineLine] = useState<string>("");
|
const [lineLine, setLineLine] = useState<string>("");
|
||||||
@@ -113,6 +220,11 @@ export default function ProductEditor() {
|
|||||||
.finally(() => setIsLoadingSublines(false));
|
.finally(() => setIsLoadingSublines(false));
|
||||||
}, [lineLine]);
|
}, [lineLine]);
|
||||||
|
|
||||||
|
const loadedPids = useMemo(
|
||||||
|
() => new Set(allProducts.map((p) => Number(p.pid))),
|
||||||
|
[allProducts]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSearchSelect = useCallback((product: SearchProduct) => {
|
const handleSearchSelect = useCallback((product: SearchProduct) => {
|
||||||
setAllProducts((prev) => {
|
setAllProducts((prev) => {
|
||||||
if (prev.some((p) => p.pid === product.pid)) return prev;
|
if (prev.some((p) => p.pid === product.pid)) return prev;
|
||||||
@@ -121,6 +233,39 @@ export default function ProductEditor() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleNewSearch = useCallback(() => {
|
||||||
|
setAllProducts([]);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoadAllSearch = useCallback(async (pids: number[]) => {
|
||||||
|
const hadExisting = allProducts.length > 0;
|
||||||
|
setIsLoadingProducts(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get("/api/import/search-products", {
|
||||||
|
params: { pid: pids.join(",") },
|
||||||
|
});
|
||||||
|
const fetched = res.data as SearchProduct[];
|
||||||
|
setAllProducts((prev) => {
|
||||||
|
const existingPids = new Set(prev.map((p) => p.pid));
|
||||||
|
const newProducts = fetched.filter((p) => !existingPids.has(p.pid));
|
||||||
|
return [...prev, ...newProducts];
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
if (fetched.length > 1) {
|
||||||
|
toast.success(
|
||||||
|
hadExisting
|
||||||
|
? `Loaded remaining ${fetched.length} products`
|
||||||
|
: "Loaded all products"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load products");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProducts(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRemoveProduct = useCallback((pid: number) => {
|
const handleRemoveProduct = useCallback((pid: number) => {
|
||||||
setAllProducts((prev) => prev.filter((p) => p.pid !== pid));
|
setAllProducts((prev) => prev.filter((p) => p.pid !== pid));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -184,6 +329,8 @@ export default function ProductEditor() {
|
|||||||
// Auto-load when switching tabs
|
// Auto-load when switching tabs
|
||||||
const handleTabChange = useCallback((tab: string) => {
|
const handleTabChange = useCallback((tab: string) => {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
|
setQueryStatus(null);
|
||||||
|
setQueryId("");
|
||||||
if (tab === "new" && loadedTab !== "new") {
|
if (tab === "new" && loadedTab !== "new") {
|
||||||
setLoadedTab("new");
|
setLoadedTab("new");
|
||||||
loadFeedProducts("new-products", "new");
|
loadFeedProducts("new-products", "new");
|
||||||
@@ -195,6 +342,10 @@ export default function ProductEditor() {
|
|||||||
} else if (tab === "hidden" && loadedTab !== "hidden") {
|
} else if (tab === "hidden" && loadedTab !== "hidden") {
|
||||||
setLoadedTab("hidden");
|
setLoadedTab("hidden");
|
||||||
loadFeedProducts("hidden-new-products", "hidden");
|
loadFeedProducts("hidden-new-products", "hidden");
|
||||||
|
} else if (tab === "search" || tab === "by-line" || tab === "by-query") {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setAllProducts([]);
|
||||||
|
setPage(1);
|
||||||
}
|
}
|
||||||
}, [loadedTab, loadFeedProducts, loadLandingExtras]);
|
}, [loadedTab, loadFeedProducts, loadLandingExtras]);
|
||||||
|
|
||||||
@@ -219,6 +370,40 @@ export default function ProductEditor() {
|
|||||||
}
|
}
|
||||||
}, [lineCompany, lineLine, lineSubline]);
|
}, [lineCompany, lineLine, lineSubline]);
|
||||||
|
|
||||||
|
const loadQueryProducts = useCallback(async () => {
|
||||||
|
const qid = queryId.trim();
|
||||||
|
if (!qid || isNaN(Number(qid))) return;
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
setAllProducts([]);
|
||||||
|
setQueryStatus(null);
|
||||||
|
setIsLoadingProducts(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get("/api/import/query-products", {
|
||||||
|
params: { query_id: qid },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const { results, filters, unsupported } = res.data;
|
||||||
|
setAllProducts(results);
|
||||||
|
setPage(1);
|
||||||
|
setQueryStatus({
|
||||||
|
id: qid,
|
||||||
|
name: res.headers["x-query-name"] || "",
|
||||||
|
count: results.length,
|
||||||
|
filters: filters ?? [],
|
||||||
|
unsupported: unsupported ?? [],
|
||||||
|
});
|
||||||
|
if (unsupported?.length) {
|
||||||
|
toast.warning(`Query #${qid}: ${unsupported.length} unsupported filter(s) removed — results may be broader than expected`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!axios.isCancel(e)) toast.error("Failed to load query products");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProducts(false);
|
||||||
|
}
|
||||||
|
}, [queryId]);
|
||||||
|
|
||||||
const renderLandingExtras = (tabKey: string) => {
|
const renderLandingExtras = (tabKey: string) => {
|
||||||
const extras = landingExtras[tabKey];
|
const extras = landingExtras[tabKey];
|
||||||
if (!extras || extras.length === 0) return null;
|
if (!extras || extras.length === 0) return null;
|
||||||
@@ -370,11 +555,75 @@ export default function ProductEditor() {
|
|||||||
<TabsTrigger value="preorder">Pre-Order</TabsTrigger>
|
<TabsTrigger value="preorder">Pre-Order</TabsTrigger>
|
||||||
<TabsTrigger value="hidden">Hidden (New)</TabsTrigger>
|
<TabsTrigger value="hidden">Hidden (New)</TabsTrigger>
|
||||||
<TabsTrigger value="by-line">By Line</TabsTrigger>
|
<TabsTrigger value="by-line">By Line</TabsTrigger>
|
||||||
|
<TabsTrigger value="by-query">By Query</TabsTrigger>
|
||||||
<TabsTrigger value="search">Search</TabsTrigger>
|
<TabsTrigger value="search">Search</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="search" className="mt-4">
|
<TabsContent value="search" className="mt-4">
|
||||||
<ProductSearch onSelect={handleSearchSelect} />
|
<ProductSearch
|
||||||
|
onSelect={handleSearchSelect}
|
||||||
|
onLoadAll={handleLoadAllSearch}
|
||||||
|
onNewSearch={handleNewSearch}
|
||||||
|
loadedPids={loadedPids}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="by-query" className="mt-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Query ID..."
|
||||||
|
value={queryId}
|
||||||
|
onChange={(e) => setQueryId(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") loadQueryProducts(); }}
|
||||||
|
className="w-52"
|
||||||
|
/>
|
||||||
|
<Button onClick={loadQueryProducts} disabled={!queryId.trim() || isNaN(Number(queryId)) || isLoadingProducts}>
|
||||||
|
{isLoadingProducts && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
{queryStatus && (
|
||||||
|
<Button variant="outline" size="icon" onClick={loadQueryProducts} disabled={isLoadingProducts} title="Refresh query results">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLoadingProducts && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading products...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{queryStatus && !isLoadingProducts && (
|
||||||
|
<div className="mt-3 text-sm text-muted-foreground space-y-1.5">
|
||||||
|
<div>
|
||||||
|
Showing {queryStatus.count} product{queryStatus.count !== 1 ? "s" : ""} from query {queryStatus.id}
|
||||||
|
{queryStatus.name ? ` — ${queryStatus.name}` : ""}.{" "}
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product_tool/${queryStatus.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 underline hover:text-foreground"
|
||||||
|
>
|
||||||
|
Open in Product Tool <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{queryStatus.filters.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 items-center">
|
||||||
|
<span className="text-xs">Filters:</span>
|
||||||
|
{formatFilterBadges(queryStatus.filters).map(({ key, text }) => (
|
||||||
|
<span key={key} className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{queryStatus.unsupported.length > 0 && (
|
||||||
|
<div className="text-amber-600 dark:text-amber-400">
|
||||||
|
{queryStatus.unsupported.length} filter type{queryStatus.unsupported.length !== 1 ? "s" : ""} not supported ({queryStatus.unsupported.join(", ")}). Results may be broader than expected.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="new" className="mt-4">
|
<TabsContent value="new" className="mt-4">
|
||||||
@@ -457,10 +706,15 @@ export default function ProductEditor() {
|
|||||||
Load
|
Load
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{isLoadingProducts && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading line products...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div ref={topRef} />
|
|
||||||
{renderPagination()}
|
{renderPagination()}
|
||||||
|
|
||||||
{products.length > 0 && fieldOptions && (
|
{products.length > 0 && fieldOptions && (
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
export interface ImageChanges {
|
export interface ImageChanges {
|
||||||
order: (number | string)[];
|
order: (number | string)[];
|
||||||
hidden: number[];
|
hidden: number[];
|
||||||
deleted: number[];
|
show: number[];
|
||||||
added: Record<string, string>; // e.g. { "new-0": "https://..." }
|
delete: number[];
|
||||||
|
add: Record<string, string>; // e.g. { "new-0": "https://..." }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitProductEditArgs {
|
export interface SubmitProductEditArgs {
|
||||||
pid: number;
|
pid: number;
|
||||||
changes: Record<string, unknown>;
|
changes: Record<string, unknown>;
|
||||||
environment: "dev" | "prod";
|
environment: "dev" | "prod";
|
||||||
imageChanges?: ImageChanges;
|
}
|
||||||
|
|
||||||
|
export interface SubmitImageChangesArgs {
|
||||||
|
pid: number;
|
||||||
|
imageChanges: ImageChanges;
|
||||||
|
environment: "dev" | "prod";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitProductEditResponse {
|
export interface SubmitProductEditResponse {
|
||||||
@@ -31,14 +37,10 @@ export async function submitProductEdit({
|
|||||||
pid,
|
pid,
|
||||||
changes,
|
changes,
|
||||||
environment,
|
environment,
|
||||||
imageChanges,
|
|
||||||
}: SubmitProductEditArgs): Promise<SubmitProductEditResponse> {
|
}: SubmitProductEditArgs): Promise<SubmitProductEditResponse> {
|
||||||
const targetUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
|
const targetUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
|
||||||
|
|
||||||
const product: Record<string, unknown> = { pid, ...changes };
|
const product: Record<string, unknown> = { pid, ...changes };
|
||||||
if (imageChanges) {
|
|
||||||
product.image_changes = imageChanges;
|
|
||||||
}
|
|
||||||
const payload = new URLSearchParams();
|
const payload = new URLSearchParams();
|
||||||
payload.append("products", JSON.stringify([product]));
|
payload.append("products", JSON.stringify([product]));
|
||||||
|
|
||||||
@@ -96,3 +98,138 @@ export async function submitProductEdit({
|
|||||||
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TaxonomyType = "cats" | "themes" | "colors";
|
||||||
|
|
||||||
|
export interface SubmitTaxonomySetArgs {
|
||||||
|
pid: number;
|
||||||
|
type: TaxonomyType;
|
||||||
|
ids: number[];
|
||||||
|
environment: "dev" | "prod";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitTaxonomySet({
|
||||||
|
pid,
|
||||||
|
type,
|
||||||
|
ids,
|
||||||
|
environment,
|
||||||
|
}: SubmitTaxonomySetArgs): Promise<SubmitProductEditResponse> {
|
||||||
|
const base = environment === "dev" ? "/apiv2-test" : "/apiv2";
|
||||||
|
const targetUrl = `${base}/product/${type}/${pid}/set`;
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(ids),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (environment === "dev") {
|
||||||
|
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
|
||||||
|
if (authToken) {
|
||||||
|
fetchOptions.body = JSON.stringify({ ids, auth: authToken });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fetchOptions.credentials = "include";
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(targetUrl, fetchOptions);
|
||||||
|
} catch (networkError) {
|
||||||
|
throw new Error(
|
||||||
|
networkError instanceof Error ? networkError.message : "Network request failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await response.text();
|
||||||
|
|
||||||
|
if (isHtmlResponse(rawBody)) {
|
||||||
|
throw new Error(
|
||||||
|
"Backend authentication required. Please ensure you are logged into the backend system."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Unexpected response from backend (${response.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new Error("Empty response from backend");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedResponse = parsed as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
success: Boolean(parsedResponse.success),
|
||||||
|
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
|
||||||
|
data: parsedResponse.data,
|
||||||
|
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEV_IMAGE_ENDPOINT = "/apiv2-test/product/image_changes";
|
||||||
|
const PROD_IMAGE_ENDPOINT = "/apiv2/product/image_changes";
|
||||||
|
|
||||||
|
export async function submitImageChanges({
|
||||||
|
pid,
|
||||||
|
imageChanges,
|
||||||
|
environment,
|
||||||
|
}: SubmitImageChangesArgs): Promise<SubmitProductEditResponse> {
|
||||||
|
const targetUrl = environment === "dev" ? DEV_IMAGE_ENDPOINT : PROD_IMAGE_ENDPOINT;
|
||||||
|
|
||||||
|
const body = { pid, image_changes: imageChanges };
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (environment === "dev") {
|
||||||
|
const authToken = import.meta.env.VITE_APIV2_AUTH_TOKEN;
|
||||||
|
if (authToken) {
|
||||||
|
(body as Record<string, unknown>).auth = authToken;
|
||||||
|
fetchOptions.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fetchOptions.credentials = "include";
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(targetUrl, fetchOptions);
|
||||||
|
} catch (networkError) {
|
||||||
|
throw new Error(
|
||||||
|
networkError instanceof Error ? networkError.message : "Network request failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await response.text();
|
||||||
|
|
||||||
|
if (isHtmlResponse(rawBody)) {
|
||||||
|
throw new Error(
|
||||||
|
"Backend authentication required. Please ensure you are logged into the backend system."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Unexpected response from backend (${response.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new Error("Empty response from backend");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedResponse = parsed as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
success: Boolean(parsedResponse.success),
|
||||||
|
message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined,
|
||||||
|
data: parsedResponse.data,
|
||||||
|
error: parsedResponse.error ?? parsedResponse.errors ?? parsedResponse.error_msg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user