8 Commits

27 changed files with 2557 additions and 390 deletions

View File

@@ -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)
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
MAX_SMOOTHING_MULTIPLIER = 10 # cap exp smoothing forecast at Nx observed velocity
logging.basicConfig(
level=logging.INFO,
@@ -838,6 +839,12 @@ def forecast_mature(product, history_df):
if np.count_nonzero(series) < 2:
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:
# Holt's with damped trend: the phi parameter dampens the trend over
# 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)
fit = model.fit(optimized=True)
forecast = fit.forecast(FORECAST_HORIZON_DAYS)
forecast = np.maximum(forecast, 0)
forecast = np.clip(forecast, 0, cap)
return forecast
except Exception:
# 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')
fit = model.fit(optimized=True)
forecast = fit.forecast(FORECAST_HORIZON_DAYS)
forecast = np.maximum(forecast, 0)
forecast = np.clip(forecast, 0, cap)
return forecast
except Exception as e:
log.debug(f"ExpSmoothing failed for pid {pid}: {e}")

View File

@@ -21,8 +21,8 @@ router.get('/stock/metrics', async (req, res) => {
COALESCE(COUNT(*), 0)::integer as total_products,
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,
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_retail END), 0)::numeric, 3) as total_retail
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, 2) as total_retail
FROM product_metrics
WHERE is_visible = true
`);
@@ -34,21 +34,21 @@ router.get('/stock/metrics', async (req, res) => {
COALESCE(brand, 'Unbranded') as brand,
COUNT(DISTINCT pid)::integer as variant_count,
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_retail), 0)::numeric, 3) as stock_retail
ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 2) as stock_cost,
ROUND(COALESCE(SUM(current_stock_retail), 0)::numeric, 2) as stock_retail
FROM product_metrics
WHERE current_stock > 0
AND is_visible = true
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 (
SELECT
'Other' as brand,
SUM(variant_count)::integer as variant_count,
SUM(stock_units)::integer as stock_units,
ROUND(SUM(stock_cost)::numeric, 3) as stock_cost,
ROUND(SUM(stock_retail)::numeric, 3) as stock_retail
ROUND(SUM(stock_cost)::numeric, 2) as stock_cost,
ROUND(SUM(stock_retail)::numeric, 2) as stock_retail
FROM brand_totals
WHERE stock_cost <= 5000
),
@@ -154,7 +154,10 @@ router.get('/purchase/metrics', async (req, res) => {
vendor,
SUM(on_order_qty)::integer AS units,
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
WHERE is_visible = true AND on_order_qty > 0
GROUP BY vendor
@@ -169,9 +172,10 @@ router.get('/purchase/metrics', async (req, res) => {
retail: parseFloat(v.retail) || 0
}));
const onOrderUnits = vendorOrders.reduce((sum, v) => sum + v.units, 0);
const onOrderCost = vendorOrders.reduce((sum, v) => sum + v.cost, 0);
const onOrderRetail = vendorOrders.reduce((sum, v) => sum + v.retail, 0);
const firstRow = vendorRows[0];
const onOrderUnits = firstRow ? parseInt(firstRow.total_units) || 0 : 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
const response = {
@@ -199,8 +203,8 @@ router.get('/replenishment/metrics', async (req, res) => {
SELECT
COUNT(DISTINCT pm.pid)::integer as products_to_replenish,
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_retail), 0)::numeric, 3) as total_retail
ROUND(COALESCE(SUM(pm.replenishment_cost), 0)::numeric, 2) as total_cost,
ROUND(COALESCE(SUM(pm.replenishment_retail), 0)::numeric, 2) as total_retail
FROM product_metrics pm
WHERE pm.is_visible = true
AND pm.is_replenishable = true
@@ -216,8 +220,8 @@ router.get('/replenishment/metrics', async (req, res) => {
pm.title,
pm.current_stock::integer as current_stock,
pm.replenishment_units::integer as replenish_qty,
ROUND(pm.replenishment_cost::numeric, 3) as replenish_cost,
ROUND(pm.replenishment_retail::numeric, 3) as replenish_retail,
ROUND(pm.replenishment_cost::numeric, 2) as replenish_cost,
ROUND(pm.replenishment_retail::numeric, 2) as replenish_retail,
pm.status,
pm.planning_period_days::text as planning_period
FROM product_metrics pm
@@ -552,7 +556,7 @@ router.get('/forecast/metrics', async (req, res) => {
return res.json({
forecastSales: Math.round(totalUnits),
forecastRevenue: totalRevenue.toFixed(2),
forecastRevenue: parseFloat(totalRevenue.toFixed(2)),
confidenceLevel,
dailyForecasts,
dailyForecastsByPhase,
@@ -611,7 +615,7 @@ router.get('/forecast/metrics', async (req, res) => {
res.json({
forecastSales: Math.round(dailyUnits * days),
forecastRevenue: (dailyRevenue * days).toFixed(2),
forecastRevenue: parseFloat((dailyRevenue * days).toFixed(2)),
confidenceLevel: 0,
dailyForecasts,
categoryForecasts: categoryRows.map(c => ({
@@ -794,10 +798,10 @@ router.get('/overstock/metrics', async (req, res) => {
if (parseInt(countCheck.overstock_count) === 0) {
return res.json({
overstockedProducts: 0,
total_excess_units: 0,
total_excess_cost: 0,
total_excess_retail: 0,
category_data: []
totalExcessUnits: 0,
totalExcessCost: 0,
totalExcessRetail: 0,
categoryData: []
});
}
@@ -806,8 +810,8 @@ router.get('/overstock/metrics', async (req, res) => {
SELECT
COUNT(DISTINCT pid)::integer as total_overstocked,
SUM(overstocked_units)::integer as total_excess_units,
ROUND(SUM(overstocked_cost)::numeric, 3) as total_excess_cost,
ROUND(SUM(overstocked_retail)::numeric, 3) as total_excess_retail
ROUND(SUM(overstocked_cost)::numeric, 2) as total_excess_cost,
ROUND(SUM(overstocked_retail)::numeric, 2) as total_excess_retail
FROM product_metrics
WHERE status = 'Overstock'
AND is_visible = true
@@ -819,8 +823,8 @@ router.get('/overstock/metrics', async (req, res) => {
c.name as category_name,
COUNT(DISTINCT pm.pid)::integer as overstocked_products,
SUM(pm.overstocked_units)::integer as total_excess_units,
ROUND(SUM(pm.overstocked_cost)::numeric, 3) as total_excess_cost,
ROUND(SUM(pm.overstocked_retail)::numeric, 3) as total_excess_retail
ROUND(SUM(pm.overstocked_cost)::numeric, 2) as total_excess_cost,
ROUND(SUM(pm.overstocked_retail)::numeric, 2) as total_excess_retail
FROM categories c
JOIN product_categories pc ON c.cat_id = pc.cat_id
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
const response = {
overstockedProducts: parseInt(summaryMetrics.total_overstocked) || 0,
total_excess_units: parseInt(summaryMetrics.total_excess_units) || 0,
total_excess_cost: parseFloat(summaryMetrics.total_excess_cost) || 0,
total_excess_retail: parseFloat(summaryMetrics.total_excess_retail) || 0,
category_data: categoryData.map(cat => ({
totalExcessUnits: parseInt(summaryMetrics.total_excess_units) || 0,
totalExcessCost: parseFloat(summaryMetrics.total_excess_cost) || 0,
totalExcessRetail: parseFloat(summaryMetrics.total_excess_retail) || 0,
categoryData: categoryData.map(cat => ({
category: cat.category_name,
products: parseInt(cat.overstocked_products) || 0,
units: parseInt(cat.total_excess_units) || 0,

View File

@@ -1058,7 +1058,16 @@ router.get('/search-products', async (req, res) => {
// Build WHERE clause with additional filters
let whereClause;
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 {
whereClause = `
WHERE (
@@ -1142,12 +1151,13 @@ router.get('/search-products', async (req, res) => {
p.itemnumber AS sku,
p.upc AS barcode,
p.harmonized_tariff_code,
pcp.price_each AS price,
MIN(pcp.price_each) AS price,
p.sellingprice AS regular_price,
CASE
WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0)
THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0)
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
CASE
WHEN sid.supplier_id = 92 THEN
CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END
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,
s.companyname AS vendor,
sid.supplier_itemnumber AS vendor_reference,
@@ -1162,7 +1172,7 @@ router.get('/search-products', async (req, res) => {
p.subline AS subline_id,
pc4.name AS artist,
p.artist AS artist_id,
COALESCE(CASE
COALESCE(CASE
WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit
ELSE sid.supplier_qty_per_unit
END, sid.notions_qty_per_unit) AS moq,
@@ -1263,12 +1273,13 @@ const PRODUCT_SELECT = `
p.itemnumber AS sku,
p.upc AS barcode,
p.harmonized_tariff_code,
pcp.price_each AS price,
MIN(pcp.price_each) AS price,
p.sellingprice AS regular_price,
CASE
WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0)
THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0)
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
WHEN sid.supplier_id = 92 THEN
CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END
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,
s.companyname AS vendor,
sid.supplier_itemnumber AS vendor_reference,

View File

@@ -473,21 +473,31 @@ router.get('/search', async (req, res) => {
return [like, like, like, like, like];
});
const { rows } = await pool.query(`
SELECT pid, title, sku, barcode, brand, line, regular_price, image_175
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()}%`]);
const whereClause = conditions.join(' AND ');
const searchParams = [...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 50
`, searchParams),
pool.query(`
SELECT COUNT(*)::int AS total
FROM products p
WHERE ${whereClause}
`, params),
]);
res.json({ results: rows, total: countRows[0].total });
} catch (error) {
console.error('Error searching products:', error);
res.status(500).json({ error: 'Search failed' });

View File

@@ -40,6 +40,9 @@ const Import = lazy(() => import('./pages/Import').then(module => ({ default: mo
// Product editor
const ProductEditor = lazy(() => import('./pages/ProductEditor'));
// Bulk edit
const BulkEdit = lazy(() => import('./pages/BulkEdit'));
// 4. Chat archive - separate chunk
const Chat = lazy(() => import('./pages/Chat').then(module => ({ default: module.Chat })));
@@ -198,6 +201,15 @@ function App() {
</Protected>
} />
{/* Bulk edit */}
<Route path="/bulk-edit" element={
<Protected page="bulk_edit">
<Suspense fallback={<PageLoading />}>
<BulkEdit />
</Suspense>
</Protected>
} />
{/* Product import - separate chunk */}
<Route path="/import" element={
<Protected page="import">

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

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

View File

@@ -14,6 +14,7 @@ import {
FileSearch,
ShoppingCart,
FilePenLine,
PenLine,
Mail,
} from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
@@ -122,6 +123,12 @@ const toolsItems = [
url: "/product-editor",
permission: "access:product_editor"
},
{
title: "Bulk Edit",
icon: PenLine,
url: "/bulk-edit",
permission: "access:bulk_edit"
},
{
title: "Newsletter",
icon: Mail,

View File

@@ -44,7 +44,7 @@ interface DailyPhaseData {
interface ForecastData {
forecastSales: number
forecastRevenue: string
forecastRevenue: number
confidenceLevel: number
dailyForecasts: {
date: string
@@ -129,7 +129,7 @@ export function ForecastMetrics() {
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
</div>
{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>

View File

@@ -17,10 +17,10 @@ interface PhaseBreakdown {
interface OverstockMetricsData {
overstockedProducts: number
total_excess_units: number
total_excess_cost: number
total_excess_retail: number
category_data: {
totalExcessUnits: number
totalExcessCost: number
totalExcessRetail: number
categoryData: {
category: string
products: number
units: number
@@ -69,7 +69,7 @@ export function OverstockMetrics() {
<p className="text-sm font-medium text-muted-foreground">Overstocked Units</p>
</div>
{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 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>
</div>
{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 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>
</div>
{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>
{data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (

View File

@@ -17,6 +17,7 @@ export function EditableInput({
copyable,
alwaysShowCopy,
formatDisplay,
rightAction,
}: {
value: string;
onChange: (val: string) => void;
@@ -30,6 +31,7 @@ export function EditableInput({
copyable?: boolean;
alwaysShowCopy?: boolean;
formatDisplay?: (val: string) => string;
rightAction?: React.ReactNode;
}) {
const [editing, setEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -106,6 +108,7 @@ export function EditableInput({
<Copy className="h-3 w-3" />
</button>
)}
{rightAction}
</div>
);
}

View File

@@ -175,7 +175,7 @@ function SortableImageCell({
src={src}
alt={`Image ${image.iid}`}
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"
)}
draggable={false}

View File

@@ -7,8 +7,12 @@ import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink } from "lucide-react";
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink, Sparkles } from "lucide-react";
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 { EditableInput } from "./EditableInput";
import { EditableMultiSelect } from "./EditableMultiSelect";
@@ -75,6 +79,8 @@ interface FieldConfig {
showColors?: boolean;
/** Format value for display (editing shows raw value) */
formatDisplay?: (val: string) => string;
/** Number of grid columns this field spans (default 1) */
colSpan?: number;
}
interface FieldGroup {
@@ -103,7 +109,8 @@ const F: Record<string, FieldConfig> = {
artist: { key: "artist", label: "Artist", type: "combobox", optionsKey: "artists", searchPlaceholder: "Search artists..." },
tax_cat: { key: "tax_cat", label: "Tax Cat", type: "combobox", optionsKey: "taxCategories" },
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); } },
min_qty: { key: "qty_per_unit", label: "Min Qty", type: "input" },
case_qty: { key: "case_qty", label: "Case Pack", type: "input" },
@@ -141,7 +148,7 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
{ label: "Taxonomy", cols: 2, fields: [F.supplier,F.company, F.line, F.subline] },
{ cols: 2, fields: [F.artist, F.size_cat] },
{ 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] },
{ cols: 4, fields: [ F.tax_cat, F.ship,F.coo, F.hts_code] },
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
@@ -152,22 +159,21 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
sidebarGroups: 3,
descriptionRows: 8,
groups: [
{ label: "Taxonomy", cols: 2, fields: [F.company, F.msrp, F.line, F.subline] },
{ cols: 2, fields: [F.artist, F.size_cat] },
{ label: "Taxonomy", cols: 7, fields: [{ ...F.company, colSpan: 3 }, { ...F.msrp, colSpan: 2 }, { ...F.cur_price, colSpan: 2 }] },
{ cols: 2, fields: [F.line, F.subline, F.artist, F.size_cat] },
{ label: "Description", cols: 1, fields: [F.description] },
{ label: "Classification", cols: 3, fields: [F.categories, F.themes, F.colors] },
],
},
backend: {
sidebarGroups: 5,
sidebarGroups: 6,
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] },
{ 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] },
{ label: "Notes", cols: 1, fields: [F.priv_notes] },
],
},
minimal: {
@@ -207,11 +213,14 @@ export function ProductEditForm({
handleSubmit,
reset,
watch,
setValue,
getValues,
formState: { dirtyFields },
} = useForm<ProductFormValues>();
const watchCompany = watch("company");
const watchLine = watch("line");
const watchDescription = watch("description");
// Populate form on mount
useEffect(() => {
@@ -226,6 +235,7 @@ export function ProductEditForm({
supplier_no: product.vendor_reference ?? "",
notions_no: product.notions_reference ?? "",
msrp: String(product.regular_price ?? ""),
current_price: String(product.price ?? ""),
cost_each: String(product.cost_price ?? ""),
qty_per_unit: String(product.moq ?? ""),
case_qty: String(product.case_qty ?? ""),
@@ -317,12 +327,13 @@ export function ProductEditForm({
const originalIds = original.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 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) {
if (img.isNew && img.imageUrl) {
added[String(img.iid)] = img.imageUrl;
add[String(img.iid)] = img.imageUrl;
}
}
@@ -331,14 +342,14 @@ export function ProductEditForm({
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 hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
const hasDeleted = deleted.length > 0;
const hasAdded = Object.keys(added).length > 0;
const hasDeleted = toDelete.length > 0;
const hasAdded = Object.keys(add).length > 0;
if (!orderChanged && !hiddenChanged && !hasDeleted && !hasAdded) {
return null;
}
return { order, hidden, deleted, added };
return { order, hidden, show, delete: toDelete, add };
}, [productImages]);
const onSubmit = useCallback(
@@ -362,32 +373,57 @@ export function ProductEditForm({
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");
return;
}
setIsSubmitting(true);
try {
const result = await submitProductEdit({
pid: product.pid,
changes,
environment: "prod",
imageChanges: imageChanges ?? undefined,
});
const promises: Promise<{ success: boolean; error?: unknown; message?: string }>[] = [];
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");
originalValuesRef.current = { ...data };
originalImagesRef.current = [...productImages];
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) {
toast.error(
@@ -411,6 +447,62 @@ export function ProductEditForm({
[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]);
const hasImageChanges = computeImageChanges() !== null;
const changedCount = Object.keys(dirtyFields).length;
@@ -421,8 +513,13 @@ export function ProductEditForm({
style={{ gridTemplateColumns: `repeat(${group.cols}, minmax(0, 1fr))` }}
>
{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") {
return (
return wrapSpan(
<Controller
key={fc.key}
name={fc.key}
@@ -443,7 +540,7 @@ export function ProductEditForm({
);
}
if (fc.type === "combobox") {
return (
return wrapSpan(
<Controller
key={fc.key}
name={fc.key}
@@ -463,7 +560,7 @@ export function ProductEditForm({
);
}
if (fc.type === "multiselect") {
return (
return wrapSpan(
<Controller
key={fc.key}
name={fc.key}
@@ -483,9 +580,42 @@ export function ProductEditForm({
);
}
if (fc.type === "textarea") {
const isDescription = fc.key === "description";
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">
<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" />
</div>
);
@@ -499,7 +629,7 @@ export function ProductEditForm({
<Card>
<CardHeader>
<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">
<Controller
name="name"
@@ -512,9 +642,42 @@ export function ProductEditForm({
placeholder="Product name"
className="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>
<Tooltip>
<TooltipTrigger asChild>
@@ -611,6 +774,38 @@ export function ProductEditForm({
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 */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button

View File

@@ -12,10 +12,16 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Loader2, Search } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Check, ChevronDown, Loader2, Search } from "lucide-react";
import type { SearchProduct } from "./types";
const SEARCH_LIMIT = 50;
interface QuickSearchResult {
pid: number;
title: string;
@@ -29,31 +35,43 @@ interface QuickSearchResult {
export function ProductSearch({
onSelect,
onLoadAll,
onNewSearch,
loadedPids,
}: {
onSelect: (product: SearchProduct) => void;
onLoadAll: (pids: number[]) => void;
onNewSearch: () => void;
loadedPids: Set<number>;
}) {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<QuickSearchResult[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
const [resultsOpen, setResultsOpen] = useState(false);
const handleSearch = useCallback(async () => {
if (!searchTerm.trim()) return;
setIsSearching(true);
onNewSearch();
try {
const res = await axios.get("/api/products/search", {
params: { q: searchTerm },
});
setSearchResults(res.data);
setSearchResults(res.data.results);
setTotalCount(res.data.total);
setResultsOpen(true);
} catch {
toast.error("Search failed");
} finally {
setIsSearching(false);
}
}, [searchTerm]);
}, [searchTerm, onNewSearch]);
const handleSelect = useCallback(
async (product: QuickSearchResult) => {
if (loadedPids.has(Number(product.pid))) return;
setIsLoadingProduct(product.pid);
try {
const res = await axios.get("/api/import/search-products", {
@@ -62,7 +80,7 @@ export function ProductSearch({
const full = (res.data as SearchProduct[])[0];
if (full) {
onSelect(full);
setSearchResults([]);
setResultsOpen(false);
} else {
toast.error("Could not load full product details");
}
@@ -72,9 +90,23 @@ export function ProductSearch({
setIsLoadingProduct(null);
}
},
[onSelect]
[onSelect, loadedPids]
);
const handleLoadAll = useCallback(() => {
const pids = searchResults
.map((r) => r.pid)
.filter((pid) => !loadedPids.has(Number(pid)));
if (pids.length === 0) return;
onLoadAll(pids);
setResultsOpen(false);
}, [searchResults, loadedPids, onLoadAll]);
const unloadedCount = searchResults.filter(
(r) => !loadedPids.has(Number(r.pid))
).length;
const isTruncated = totalCount > SEARCH_LIMIT;
return (
<Card>
<CardHeader>
@@ -98,45 +130,80 @@ export function ProductSearch({
</div>
{searchResults.length > 0 && (
<div className="mt-4 border rounded-md">
<ScrollArea className="max-h-80">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Brand</TableHead>
<TableHead>Line</TableHead>
<TableHead className="text-right">Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchResults.map((product) => (
<TableRow
key={product.pid}
className={`cursor-pointer hover:bg-muted/50 ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
onClick={() => !isLoadingProduct && handleSelect(product)}
>
<TableCell className="max-w-[300px] truncate">
{isLoadingProduct === product.pid && (
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
)}
{product.title}
</TableCell>
<TableCell>{product.sku}</TableCell>
<TableCell>{product.brand}</TableCell>
<TableCell>{product.line}</TableCell>
<TableCell className="text-right">
$
{Number(product.regular_price)?.toFixed(2) ??
product.regular_price}
</TableCell>
<Collapsible open={resultsOpen} onOpenChange={setResultsOpen} className="mt-4">
<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>
<TableHeader>
<TableRow>
<TableHead className="sticky top-0 bg-background">Name</TableHead>
<TableHead className="sticky top-0 bg-background">SKU</TableHead>
<TableHead className="sticky top-0 bg-background">Brand</TableHead>
<TableHead className="sticky top-0 bg-background">Line</TableHead>
<TableHead className="sticky top-0 bg-background text-right">
Price
</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
</TableHeader>
<TableBody>
{searchResults.map((product) => {
const isLoaded = loadedPids.has(Number(product.pid));
return (
<TableRow
key={product.pid}
className={`${isLoaded ? "opacity-50" : "cursor-pointer hover:bg-muted/50"} ${isLoadingProduct === product.pid ? "opacity-50" : ""}`}
onClick={() =>
!isLoadingProduct && !isLoaded && handleSelect(product)
}
>
<TableCell className="max-w-[300px] truncate">
{isLoadingProduct === product.pid && (
<Loader2 className="h-3 w-3 animate-spin inline mr-2" />
)}
{isLoaded && (
<Check className="h-3 w-3 inline mr-2 text-green-600" />
)}
{product.title}
</TableCell>
<TableCell>{product.sku}</TableCell>
<TableCell>{product.brand}</TableCell>
<TableCell>{product.line}</TableCell>
<TableCell className="text-right">
$
{Number(product.regular_price)?.toFixed(2) ??
product.regular_price}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{isTruncated && (
<p className="text-xs text-muted-foreground mt-2">
Showing top {SEARCH_LIMIT} of {totalCount} matches. Refine your search to find specific products.
</p>
)}
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</Card>

View File

@@ -86,6 +86,7 @@ export interface ProductFormValues {
supplier_no: string;
notions_no: string;
msrp: string;
current_price: string;
cost_each: string;
qty_per_unit: string;
case_qty: string;

View File

@@ -193,18 +193,6 @@ export const BASE_IMPORT_FIELDS = [
fieldType: { type: "input" },
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",
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" },
],
},
{
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",
key: "ship_restrictions",

View File

@@ -5,11 +5,11 @@
* Used for inline validation suggestions on Name and Description fields.
*
* 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 { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
@@ -24,10 +24,14 @@ interface AiSuggestionBadgeProps {
suggestion: string;
/** List of issues found (optional) */
issues?: string[];
/** Called when user accepts the suggestion */
onAccept: () => void;
/** Called when user accepts the suggestion (receives the possibly-edited value) */
onAccept: (editedValue: string) => void;
/** Called when user dismisses the suggestion */
onDismiss: () => void;
/** Called to refresh (re-run) the AI validation */
onRevalidate?: () => void;
/** Whether re-validation is in progress */
isRevalidating?: boolean;
/** Additional CSS classes */
className?: string;
/** Whether to show the suggestion as compact (inline) - used for name field */
@@ -41,13 +45,21 @@ export function AiSuggestionBadge({
issues = [],
onAccept,
onDismiss,
onRevalidate,
isRevalidating = false,
className,
compact = false,
collapsible = false
}: AiSuggestionBadgeProps) {
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) {
return (
<div
@@ -58,24 +70,27 @@ export function AiSuggestionBadge({
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" />
<span className="text-purple-700 dark:text-purple-300">
{suggestion}
</span>
<input
type="text"
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 className="flex items-center gap-0.5 flex-shrink-0">
<div className="flex items-center gap-[0px] flex-shrink-0">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
size="sm"
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) => {
e.stopPropagation();
onAccept();
onAccept(editedValue);
}}
>
<Check className="h-3 w-3" />
@@ -92,7 +107,7 @@ export function AiSuggestionBadge({
<Button
size="sm"
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) => {
e.stopPropagation();
onDismiss();
@@ -106,19 +121,46 @@ export function AiSuggestionBadge({
</TooltipContent>
</Tooltip>
</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 */}
{issues.length > 0 && (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<button
type="button"
className="flex-shrink-0 text-purple-400 hover:text-purple-600 transition-colors"
onClick={(e) => e.stopPropagation()}
<Button
variant="ghost"
size="sm"
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" />
</button>
</TooltipTrigger>
</Button>
</TooltipTrigger>
<TooltipContent
side="top"
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"
onClick={(e) => {
e.stopPropagation();
onAccept();
onAccept(suggestion);
}}
>
<Check className="h-3 w-3 mr-1" />

View File

@@ -588,9 +588,9 @@ const CellWrapper = memo(({
// Check if description should be validated
const descIsDismissed = nameSuggestion?.dismissed?.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
setInlineAiValidating(`${contextProductIndex}-description`, true);
@@ -687,7 +687,9 @@ const CellWrapper = memo(({
// Trigger inline AI validation for name/description fields
// This validates spelling, grammar, and naming conventions using Groq
// 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 fields = useValidationStore.getState().fields;
if (currentRow) {
@@ -751,6 +753,66 @@ const CellWrapper = memo(({
}, 0);
}, [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)
const handleFetchOptions = useCallback(async () => {
const state = useValidationStore.getState();
@@ -854,6 +916,7 @@ const CellWrapper = memo(({
onDismissAiSuggestion: () => {
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
},
onRevalidate: handleRevalidate,
})}
/>
</div>
@@ -925,12 +988,18 @@ const CellWrapper = memo(({
<AiSuggestionBadge
suggestion={fieldSuggestion.suggestion!}
issues={fieldSuggestion.issues}
onAccept={() => {
useValidationStore.getState().acceptInlineAiSuggestion(productIndex, 'name');
onAccept={(editedValue) => {
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={() => {
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name');
}}
onRevalidate={handleRevalidate}
isRevalidating={isInlineAiValidating}
compact
/>
</div>

View File

@@ -16,8 +16,9 @@ import {
TooltipProvider,
TooltipTrigger,
} 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 { AiDescriptionCompare } from '@/components/ai/AiDescriptionCompare';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
import { useValidationStore } from '../../store/validationStore';
@@ -50,6 +51,8 @@ interface MultilineInputProps {
isAiValidating?: boolean;
/** Called when user dismisses/clears the AI suggestion (also called after applying) */
onDismissAiSuggestion?: () => void;
/** Called to manually trigger AI re-validation */
onRevalidate?: () => void;
}
const MultilineInputComponent = ({
@@ -63,12 +66,11 @@ const MultilineInputComponent = ({
aiSuggestion,
isAiValidating,
onDismissAiSuggestion,
onRevalidate,
}: MultilineInputProps) => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState('');
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
const [editedSuggestion, setEditedSuggestion] = useState('');
const [popoverWidth, setPopoverWidth] = useState(400);
const [popoverHeight, setPopoverHeight] = useState<number | undefined>(undefined);
const resizeContainerRef = useRef<HTMLDivElement>(null);
@@ -77,12 +79,8 @@ const MultilineInputComponent = ({
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
const intentionalCloseRef = useRef(false);
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
// Tracks the value when popover opened, to detect actual changes
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
const productName = useValidationStore(
@@ -121,13 +119,6 @@ const MultilineInputComponent = ({
}
}, [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
const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => {
if (!textarea) return;
@@ -145,61 +136,25 @@ const MultilineInputComponent = ({
}
}, [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(() => {
if (aiSuggestionExpanded || (popoverOpen && hasAiSuggestion)) {
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; }
if (!popoverOpen || hasAiSuggestion) { setPopoverHeight(undefined); return; }
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
if (!isDesktop) { setPopoverHeight(undefined); return; }
const rafId = requestAnimationFrame(() => {
const main = mainTextareaRef.current;
const suggestion = suggestionTextareaRef.current;
const container = resizeContainerRef.current;
if (!container) return;
// Get textarea natural content heights
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 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);
setPopoverHeight(Math.max(Math.min(naturalHeight, maxHeight), 200));
});
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]);
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
@@ -261,7 +216,6 @@ const MultilineInputComponent = ({
// Immediately close popover
setPopoverOpen(false);
setAiSuggestionExpanded(false);
// Prevent reopening this same cell
preventReopenRef.current = true;
@@ -291,7 +245,6 @@ const MultilineInputComponent = ({
}
setPopoverOpen(false);
setAiSuggestionExpanded(false);
// Signal to other cells that a popover just closed via click-outside
setCellPopoverClosed();
@@ -322,23 +275,19 @@ const MultilineInputComponent = ({
autoResizeTextarea(e.target);
}, [autoResizeTextarea]);
// Handle accepting the AI suggestion (possibly edited)
const handleAcceptSuggestion = useCallback(() => {
// Use the edited suggestion
setEditValue(editedSuggestion);
setLocalDisplayValue(editedSuggestion);
// onBlur handles both cell update and validation
onBlur(editedSuggestion);
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
setAiSuggestionExpanded(false);
// Handle accepting the AI suggestion (possibly edited) via AiDescriptionCompare
const handleAcceptSuggestion = useCallback((text: string) => {
setEditValue(text);
setLocalDisplayValue(text);
onBlur(text);
onDismissAiSuggestion?.();
intentionalCloseRef.current = true;
setPopoverOpen(false);
}, [editedSuggestion, onBlur, onDismissAiSuggestion]);
}, [onBlur, onDismissAiSuggestion]);
// Handle dismissing the AI suggestion
// Handle dismissing the AI suggestion via AiDescriptionCompare
const handleDismissSuggestion = useCallback(() => {
onDismissAiSuggestion?.();
setAiSuggestionExpanded(false);
intentionalCloseRef.current = true;
setPopoverOpen(false);
}, [onDismissAiSuggestion]);
@@ -380,7 +329,6 @@ const MultilineInputComponent = ({
return;
}
updatePopoverWidth();
setAiSuggestionExpanded(true);
setPopoverOpen(true);
// Initialize edit value and track it for change detection
const initValue = localDisplayValue || String(value ?? '');
@@ -436,7 +384,12 @@ const MultilineInputComponent = ({
>
<div
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}
>
{/* Close button */}
@@ -449,116 +402,29 @@ const MultilineInputComponent = ({
<X className="h-3 w-3" />
</Button>
{/* Main textarea */}
<div data-col="left" className={cn("flex flex-col min-h-0 w-full", hasAiSuggestion && "lg:w-1/2")}>
<div className={cn(hasAiSuggestion ? 'px-3 py-2 bg-accent' : '', 'flex flex-col flex-1 min-h-0')}>
{/* Product name - shown inline on mobile, in measured spacer on desktop */}
{hasAiSuggestion && 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 line-clamp-1">{productName}</div>
</div>
)}
{hasAiSuggestion && 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 line-clamp-1 px-1">{productName}</div>
</div>
)}
</div>
)}
{hasAiSuggestion && <div className="text-sm mb-1 font-medium flex items-center gap-2 flex-shrink-0">
Current Description:
</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>
{hasAiSuggestion ? (
<AiDescriptionCompare
currentValue={editValue}
onCurrentChange={setEditValue}
suggestion={aiSuggestion.suggestion!}
issues={aiIssues}
productName={productName}
onAccept={handleAcceptSuggestion}
onDismiss={handleDismissSuggestion}
onRevalidate={onRevalidate}
isRevalidating={isAiValidating}
/>
) : (
<div data-col="left" className="flex flex-col min-h-0 w-full">
<Textarea
ref={mainTextareaRef}
value={editValue}
onChange={handleChange}
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"
placeholder={`Enter ${field.label || 'text'}...`}
autoFocus
/>
</div>
)}
</div>

View File

@@ -108,9 +108,9 @@ export function useAutoInlineAiValidation() {
typeof row.name === 'string' &&
row.name.trim();
// Check description context: company + line + name (description can be empty)
// We want to validate descriptions even when empty so AI can suggest one
const hasDescContext = hasNameContext;
// Check description context: company + line + name + description with ≥10 chars
const descriptionValue = typeof row.description === 'string' ? row.description.trim() : '';
const hasDescContext = hasNameContext && descriptionValue.length >= 10;
// Skip if already auto-validated (shouldn't happen on first run, but be safe)
const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`);

View File

@@ -32,6 +32,7 @@ import type {
InlineAiValidationResult,
} from './types';
import type { Field, SelectOption } from '../../../types';
import { stripPriceFormatting } from '../utils/priceUtils';
// =============================================================================
// Initial State
@@ -165,11 +166,24 @@ export const useValidationStore = create<ValidationStore>()(
// Apply fresh state first (clean slate)
Object.assign(state, freshState);
// Then set up with new data
state.rows = data.map((row) => ({
...row,
__index: row.__index || uuidv4(),
}));
// Identify price fields to clean on ingestion (strips $, commas, whitespace)
const priceFieldKeys = fields
.filter((f) => f.fieldType.type === 'input' && 'price' in f.fieldType && f.fieldType.price)
.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));
// Cast to bypass immer's strict readonly type checking
state.fields = fields as unknown as typeof state.fields;

View File

@@ -2,10 +2,84 @@
* 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
*
* - Removes dollar signs ($) and commas (,)
* - Removes currency symbols and whitespace
* - Normalizes European decimal commas
* - Converts to number and formats with 2 decimal places
* - Returns original value if conversion fails
*
@@ -14,13 +88,14 @@
*
* @example
* cleanPriceField("$1,234.56") // "1234.56"
* cleanPriceField("$99.9") // "99.90"
* cleanPriceField(123.456) // "123.46"
* cleanPriceField("invalid") // "invalid"
* cleanPriceField(" $ 99.9") // "99.90"
* cleanPriceField("1,50") // "1.50"
* cleanPriceField(123.456) // "123.46"
* cleanPriceField("invalid") // "invalid"
*/
export function cleanPriceField(value: string | number): string {
if (typeof value === "string") {
const cleaned = value.replace(/[$,]/g, "");
const cleaned = stripPriceFormatting(value);
const numValue = parseFloat(cleaned);
if (!isNaN(numValue)) {
return numValue.toFixed(2);
@@ -59,4 +134,4 @@ export function cleanPriceFields<T extends Record<string, any>>(
}
return cleaned;
}
}

View File

@@ -20,7 +20,7 @@
*/
export function cleanPriceField(value: string | number): string {
if (typeof value === "string") {
const cleaned = value.replace(/[$,]/g, "");
const cleaned = value.replace(/[\s$,]/g, "");
const numValue = parseFloat(cleaned);
if (!isNaN(numValue)) {
return numValue.toFixed(2);

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

View File

@@ -113,6 +113,11 @@ export default function ProductEditor() {
.finally(() => setIsLoadingSublines(false));
}, [lineLine]);
const loadedPids = useMemo(
() => new Set(allProducts.map((p) => Number(p.pid))),
[allProducts]
);
const handleSearchSelect = useCallback((product: SearchProduct) => {
setAllProducts((prev) => {
if (prev.some((p) => p.pid === product.pid)) return prev;
@@ -121,6 +126,39 @@ export default function ProductEditor() {
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) => {
setAllProducts((prev) => prev.filter((p) => p.pid !== pid));
}, []);
@@ -195,6 +233,10 @@ export default function ProductEditor() {
} 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]);
@@ -374,7 +416,12 @@ export default function ProductEditor() {
</TabsList>
<TabsContent value="search" className="mt-4">
<ProductSearch onSelect={handleSearchSelect} />
<ProductSearch
onSelect={handleSearchSelect}
onLoadAll={handleLoadAllSearch}
onNewSearch={handleNewSearch}
loadedPids={loadedPids}
/>
</TabsContent>
<TabsContent value="new" className="mt-4">

View File

@@ -1,15 +1,21 @@
export interface ImageChanges {
order: (number | string)[];
hidden: number[];
deleted: number[];
added: Record<string, string>; // e.g. { "new-0": "https://..." }
show: number[];
delete: number[];
add: Record<string, string>; // e.g. { "new-0": "https://..." }
}
export interface SubmitProductEditArgs {
pid: number;
changes: Record<string, unknown>;
environment: "dev" | "prod";
imageChanges?: ImageChanges;
}
export interface SubmitImageChangesArgs {
pid: number;
imageChanges: ImageChanges;
environment: "dev" | "prod";
}
export interface SubmitProductEditResponse {
@@ -31,14 +37,10 @@ export async function submitProductEdit({
pid,
changes,
environment,
imageChanges,
}: SubmitProductEditArgs): Promise<SubmitProductEditResponse> {
const targetUrl = environment === "dev" ? DEV_ENDPOINT : PROD_ENDPOINT;
const product: Record<string, unknown> = { pid, ...changes };
if (imageChanges) {
product.image_changes = imageChanges;
}
const payload = new URLSearchParams();
payload.append("products", JSON.stringify([product]));
@@ -96,3 +98,138 @@ export async function submitProductEdit({
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