);
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx
index 2ae9e35..2cd52ad 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx
@@ -93,7 +93,7 @@ const getCellComponent = (field: Field, optionCount: number = 0) => {
/**
* Row height for virtualization
*/
-const ROW_HEIGHT = 40;
+const ROW_HEIGHT = 80; // Taller rows to show 2 lines of description + space for AI badges
const HEADER_HEIGHT = 40;
// Stable empty references to avoid creating new objects in selectors
@@ -176,6 +176,19 @@ const CellWrapper = memo(({
const isDismissed = isInlineAiField ? inlineAiSuggestion?.dismissed?.[field.key as 'name' | 'description'] : false;
const showSuggestion = fieldSuggestion && !fieldSuggestion.isValid && fieldSuggestion.suggestion && !isDismissed;
+ // Debug: Log when we have a suggestion for name field
+ if (isInlineAiField && field.key === 'name' && inlineAiSuggestion) {
+ console.log('[CellWrapper] Name field suggestion:', {
+ productIndex,
+ inlineAiSuggestion,
+ fieldSuggestion,
+ isDismissed,
+ showSuggestion,
+ isValid: fieldSuggestion?.isValid,
+ suggestion: fieldSuggestion?.suggestion
+ });
+ }
+
// Check if cell has a value (for showing copy-down button)
const hasValue = value !== undefined && value !== null && value !== '';
@@ -289,8 +302,12 @@ const CellWrapper = memo(({
// Stable callback for onBlur - validates field and triggers UPC validation if needed
// Uses setTimeout(0) to defer validation AFTER browser paint
const handleBlur = useCallback((newValue: unknown) => {
- const { updateCell } = useValidationStore.getState();
-
+ const state = useValidationStore.getState();
+ const { updateCell } = state;
+
+ // Capture previous value BEFORE updating - needed to detect actual changes
+ const previousValue = state.rows[rowIndex]?.[field.key];
+
let valueToSave = newValue;
// Auto-correct UPC check digit if this is the UPC field
@@ -301,7 +318,10 @@ const CellWrapper = memo(({
// We'll use the corrected value
}
}
-
+
+ // Check if the value actually changed (for AI validation trigger)
+ const valueChanged = String(valueToSave ?? '') !== String(previousValue ?? '');
+
updateCell(rowIndex, field.key, valueToSave);
// Defer validation to after the browser paints
@@ -534,7 +554,8 @@ const CellWrapper = memo(({
// Trigger inline AI validation for name/description fields
// This validates spelling, grammar, and naming conventions using Groq
- if (isInlineAiField && valueToSave && String(valueToSave).trim()) {
+ // Only trigger if value actually changed to avoid unnecessary API calls
+ if (isInlineAiField && valueChanged && valueToSave && String(valueToSave).trim()) {
const currentRow = useValidationStore.getState().rows[rowIndex];
const fields = useValidationStore.getState().fields;
if (currentRow) {
@@ -554,6 +575,33 @@ const CellWrapper = memo(({
return undefined;
};
+ // Compute sibling products (same company + line + subline if set) for naming context
+ const rows = useValidationStore.getState().rows;
+ const siblingNames: string[] = [];
+
+ if (currentRow.company && currentRow.line) {
+ const companyId = String(currentRow.company);
+ const lineId = String(currentRow.line);
+ const sublineId = currentRow.subline ? String(currentRow.subline) : null;
+
+ for (const row of rows) {
+ // Skip self
+ if (row.__index === productIndex) continue;
+
+ // Must match company and line
+ if (String(row.company) !== companyId) continue;
+ if (String(row.line) !== lineId) continue;
+
+ // If current product has subline, siblings must match subline too
+ if (sublineId && String(row.subline) !== sublineId) continue;
+
+ // Add name if it exists
+ if (row.name && typeof row.name === 'string' && row.name.trim()) {
+ siblingNames.push(row.name);
+ }
+ }
+ }
+
// Build product payload for API
const productPayload = {
name: fieldKey === 'name' ? String(valueToSave) : (currentRow.name as string),
@@ -561,7 +609,11 @@ const CellWrapper = memo(({
company_name: currentRow.company ? getFieldLabel('company', currentRow.company) : undefined,
company_id: currentRow.company ? String(currentRow.company) : undefined,
line_name: currentRow.line ? getFieldLabel('line', currentRow.line) : undefined,
+ line_id: currentRow.line ? String(currentRow.line) : undefined,
+ subline_name: currentRow.subline ? getFieldLabel('subline', currentRow.subline) : undefined,
+ subline_id: currentRow.subline ? String(currentRow.subline) : undefined,
categories: currentRow.categories as string | undefined,
+ siblingNames: siblingNames.length > 0 ? siblingNames : undefined,
};
// Call the appropriate API endpoint
@@ -691,6 +743,14 @@ const CellWrapper = memo(({
onBlur={handleBlur}
onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined}
isLoadingOptions={isLoadingOptions}
+ // Pass AI suggestion props for description field (MultilineInput handles it internally)
+ {...(field.key === 'description' && {
+ aiSuggestion: fieldSuggestion,
+ isAiValidating: isInlineAiValidating,
+ onDismissAiSuggestion: () => {
+ useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
+ },
+ })}
/>
@@ -748,24 +808,24 @@ const CellWrapper = memo(({
)}
- {/* Inline AI validation spinner */}
- {isInlineAiValidating && isInlineAiField && (
+ {/* Inline AI validation spinner - only for name field (description handles it internally) */}
+ {isInlineAiValidating && field.key === 'name' && (
)}
- {/* AI Suggestion badge - shows when AI has a suggestion for this field */}
- {showSuggestion && fieldSuggestion && (
+ {/* AI Suggestion badge - only for name field (description handles it inside its popover) */}
+ {showSuggestion && fieldSuggestion && field.key === 'name' && (
{
- useValidationStore.getState().acceptInlineAiSuggestion(productIndex, field.key as 'name' | 'description');
+ useValidationStore.getState().acceptInlineAiSuggestion(productIndex, 'name');
}}
onDismiss={() => {
- useValidationStore.getState().dismissInlineAiSuggestion(productIndex, field.key as 'name' | 'description');
+ useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name');
}}
compact
/>
@@ -881,6 +941,113 @@ const TemplateCell = memo(({ rowIndex, currentTemplateId, defaultBrand }: Templa
toast.success('Template applied');
+ // Trigger inline AI validation for name/description if template set those fields
+ const productIndex = currentRow?.__index;
+ if (productIndex) {
+ const { setInlineAiValidating, setInlineAiSuggestion } = state;
+
+ // Helper to look up field option label
+ const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => {
+ const fieldDef = fields.find(f => f.key === fieldKey);
+ if (fieldDef && fieldDef.fieldType.type === 'select' && 'options' in fieldDef.fieldType) {
+ const option = fieldDef.fieldType.options?.find(o => o.value === String(val));
+ return option?.label;
+ }
+ return undefined;
+ };
+
+ // Get the updated row data (after template applied)
+ const updatedRow = { ...currentRow, ...updates };
+
+ // Compute sibling names for context
+ const rows = state.rows;
+ const siblingNames: string[] = [];
+ if (updatedRow.company && updatedRow.line) {
+ const companyId = String(updatedRow.company);
+ const lineId = String(updatedRow.line);
+ const sublineId = updatedRow.subline ? String(updatedRow.subline) : null;
+
+ for (const row of rows) {
+ if (row.__index === productIndex) continue;
+ if (String(row.company) !== companyId) continue;
+ if (String(row.line) !== lineId) continue;
+ if (sublineId && String(row.subline) !== sublineId) continue;
+ if (row.name && typeof row.name === 'string' && row.name.trim()) {
+ siblingNames.push(row.name);
+ }
+ }
+ }
+
+ // Trigger name validation if template set name
+ if (templateFieldsSet.has('name') && updates.name && String(updates.name).trim()) {
+ setInlineAiValidating(`${productIndex}-name`, true);
+
+ const productPayload = {
+ name: String(updates.name),
+ description: updatedRow.description as string,
+ company_name: updatedRow.company ? getFieldLabel('company', updatedRow.company) : undefined,
+ company_id: updatedRow.company ? String(updatedRow.company) : undefined,
+ line_name: updatedRow.line ? getFieldLabel('line', updatedRow.line) : undefined,
+ line_id: updatedRow.line ? String(updatedRow.line) : undefined,
+ subline_name: updatedRow.subline ? getFieldLabel('subline', updatedRow.subline) : undefined,
+ subline_id: updatedRow.subline ? String(updatedRow.subline) : undefined,
+ categories: updatedRow.categories as string | undefined,
+ siblingNames: siblingNames.length > 0 ? siblingNames : undefined,
+ };
+
+ fetch('/api/ai/validate/inline/name', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ product: productPayload }),
+ })
+ .then(res => res.json())
+ .then(result => {
+ if (result.success !== false) {
+ setInlineAiSuggestion(productIndex, 'name', {
+ isValid: result.isValid ?? true,
+ suggestion: result.suggestion,
+ issues: result.issues || [],
+ latencyMs: result.latencyMs,
+ });
+ }
+ })
+ .catch(err => console.error('[InlineAI] name validation error:', err))
+ .finally(() => setInlineAiValidating(`${productIndex}-name`, false));
+ }
+
+ // Trigger description validation if template set description
+ if (templateFieldsSet.has('description') && updates.description && String(updates.description).trim()) {
+ setInlineAiValidating(`${productIndex}-description`, true);
+
+ const productPayload = {
+ name: updatedRow.name as string,
+ description: String(updates.description),
+ company_name: updatedRow.company ? getFieldLabel('company', updatedRow.company) : undefined,
+ company_id: updatedRow.company ? String(updatedRow.company) : undefined,
+ categories: updatedRow.categories as string | undefined,
+ };
+
+ fetch('/api/ai/validate/inline/description', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ product: productPayload }),
+ })
+ .then(res => res.json())
+ .then(result => {
+ if (result.success !== false) {
+ setInlineAiSuggestion(productIndex, 'description', {
+ isValid: result.isValid ?? true,
+ suggestion: result.suggestion,
+ issues: result.issues || [],
+ latencyMs: result.latencyMs,
+ });
+ }
+ })
+ .catch(err => console.error('[InlineAI] description validation error:', err))
+ .finally(() => setInlineAiValidating(`${productIndex}-description`, false));
+ }
+ }
+
// Trigger UPC validation if template set supplier or upc, and we have both values
const finalSupplier = updates.supplier ?? currentRow?.supplier;
const finalUpc = updates.upc ?? currentRow?.upc;
@@ -986,6 +1153,8 @@ interface VirtualRowProps {
columns: ColumnDef[];
fields: Field[];
totalRowCount: number;
+ /** Whether table is scrolled horizontally - used for sticky column shadow */
+ isScrolledHorizontally: boolean;
}
const VirtualRow = memo(({
@@ -995,6 +1164,7 @@ const VirtualRow = memo(({
columns,
fields,
totalRowCount,
+ isScrolledHorizontally,
}: VirtualRowProps) => {
// Subscribe to row data - this is THE subscription for all cell values in this row
const rowData = useValidationStore(
@@ -1044,7 +1214,14 @@ const VirtualRow = memo(({
// Subscribe to inline AI suggestions for this row (for name/description validation)
const inlineAiSuggestion = useValidationStore(
- useCallback((state) => state.inlineAi.suggestions.get(rowId), [rowId])
+ useCallback((state) => {
+ const suggestion = state.inlineAi.suggestions.get(rowId);
+ // Debug: Log when subscription returns a value
+ if (suggestion) {
+ console.log('[VirtualRow] Got suggestion for rowId:', rowId, suggestion);
+ }
+ return suggestion;
+ }, [rowId])
);
// Check if inline AI validation is running for this row
@@ -1065,6 +1242,13 @@ const VirtualRow = memo(({
const hasErrors = Object.keys(rowErrors).length > 0;
+ // Check if this row has a visible AI suggestion badge (needs higher z-index to show above next row)
+ // Only name field shows a floating badge - description handles AI suggestions inside its popover
+ const hasVisibleAiSuggestion = inlineAiSuggestion?.name &&
+ !inlineAiSuggestion.name.isValid &&
+ inlineAiSuggestion.name.suggestion &&
+ !inlineAiSuggestion.dismissed?.name;
+
// Handle mouse enter for copy-down target selection
const handleMouseEnter = useCallback(() => {
if (copyDownMode.isActive && copyDownMode.sourceRowIndex !== null && rowIndex > copyDownMode.sourceRowIndex) {
@@ -1083,12 +1267,14 @@ const VirtualRow = memo(({
style={{
height: ROW_HEIGHT,
transform: `translateY(${virtualStart}px)`,
+ // Elevate row when it has a visible AI suggestion so badge shows above next row
+ zIndex: hasVisibleAiSuggestion ? 10 : undefined,
}}
onMouseEnter={handleMouseEnter}
>
{/* Selection checkbox cell */}
{
const tableContainerRef = useRef(null);
const headerRef = useRef(null);
- // Sync header scroll with body scroll
+ // Calculate name column's natural left position (before it becomes sticky)
+ // Selection (40) + Template (200) + all field columns before 'name'
+ const nameColumnLeftOffset = useMemo(() => {
+ let offset = 40 + TEMPLATE_COLUMN_WIDTH; // Selection + Template columns
+ for (const field of fields) {
+ if (field.key === 'name') break;
+ offset += field.width || 150;
+ }
+ return offset;
+ }, [fields]);
+
+ // Track horizontal scroll for sticky column shadow
+ const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false);
+
+ // Sync header scroll with body scroll + track horizontal scroll state
const handleScroll = useCallback(() => {
if (tableContainerRef.current && headerRef.current) {
- headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft;
+ const scrollLeft = tableContainerRef.current.scrollLeft;
+ headerRef.current.scrollLeft = scrollLeft;
+ // Only show shadow when scrolled past the name column's natural position
+ setIsScrolledHorizontally(scrollLeft > nameColumnLeftOffset);
}
- }, []);
+ }, [nameColumnLeftOffset]);
// Compute filtered indices AND row IDs in a single pass
// This avoids calling getState() during render for each row
@@ -1373,7 +1590,9 @@ export const ValidationTable = () => {
key={column.id || index}
className={cn(
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0",
- isNameColumn && "lg:sticky lg:z-20 lg:bg-muted lg:shadow-md"
+ // Sticky header needs solid background matching the row's bg-muted/50 appearance
+ isNameColumn && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
+ isNameColumn && isScrolledHorizontally && "lg:shadow-md",
)}
style={{
width: column.size || 150,
@@ -1416,6 +1635,7 @@ export const ValidationTable = () => {
columns={columns}
fields={fields}
totalRowCount={rowCount}
+ isScrolledHorizontally={isScrolledHorizontally}
/>
);
})}
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx
index 5950c6b..a266dfa 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx
@@ -2,6 +2,7 @@
* MultilineInput Component
*
* Expandable textarea cell for long text content.
+ * Includes AI suggestion display when available.
* Memoized to prevent unnecessary re-renders when parent table updates.
*/
@@ -15,21 +16,36 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
-import { X, Loader2 } from 'lucide-react';
+import { X, Loader2, Sparkles, AlertCircle, Check, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
+/** AI suggestion data for a single field */
+interface AiFieldSuggestion {
+ isValid: boolean;
+ suggestion?: string | null;
+ issues?: string[];
+}
+
interface MultilineInputProps {
value: unknown;
field: Field;
options?: SelectOption[];
rowIndex: number;
+ productIndex: string;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => void;
+ isLoadingOptions?: boolean;
+ /** AI suggestion for this field */
+ aiSuggestion?: AiFieldSuggestion | null;
+ /** Whether AI is currently validating */
+ isAiValidating?: boolean;
+ /** Called when user dismisses/clears the AI suggestion (also called after applying) */
+ onDismissAiSuggestion?: () => void;
}
const MultilineInputComponent = ({
@@ -39,16 +55,38 @@ const MultilineInputComponent = ({
errors,
onChange,
onBlur,
+ aiSuggestion,
+ isAiValidating,
+ onDismissAiSuggestion,
}: MultilineInputProps) => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState('');
const [localDisplayValue, setLocalDisplayValue] = useState(null);
+ const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
+ const [editedSuggestion, setEditedSuggestion] = useState('');
const cellRef = useRef(null);
const preventReopenRef = useRef(false);
const hasError = errors.length > 0;
const errorMessage = errors[0]?.message;
+ // Check if we have a displayable AI suggestion
+ const hasAiSuggestion = aiSuggestion && !aiSuggestion.isValid && aiSuggestion.suggestion;
+ const aiIssues = aiSuggestion?.issues || [];
+
+ // Handle wheel scroll in textarea - stop propagation to prevent table scroll
+ const handleTextareaWheel = useCallback((e: React.WheelEvent) => {
+ const target = e.currentTarget;
+ const { scrollTop, scrollHeight, clientHeight } = target;
+ const atTop = scrollTop === 0;
+ const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
+
+ // Only stop propagation if we can scroll in the direction of the wheel
+ if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) {
+ e.stopPropagation();
+ }
+ }, []);
+
// Initialize localDisplayValue on mount and when value changes externally
useEffect(() => {
const strValue = String(value ?? '');
@@ -57,6 +95,13 @@ const MultilineInputComponent = ({
}
}, [value, localDisplayValue]);
+ // Initialize edited suggestion when AI suggestion changes
+ useEffect(() => {
+ if (aiSuggestion?.suggestion) {
+ setEditedSuggestion(aiSuggestion.suggestion);
+ }
+ }, [aiSuggestion?.suggestion]);
+
// Handle trigger click to toggle the popover
const handleTriggerClick = useCallback(
(e: React.MouseEvent) => {
@@ -91,6 +136,7 @@ const MultilineInputComponent = ({
// Immediately close popover
setPopoverOpen(false);
+ setAiSuggestionExpanded(false);
// Prevent reopening
preventReopenRef.current = true;
@@ -117,6 +163,23 @@ const MultilineInputComponent = ({
setEditValue(e.target.value);
}, []);
+ // Handle accepting the AI suggestion (possibly edited)
+ const handleAcceptSuggestion = useCallback(() => {
+ // Use the edited suggestion
+ setEditValue(editedSuggestion);
+ setLocalDisplayValue(editedSuggestion);
+ onChange(editedSuggestion);
+ onBlur(editedSuggestion);
+ onDismissAiSuggestion?.(); // Clear the suggestion after accepting
+ setAiSuggestionExpanded(false);
+ }, [editedSuggestion, onChange, onBlur, onDismissAiSuggestion]);
+
+ // Handle dismissing the AI suggestion
+ const handleDismissSuggestion = useCallback(() => {
+ onDismissAiSuggestion?.();
+ setAiSuggestionExpanded(false);
+ }, [onDismissAiSuggestion]);
+
// Calculate display value
const displayValue = localDisplayValue !== null ? localDisplayValue : String(value ?? '');
@@ -134,14 +197,38 @@ const MultilineInputComponent = ({
{displayValue}
+ {/* AI suggestion indicator - small badge in corner, clickable to open with AI expanded */}
+ {hasAiSuggestion && !popoverOpen && (
+ {
+ e.stopPropagation();
+ setAiSuggestionExpanded(true);
+ setPopoverOpen(true);
+ setEditValue(localDisplayValue || String(value ?? ''));
+ }}
+ className="absolute bottom-1 right-1 flex items-center gap-1 px-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"
+ title="View AI suggestion"
+ >
+
+ {aiIssues.length}
+
+ )}
+ {/* AI validating indicator */}
+ {isAiValidating && (
+
diff --git a/inventory/src/components/product-import/steps/ValidationStep/dialogs/SanityCheckDialog.tsx b/inventory/src/components/product-import/steps/ValidationStep/dialogs/SanityCheckDialog.tsx
index d4f14c9..b6bdd32 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/dialogs/SanityCheckDialog.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/dialogs/SanityCheckDialog.tsx
@@ -11,7 +11,8 @@ import {
Loader2,
AlertTriangle,
ChevronRight,
- XCircle
+ XCircle,
+ RefreshCw
} from 'lucide-react';
import {
Dialog,
@@ -41,10 +42,14 @@ interface SanityCheckDialogProps {
onProceed: () => void;
/** Called when user wants to go back and fix issues */
onGoBack: () => void;
+ /** Called to refresh/re-run the sanity check */
+ onRefresh?: () => void;
/** Called to scroll to a specific product */
onScrollToProduct?: (productIndex: number) => void;
/** Product names for display (indexed by product index) */
productNames?: Record;
+ /** Number of validation errors (required fields, etc.) */
+ validationErrorCount?: number;
}
export function SanityCheckDialog({
@@ -55,11 +60,15 @@ export function SanityCheckDialog({
result,
onProceed,
onGoBack,
+ onRefresh,
onScrollToProduct,
- productNames = {}
+ productNames = {},
+ validationErrorCount = 0
}: SanityCheckDialogProps) {
- const hasIssues = result?.issues && result.issues.length > 0;
- const passed = !isChecking && !error && !hasIssues && result;
+ const hasSanityIssues = result?.issues && result.issues.length > 0;
+ const hasValidationErrors = validationErrorCount > 0;
+ const hasAnyIssues = hasSanityIssues || hasValidationErrors;
+ const allClear = !isChecking && !error && !hasSanityIssues && result;
// Group issues by severity/field for better organization
const issuesByField = result?.issues?.reduce((acc, issue) => {
@@ -75,41 +84,66 @@ export function SanityCheckDialog({
)}
- {/* Success state */}
- {passed && !isChecking && (
+ {/* Success state - only show if no validation errors either */}
+ {allClear && !hasValidationErrors && !isChecking && (
@@ -149,8 +183,22 @@ export function SanityCheckDialog({
+ There {validationErrorCount === 1 ? 'is' : 'are'} {validationErrorCount} validation error{validationErrorCount === 1 ? '' : 's'} (required fields, invalid values, etc.) in your data.
+ These should be fixed before continuing.
+