Compare commits
5 Commits
45ded53530
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c344fdc3b8 | |||
| ebef903f3b | |||
| 16d2399de8 | |||
| c3e09d5fd1 | |||
| bae8c575bc |
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1145,9 +1145,10 @@ router.get('/search-products', async (req, res) => {
|
||||
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,
|
||||
@@ -1266,9 +1267,10 @@ const PRODUCT_SELECT = `
|
||||
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,
|
||||
|
||||
234
inventory/src/components/ai/AiDescriptionCompare.tsx
Normal file
234
inventory/src/components/ai/AiDescriptionCompare.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 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 } 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;
|
||||
productName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AiDescriptionCompare({
|
||||
currentValue,
|
||||
onCurrentChange,
|
||||
suggestion,
|
||||
issues,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -7,7 +7,11 @@ 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 { 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, type ImageChanges } from "@/services/productEditor";
|
||||
import { EditableComboboxField } from "./EditableComboboxField";
|
||||
import { EditableInput } from "./EditableInput";
|
||||
@@ -207,6 +211,8 @@ export function ProductEditForm({
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { dirtyFields },
|
||||
} = useForm<ProductFormValues>();
|
||||
|
||||
@@ -411,6 +417,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()) 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;
|
||||
|
||||
@@ -483,9 +545,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">
|
||||
<div className="flex items-center justify-between relative">
|
||||
<span className="text-muted-foreground text-xs shrink-0">{fc.label}</span>
|
||||
{isDescription && (
|
||||
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 +594,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 +607,40 @@ 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={() => {
|
||||
setValue("name", nameResult.suggestion!, { shouldDirty: true });
|
||||
clearNameResult();
|
||||
}}
|
||||
onDismiss={clearNameResult}
|
||||
compact
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -611,6 +737,36 @@ 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);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -67,8 +68,6 @@ const MultilineInputComponent = ({
|
||||
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 +76,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 +116,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 +133,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 +213,6 @@ const MultilineInputComponent = ({
|
||||
|
||||
// Immediately close popover
|
||||
setPopoverOpen(false);
|
||||
setAiSuggestionExpanded(false);
|
||||
|
||||
// Prevent reopening this same cell
|
||||
preventReopenRef.current = true;
|
||||
@@ -291,7 +242,6 @@ const MultilineInputComponent = ({
|
||||
}
|
||||
|
||||
setPopoverOpen(false);
|
||||
setAiSuggestionExpanded(false);
|
||||
|
||||
// Signal to other cells that a popover just closed via click-outside
|
||||
setCellPopoverClosed();
|
||||
@@ -322,23 +272,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 +326,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 +381,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 +399,27 @@ 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 */}
|
||||
|
||||
{hasAiSuggestion ? (
|
||||
<AiDescriptionCompare
|
||||
currentValue={editValue}
|
||||
onCurrentChange={setEditValue}
|
||||
suggestion={aiSuggestion.suggestion!}
|
||||
issues={aiIssues}
|
||||
productName={productName}
|
||||
onAccept={handleAcceptSuggestion}
|
||||
onDismiss={handleDismissSuggestion}
|
||||
/>
|
||||
) : (
|
||||
<div data-col="left" className="flex flex-col min-h-0 w-full">
|
||||
<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")}
|
||||
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
|
||||
/>
|
||||
{hasAiSuggestion && <div className="h-[43px] flex-shrink-0 hidden lg:block" />}
|
||||
</div></div>
|
||||
{/* AI Suggestion section */}
|
||||
{hasAiSuggestion && (
|
||||
<div data-col="right" className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
|
||||
{/* Measured header + issues area (mirrored as spacer on the left) */}
|
||||
<div ref={aiHeaderRef} className="flex-shrink-0">
|
||||
{/* Header */}
|
||||
<div className="w-full flex items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
AI Suggestion
|
||||
</span>
|
||||
<span className="text-xs text-purple-500 dark:text-purple-400">
|
||||
({aiIssues.length} {aiIssues.length === 1 ? 'issue' : 'issues'})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issues list */}
|
||||
{aiIssues.length > 0 && (
|
||||
<div className="flex flex-col gap-1 px-3 pb-3">
|
||||
{aiIssues.map((issue, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||
<span>{issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
|
||||
{/* Editable suggestion */}
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="text-sm text-purple-500 dark:text-purple-400 mb-1 font-medium flex-shrink-0">
|
||||
Suggested (editable):
|
||||
</div>
|
||||
<Textarea
|
||||
ref={suggestionTextareaRef}
|
||||
value={editedSuggestion}
|
||||
onChange={(e) => {
|
||||
setEditedSuggestion(e.target.value);
|
||||
autoResizeTextarea(e.target);
|
||||
}}
|
||||
onWheel={handleTextareaWheel}
|
||||
className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y lg:resize-none lg:flex-1 min-h-[120px] lg:min-h-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||
onClick={handleAcceptSuggestion}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Replace With Suggestion
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||
onClick={handleDismissSuggestion}
|
||||
>
|
||||
Ignore
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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) => ({
|
||||
// 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;
|
||||
|
||||
@@ -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(" $ 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user