diff --git a/inventory/src/components/product-import/config.ts b/inventory/src/components/product-import/config.ts
index 21e668f..a99e7f8 100644
--- a/inventory/src/components/product-import/config.ts
+++ b/inventory/src/components/product-import/config.ts
@@ -121,7 +121,7 @@ export const BASE_IMPORT_FIELDS = [
type: "input",
price: true
},
- width: 100,
+ width: 110,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
@@ -148,7 +148,7 @@ export const BASE_IMPORT_FIELDS = [
type: "input",
price: true
},
- width: 110,
+ width: 120,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx
index 7424dc0..342c587 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx
@@ -9,7 +9,7 @@ import { useContext } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
-import { CheckCircle, Loader2, Bug, Eye, RefreshCw, ChevronRight } from 'lucide-react';
+import { CheckCircle, Loader2, Eye, RefreshCw } from 'lucide-react';
import {
Tooltip,
TooltipContent,
@@ -83,52 +83,38 @@ export const ValidationFooter = ({
{/* Action buttons */}
{/* Skip sanity check toggle - only for admin:debug users */}
- {hasDebugPermission && onSkipSanityCheckChange && (
+ {hasDebugPermission && onSkipSanityCheckChange && !hasRunSanityCheck && (
-
+
-
- Skip
+ Skip Consistency Check
- Debug: Skip sanity check
+ Debug: Skip consistency check
)}
- {/* Before first sanity check: single "Continue" button that runs the check */}
+ {/* Before first sanity check: single "Next" button that runs the check */}
{!hasRunSanityCheck && !skipSanityCheck && (
- {isSanityChecking ? (
- <>
-
- Checking...
- >
- ) : (
- 'Continue'
- )}
+ Next
)}
@@ -141,16 +127,15 @@ export const ValidationFooter = ({
- Results
+ Review Check Results
- View previous sanity check results
+ Review previous consistency check results
@@ -161,7 +146,6 @@ export const ValidationFooter = ({
@@ -170,11 +154,11 @@ export const ValidationFooter = ({
) : (
)}
- {isSanityChecking ? 'Checking...' : 'Recheck'}
+ {isSanityChecking ? 'Checking...' : 'Check Again'}
- Run a fresh sanity check
+ Run a fresh consistency check
@@ -189,8 +173,7 @@ export const ValidationFooter = ({
: 'Continue to image upload'
}
>
- Continue
-
+ Next
>
)}
@@ -202,8 +185,7 @@ export const ValidationFooter = ({
disabled={rowCount === 0}
title="Continue to image upload (sanity check skipped)"
>
- Continue
-
+ Next
)}
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 2cd52ad..41141d4 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx
@@ -16,7 +16,7 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Checkbox } from '@/components/ui/checkbox';
-import { ArrowDown, Wand2, Loader2 } from 'lucide-react';
+import { ArrowDown, Wand2, Loader2, Calculator } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -258,9 +258,61 @@ const CellWrapper = memo(({
throw new Error(payload?.error || 'Unexpected response while generating UPC');
}
- // Update the cell value
- useValidationStore.getState().updateCell(rowIndex, 'upc', payload.upc);
+ // Update the cell value and clear any validation errors
+ const { updateCell, clearFieldError, setUpcStatus, setGeneratedItemNumber,
+ cacheUpcResult, getCachedItemNumber, startValidatingCell, stopValidatingCell,
+ setError } = useValidationStore.getState();
+ updateCell(rowIndex, 'upc', payload.upc);
+ clearFieldError(rowIndex, 'upc');
toast.success('UPC generated');
+
+ // Trigger UPC validation to generate item number
+ // We have both supplier and the newly generated UPC, so validate immediately
+ const upc = payload.upc;
+
+ // Check cache first
+ const cached = getCachedItemNumber(supplierIdString, upc);
+ if (cached) {
+ setGeneratedItemNumber(rowIndex, cached);
+ } else {
+ // Start validation
+ setUpcStatus(rowIndex, 'validating');
+ startValidatingCell(rowIndex, 'item_number');
+
+ try {
+ const validationResponse = await fetch(
+ `${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upc)}&supplierId=${encodeURIComponent(supplierIdString)}`
+ );
+
+ const validationPayload = await validationResponse.json().catch(() => null);
+
+ if (validationResponse.status === 409) {
+ // UPC already exists (shouldn't happen with newly generated UPC, but handle it)
+ setError(rowIndex, 'upc', {
+ message: 'A product with this UPC already exists',
+ level: 'error',
+ source: ErrorSource.Upc,
+ type: ErrorType.Unique,
+ });
+ setUpcStatus(rowIndex, 'error');
+ updateCell(rowIndex, 'item_number', '');
+ } else if (validationResponse.ok && validationPayload?.success && validationPayload?.itemNumber) {
+ // Success - cache and apply
+ cacheUpcResult(supplierIdString, upc, validationPayload.itemNumber);
+ setGeneratedItemNumber(rowIndex, validationPayload.itemNumber);
+ clearFieldError(rowIndex, 'upc');
+ setUpcStatus(rowIndex, 'done');
+ } else {
+ setUpcStatus(rowIndex, 'error');
+ updateCell(rowIndex, 'item_number', '');
+ }
+ } catch (validationError) {
+ console.error('UPC validation error:', validationError);
+ setUpcStatus(rowIndex, 'error');
+ } finally {
+ stopValidatingCell(rowIndex, 'item_number');
+ }
+ }
} catch (error) {
console.error('Error generating UPC:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to generate UPC';
@@ -484,6 +536,113 @@ const CellWrapper = memo(({
// Clear subline when line changes (it's no longer valid)
updateCell(rowIndex, 'subline', '');
+
+ // Check if row now has sufficient context for inline AI validation
+ // (line was just set, check if company + name exist)
+ const currentRowForContext = useValidationStore.getState().rows[rowIndex];
+ if (currentRowForContext?.company && currentRowForContext?.name) {
+ const { setInlineAiValidating, setInlineAiSuggestion, inlineAi, fields } = useValidationStore.getState();
+ const contextProductIndex = currentRowForContext.__index;
+
+ // 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;
+ };
+
+ // Check if name should be validated
+ const nameSuggestion = inlineAi.suggestions.get(contextProductIndex);
+ const nameIsDismissed = nameSuggestion?.dismissed?.name;
+ const nameIsValidating = inlineAi.validating.has(`${contextProductIndex}-name`);
+ const nameValue = String(currentRowForContext.name).trim();
+
+ if (nameValue && !nameIsDismissed && !nameIsValidating) {
+ // Trigger name validation
+ setInlineAiValidating(`${contextProductIndex}-name`, true);
+
+ const rows = useValidationStore.getState().rows;
+ const siblingNames: string[] = [];
+ const companyId = String(currentRowForContext.company);
+ const lineId = String(valueToSave); // Use the new line value
+ for (const row of rows) {
+ if (row.__index === contextProductIndex) continue;
+ if (String(row.company) !== companyId) continue;
+ if (String(row.line) !== lineId) continue;
+ if (row.name && typeof row.name === 'string' && row.name.trim()) {
+ siblingNames.push(row.name);
+ }
+ }
+
+ fetch('/api/ai/validate/inline/name', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ product: {
+ name: nameValue,
+ description: currentRowForContext.description as string,
+ company_name: getFieldLabel('company', currentRowForContext.company),
+ company_id: String(currentRowForContext.company),
+ line_name: getFieldLabel('line', valueToSave),
+ line_id: String(valueToSave),
+ siblingNames: siblingNames.length > 0 ? siblingNames : undefined,
+ },
+ }),
+ })
+ .then(res => res.json())
+ .then(result => {
+ if (result.success !== false) {
+ setInlineAiSuggestion(contextProductIndex, 'name', {
+ isValid: result.isValid ?? true,
+ suggestion: result.suggestion,
+ issues: result.issues || [],
+ latencyMs: result.latencyMs,
+ });
+ }
+ })
+ .catch(err => console.error('[InlineAI] name validation error on line change:', err))
+ .finally(() => setInlineAiValidating(`${contextProductIndex}-name`, false));
+ }
+
+ // 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();
+
+ if (descValue && !descIsDismissed && !descIsValidating) {
+ // Trigger description validation
+ setInlineAiValidating(`${contextProductIndex}-description`, true);
+
+ fetch('/api/ai/validate/inline/description', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ product: {
+ name: nameValue,
+ description: descValue,
+ company_name: getFieldLabel('company', currentRowForContext.company),
+ company_id: String(currentRowForContext.company),
+ },
+ }),
+ })
+ .then(res => res.json())
+ .then(result => {
+ if (result.success !== false) {
+ setInlineAiSuggestion(contextProductIndex, 'description', {
+ isValid: result.isValid ?? true,
+ suggestion: result.suggestion,
+ issues: result.issues || [],
+ latencyMs: result.latencyMs,
+ });
+ }
+ })
+ .catch(err => console.error('[InlineAI] description validation error on line change:', err))
+ .finally(() => setInlineAiValidating(`${contextProductIndex}-description`, false));
+ }
+ }
}
// Trigger UPC validation if supplier or UPC changed
@@ -559,11 +718,27 @@ const CellWrapper = memo(({
const currentRow = useValidationStore.getState().rows[rowIndex];
const fields = useValidationStore.getState().fields;
if (currentRow) {
- const { setInlineAiValidating, setInlineAiSuggestion } = useValidationStore.getState();
+ const { setInlineAiValidating, setInlineAiSuggestion, markInlineAiAutoValidated, inlineAi } = useValidationStore.getState();
const fieldKey = field.key as 'name' | 'description';
+ const validationKey = `${productIndex}-${fieldKey}`;
- // Mark as validating
- setInlineAiValidating(`${productIndex}-${fieldKey}`, true);
+ // Skip if validation is already in progress (auto-validation may have started)
+ if (inlineAi.validating.has(validationKey)) {
+ console.log(`[InlineAI] Skipping ${fieldKey} blur validation - already in progress`);
+ return;
+ }
+
+ // Skip if accepting an AI suggestion (value matches current suggestion)
+ // This prevents re-validating when user clicks "Accept" on a suggestion
+ const currentSuggestion = inlineAi.suggestions.get(productIndex)?.[fieldKey];
+ if (currentSuggestion?.suggestion && currentSuggestion.suggestion === String(valueToSave)) {
+ console.log(`[InlineAI] Skipping ${fieldKey} blur validation - accepting AI suggestion`);
+ return;
+ }
+
+ // Mark as validating and auto-validated (prevents race with auto-validation hook)
+ setInlineAiValidating(validationKey, true);
+ markInlineAiAutoValidated(productIndex, fieldKey);
// Helper to look up field option label
const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => {
@@ -744,8 +919,9 @@ const CellWrapper = memo(({
onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined}
isLoadingOptions={isLoadingOptions}
// Pass AI suggestion props for description field (MultilineInput handles it internally)
+ // Only pass aiSuggestion when showSuggestion is true (respects dismissed state)
{...(field.key === 'description' && {
- aiSuggestion: fieldSuggestion,
+ aiSuggestion: showSuggestion ? fieldSuggestion : undefined,
isAiValidating: isInlineAiValidating,
onDismissAiSuggestion: () => {
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
@@ -1433,6 +1609,144 @@ const HeaderCheckbox = memo(() => {
HeaderCheckbox.displayName = 'HeaderCheckbox';
+/**
+ * PriceColumnHeader Component
+ *
+ * Renders a column header for MSRP or Cost Each with a hover button
+ * that fills empty cells based on the other price field.
+ * - MSRP: Fill with Cost Each × 2
+ * - Cost Each: Fill with MSRP ÷ 2
+ *
+ * PERFORMANCE: Uses local hover state and getState() for bulk updates.
+ * No store subscriptions - this is purely a UI component that triggers actions.
+ */
+interface PriceColumnHeaderProps {
+ fieldKey: 'msrp' | 'cost_each';
+ label: string;
+ isRequired: boolean;
+}
+
+const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHeaderProps) => {
+ const [isHovered, setIsHovered] = useState(false);
+ const [hasFillableCells, setHasFillableCells] = useState(false);
+
+ // Determine the source field and calculation
+ const sourceField = fieldKey === 'msrp' ? 'cost_each' : 'msrp';
+ const tooltipText = fieldKey === 'msrp'
+ ? 'Fill empty cells with Cost Each × 2'
+ : 'Fill empty cells with MSRP ÷ 2';
+
+ // Check if there are any cells that can be filled (called on hover)
+ const checkFillableCells = useCallback(() => {
+ const { rows } = useValidationStore.getState();
+ return rows.some((row) => {
+ const currentValue = row[fieldKey];
+ const sourceValue = row[sourceField];
+ const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
+ const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
+ if (isEmpty && hasSource) {
+ const sourceNum = parseFloat(String(sourceValue));
+ return !isNaN(sourceNum) && sourceNum > 0;
+ }
+ return false;
+ });
+ }, [fieldKey, sourceField]);
+
+ // Update fillable check on hover
+ const handleMouseEnter = useCallback(() => {
+ setIsHovered(true);
+ setHasFillableCells(checkFillableCells());
+ }, [checkFillableCells]);
+
+ const handleCalculate = useCallback(() => {
+ const updatedIndices: number[] = [];
+
+ // Use setState() for efficient batch update with Immer
+ useValidationStore.setState((draft) => {
+ draft.rows.forEach((row, index) => {
+ const currentValue = row[fieldKey];
+ const sourceValue = row[sourceField];
+
+ // Only fill if current field is empty and source has a value
+ const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
+ const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
+
+ if (isEmpty && hasSource) {
+ const sourceNum = parseFloat(String(sourceValue));
+ if (!isNaN(sourceNum) && sourceNum > 0) {
+ // Calculate the new value
+ let newValue: string;
+ if (fieldKey === 'msrp') {
+ let msrp = sourceNum * 2;
+ // Round down .00 to .99 for better pricing (e.g., 13.00 → 12.99)
+ if (msrp === Math.floor(msrp)) {
+ msrp -= 0.01;
+ }
+ newValue = msrp.toFixed(2);
+ } else {
+ newValue = (sourceNum / 2).toFixed(2);
+ }
+ draft.rows[index][fieldKey] = newValue;
+ updatedIndices.push(index);
+ }
+ }
+ });
+ });
+
+ // Clear validation errors for all updated cells (removes "required" error styling)
+ if (updatedIndices.length > 0) {
+ const { clearFieldError } = useValidationStore.getState();
+ updatedIndices.forEach((rowIndex) => {
+ clearFieldError(rowIndex, fieldKey);
+ });
+
+ toast.success(`Updated ${updatedIndices.length} ${label} value${updatedIndices.length === 1 ? '' : 's'}`);
+ }
+ }, [fieldKey, sourceField, label]);
+
+ return (
+ setIsHovered(false)}
+ >
+
{label}
+ {isRequired && (
+
*
+ )}
+ {isHovered && hasFillableCells && (
+
+
+
+ {
+ e.stopPropagation();
+ handleCalculate();
+ }}
+ className={cn(
+ 'absolute right-1 top-1/2 -translate-y-1/2',
+ 'flex items-center gap-0.5',
+ 'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
+ 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
+ 'transition-opacity'
+ )}
+ >
+
+
+
+
+ {tooltipText}
+
+
+
+ )}
+
+ );
+});
+
+PriceColumnHeader.displayName = 'PriceColumnHeader';
+
/**
* Main table component
*
@@ -1535,18 +1849,29 @@ export const ValidationTable = () => {
};
// Data columns from fields with widths from config.ts
- const dataColumns: ColumnDef[] = fields.map((field) => ({
- id: field.key,
- header: () => (
-
- {field.label}
- {field.validations?.some((v: Validation) => v.rule === 'required') && (
- *
- )}
-
- ),
- size: field.width || 150,
- }));
+ const dataColumns: ColumnDef[] = fields.map((field) => {
+ const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
+ const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
+
+ return {
+ id: field.key,
+ header: () => isPriceColumn ? (
+
+ ) : (
+
+ {field.label}
+ {isRequired && (
+ *
+ )}
+
+ ),
+ size: field.width || 150,
+ };
+ });
return [selectionColumn, templateColumn, ...dataColumns];
}, [fields]); // CRITICAL: No selection-related deps!
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 a266dfa..ceb0416 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
@@ -197,8 +197,8 @@ const MultilineInputComponent = ({
{/* Close button */}
@@ -267,7 +267,7 @@ const MultilineInputComponent = ({
value={editValue}
onChange={handleChange}
onWheel={handleTextareaWheel}
- className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none p-2 pr-8 resize-none"
+ className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none pl-2 pr-4 py-1 resize-none"
placeholder={`Enter ${field.label || 'text'}...`}
autoFocus
/>
@@ -337,7 +337,7 @@ const MultilineInputComponent = ({
onClick={handleAcceptSuggestion}
>
- Apply
+ Replace With Suggestion
{isChecking ? (
<>
-
- Running Sanity Check...
+ Running Consistency Check...
>
) : error ? (
<>
- Sanity Check Failed
+ Consistency Check Failed
>
) : hasAnyIssues ? (
<>
{hasValidationErrors && hasSanityIssues
- ? 'Validation Errors & Issues Found'
+ ? 'Validation Errors & Consistency Issues Found'
: hasValidationErrors
? 'Validation Errors'
- : 'Issues Found'}
+ : 'Consistency Issues Found'}
>
) : allClear ? (
<>
-
- Ready to Continue
+ Continue
>
) : (
- 'Pre-flight Check'
+ <>
+
+ Consistency Check
+ >
)}
- {/* Refresh button - only show when not checking */}
- {!isChecking && onRefresh && result && (
-
-
- Refresh
-
- )}
+
-
- {isChecking
- ? 'Reviewing products for consistency and appropriateness...'
- : error
- ? 'An error occurred while checking your products.'
- : allClear && !hasValidationErrors
- ? 'All products look good! No issues detected.'
- : hasAnyIssues
- ? buildIssuesSummary(validationErrorCount, result?.issues?.length || 0)
- : 'Checking your products...'}
- {/* Show when results were cached */}
- {!isChecking && result?.checkedAt && (
-
- Last checked {formatTimeAgo(result.checkedAt)}
-
- )}
-
{/* Content */}
@@ -173,9 +142,7 @@ export function SanityCheckDialog({
{/* Success state - only show if no validation errors either */}
{allClear && !hasValidationErrors && !isChecking && (
-
-
All Clear!
{result?.summary || 'No consistency issues detected in your products.'}
@@ -185,28 +152,33 @@ export function SanityCheckDialog({
{/* Validation errors warning */}
{hasValidationErrors && !isChecking && (
-
-
+
-
Validation Errors
-
- There {validationErrorCount === 1 ? 'is' : 'are'} {validationErrorCount} validation error{validationErrorCount === 1 ? '' : 's'} (required fields, invalid values, etc.) in your data.
+
+ There {validationErrorCount === 1 ? 'is' : 'are'} {validationErrorCount} validation error{validationErrorCount === 1 ? '' : 's'} (required fields missing, invalid values, etc.) in your data.
These should be fixed before continuing.
)}
+ {hasSanityIssues && !isChecking && (
+ <>
+ {/* Summary */}
+ {result?.summary && (
+
+ )}
+ >
+ )}
{/* Sanity check issues list */}
{hasSanityIssues && !isChecking && (
-
+
- {/* Summary */}
- {result?.summary && (
-
- )}
+
{/* Issues grouped by field */}
{Object.entries(issuesByField).map(([field, fieldIssues]) => (
@@ -249,7 +221,7 @@ export function SanityCheckDialog({
{issue.issue}
{issue.suggestion && (
- 💡 {issue.suggestion}
+ {issue.suggestion}
)}
@@ -288,8 +260,7 @@ export function SanityCheckDialog({
Go Back & Fix
- {hasValidationErrors ? 'Proceed Despite Errors' : 'Proceed Anyway'}
-
+ Proceed Anyway
>
) : null}
@@ -320,47 +291,4 @@ function formatFieldName(field: string): string {
};
return fieldNames[field] || field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
-}
-
-/**
- * Format a timestamp as a relative time string
- */
-function formatTimeAgo(timestamp: number): string {
- const seconds = Math.floor((Date.now() - timestamp) / 1000);
-
- if (seconds < 5) return 'just now';
- if (seconds < 60) return `${seconds} seconds ago`;
-
- const minutes = Math.floor(seconds / 60);
- if (minutes === 1) return '1 minute ago';
- if (minutes < 60) return `${minutes} minutes ago`;
-
- const hours = Math.floor(minutes / 60);
- if (hours === 1) return '1 hour ago';
- if (hours < 24) return `${hours} hours ago`;
-
- return 'over a day ago';
-}
-
-/**
- * Build a summary string describing both validation errors and sanity issues
- */
-function buildIssuesSummary(validationErrorCount: number, sanityIssueCount: number): string {
- const parts: string[] = [];
-
- if (validationErrorCount > 0) {
- parts.push(`${validationErrorCount} validation error${validationErrorCount === 1 ? '' : 's'}`);
- }
-
- if (sanityIssueCount > 0) {
- parts.push(`${sanityIssueCount} consistency issue${sanityIssueCount === 1 ? '' : 's'}`);
- }
-
- if (parts.length === 2) {
- return `Found ${parts[0]} and ${parts[1]} to review.`;
- } else if (parts.length === 1) {
- return `Found ${parts[0]} to review.`;
- }
-
- return 'Review the issues below.';
-}
+}
\ No newline at end of file
diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts
new file mode 100644
index 0000000..2b1a06f
--- /dev/null
+++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts
@@ -0,0 +1,201 @@
+/**
+ * useAutoInlineAiValidation Hook
+ *
+ * Automatically triggers inline AI validation (name/description) for rows
+ * that have sufficient context when the validation step becomes ready.
+ *
+ * Context requirements:
+ * - Name validation: company + line + name value
+ * - Description validation: company + line + name + description value
+ *
+ * This runs once when the table is ready, firing all requests at once.
+ * The blur handler in ValidationTable.tsx handles subsequent validations
+ * when fields are edited.
+ */
+
+import { useEffect, useRef } from 'react';
+import { useValidationStore } from '../store/validationStore';
+import { useInitPhase } from '../store/selectors';
+import type { RowData } from '../store/types';
+import type { Field } from '../../../types';
+
+/**
+ * Build product payload for AI validation API
+ */
+function buildProductPayload(
+ row: RowData,
+ _field: 'name' | 'description',
+ fields: Field[],
+ allRows: RowData[]
+) {
+ // 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;
+ };
+
+ // Compute sibling names for context (same company + line + subline if set)
+ const siblingNames: string[] = [];
+ if (row.company && row.line) {
+ const companyId = String(row.company);
+ const lineId = String(row.line);
+ const sublineId = row.subline ? String(row.subline) : null;
+
+ for (const otherRow of allRows) {
+ // Skip self
+ if (otherRow.__index === row.__index) continue;
+
+ // Must match company and line
+ if (String(otherRow.company) !== companyId) continue;
+ if (String(otherRow.line) !== lineId) continue;
+
+ // If current product has subline, siblings must match subline too
+ if (sublineId && String(otherRow.subline) !== sublineId) continue;
+
+ // Add name if it exists
+ if (otherRow.name && typeof otherRow.name === 'string' && otherRow.name.trim()) {
+ siblingNames.push(otherRow.name);
+ }
+ }
+ }
+
+ return {
+ name: row.name as string,
+ description: row.description as string | undefined,
+ company_name: row.company ? getFieldLabel('company', row.company) : undefined,
+ company_id: row.company ? String(row.company) : undefined,
+ line_name: row.line ? getFieldLabel('line', row.line) : undefined,
+ line_id: row.line ? String(row.line) : undefined,
+ subline_name: row.subline ? getFieldLabel('subline', row.subline) : undefined,
+ subline_id: row.subline ? String(row.subline) : undefined,
+ categories: row.categories as string | undefined,
+ siblingNames: siblingNames.length > 0 ? siblingNames : undefined,
+ };
+}
+
+/**
+ * Trigger validation for a single field
+ */
+async function triggerValidation(
+ productIndex: string,
+ field: 'name' | 'description',
+ payload: ReturnType
+) {
+ const {
+ setInlineAiValidating,
+ setInlineAiSuggestion,
+ markInlineAiAutoValidated,
+ } = useValidationStore.getState();
+
+ const validationKey = `${productIndex}-${field}`;
+
+ // Mark as auto-validated BEFORE calling API (prevents blur handler race condition)
+ markInlineAiAutoValidated(productIndex, field);
+
+ // Mark as validating
+ setInlineAiValidating(validationKey, true);
+
+ const endpoint = field === 'name'
+ ? '/api/ai/validate/inline/name'
+ : '/api/ai/validate/inline/description';
+
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ product: payload }),
+ });
+
+ const result = await response.json();
+
+ if (result.success !== false) {
+ setInlineAiSuggestion(productIndex, field, {
+ isValid: result.isValid ?? true,
+ suggestion: result.suggestion,
+ issues: result.issues || [],
+ latencyMs: result.latencyMs,
+ });
+ }
+ } catch (err) {
+ console.error(`[AutoInlineAI] ${field} validation error for ${productIndex}:`, err);
+ } finally {
+ setInlineAiValidating(validationKey, false);
+ }
+}
+
+/**
+ * Hook that triggers inline AI validation for all rows with sufficient context
+ * when the validation step becomes ready.
+ */
+export function useAutoInlineAiValidation() {
+ const initPhase = useInitPhase();
+ const hasRunRef = useRef(false);
+
+ useEffect(() => {
+ // Only run when ready phase is reached, and only once
+ if (initPhase !== 'ready' || hasRunRef.current) {
+ return;
+ }
+
+ hasRunRef.current = true;
+
+ const state = useValidationStore.getState();
+ const { rows, fields, inlineAi } = state;
+
+ console.log('[AutoInlineAI] Starting auto-validation for', rows.length, 'rows');
+
+ let nameCount = 0;
+ let descCount = 0;
+
+ // Process all rows - fire requests immediately (no batching)
+ for (const row of rows) {
+ const productIndex = row.__index;
+ if (!productIndex) continue;
+
+ // Check name context: company + line + name
+ const hasNameContext =
+ row.company &&
+ row.line &&
+ row.name &&
+ typeof row.name === 'string' &&
+ row.name.trim();
+
+ // Check description context: company + line + name + description
+ const hasDescContext =
+ hasNameContext &&
+ row.description &&
+ typeof row.description === 'string' &&
+ row.description.trim();
+
+ // Skip if already auto-validated (shouldn't happen on first run, but be safe)
+ const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`);
+ const descAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-description`);
+
+ // Skip if currently validating (another process started validation)
+ const nameCurrentlyValidating = inlineAi.validating.has(`${productIndex}-name`);
+ const descCurrentlyValidating = inlineAi.validating.has(`${productIndex}-description`);
+
+ // Trigger name validation if context is sufficient
+ if (hasNameContext && !nameAlreadyValidated && !nameCurrentlyValidating) {
+ const payload = buildProductPayload(row, 'name', fields, rows);
+ triggerValidation(productIndex, 'name', payload);
+ nameCount++;
+ }
+
+ // Trigger description validation if context is sufficient
+ if (hasDescContext && !descAlreadyValidated && !descCurrentlyValidating) {
+ const payload = buildProductPayload(row, 'description', fields, rows);
+ triggerValidation(productIndex, 'description', payload);
+ descCount++;
+ }
+ }
+
+ console.log(`[AutoInlineAI] Triggered ${nameCount} name validations, ${descCount} description validations`);
+ }, [initPhase]);
+}
+
+export default useAutoInlineAiValidation;
diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts
index b9e3332..9430cbb 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts
+++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts
@@ -1,54 +1,168 @@
/**
* useCopyDownValidation Hook
*
- * Watches for copy-down operations on UPC-related fields (supplier, upc, barcode)
- * and triggers UPC validation for affected rows using the existing validateUpc function.
+ * Watches for copy-down operations and triggers appropriate validations:
+ * - UPC-related fields (supplier, upc, barcode) -> UPC validation
+ * - Line field -> Inline AI validation for rows that gain sufficient context
*
- * This avoids duplicating UPC validation logic - we reuse the same code path
- * that handles individual cell blur events.
+ * This avoids duplicating validation logic - we reuse the same code paths
+ * that handle individual cell blur events.
*/
import { useEffect } from 'react';
import { useValidationStore } from '../store/validationStore';
import { useUpcValidation } from './useUpcValidation';
+import type { Field } from '../../../types';
/**
- * Hook that handles UPC validation after copy-down operations.
+ * Helper to look up field option label
+ */
+function getFieldLabel(fields: Field[], 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;
+}
+
+/**
+ * Trigger inline AI validation for a single row/field
+ */
+async function triggerInlineAiValidation(
+ rowIndex: number,
+ field: 'name' | 'description',
+ rows: ReturnType['rows'],
+ fields: Field[],
+ setInlineAiValidating: (key: string, validating: boolean) => void,
+ setInlineAiSuggestion: (productIndex: string, field: 'name' | 'description', result: { isValid: boolean; suggestion?: string; issues: string[]; latencyMs?: number }) => void
+) {
+ const row = rows[rowIndex];
+ if (!row?.__index) return;
+
+ const productIndex = row.__index;
+ const validationKey = `${productIndex}-${field}`;
+
+ setInlineAiValidating(validationKey, true);
+
+ // Compute sibling names for context
+ const siblingNames: string[] = [];
+ if (row.company && row.line) {
+ const companyId = String(row.company);
+ const lineId = String(row.line);
+ for (const otherRow of rows) {
+ if (otherRow.__index === productIndex) continue;
+ if (String(otherRow.company) !== companyId) continue;
+ if (String(otherRow.line) !== lineId) continue;
+ if (otherRow.name && typeof otherRow.name === 'string' && otherRow.name.trim()) {
+ siblingNames.push(otherRow.name);
+ }
+ }
+ }
+
+ const productPayload = {
+ name: String(row.name),
+ description: row.description ? String(row.description) : undefined,
+ company_name: row.company ? getFieldLabel(fields, 'company', row.company) : undefined,
+ company_id: row.company ? String(row.company) : undefined,
+ line_name: row.line ? getFieldLabel(fields, 'line', row.line) : undefined,
+ line_id: row.line ? String(row.line) : undefined,
+ siblingNames: siblingNames.length > 0 ? siblingNames : undefined,
+ };
+
+ const endpoint = field === 'name'
+ ? '/api/ai/validate/inline/name'
+ : '/api/ai/validate/inline/description';
+
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ product: productPayload }),
+ });
+
+ const result = await response.json();
+
+ if (result.success !== false) {
+ setInlineAiSuggestion(productIndex, field, {
+ isValid: result.isValid ?? true,
+ suggestion: result.suggestion,
+ issues: result.issues || [],
+ latencyMs: result.latencyMs,
+ });
+ }
+ } catch (err) {
+ console.error(`[InlineAI] ${field} validation error on line copy-down:`, err);
+ } finally {
+ setInlineAiValidating(validationKey, false);
+ }
+}
+
+/**
+ * Hook that handles validation after copy-down operations.
* Should be called once in ValidationContainer to ensure validation runs.
*/
export const useCopyDownValidation = () => {
const { validateUpc } = useUpcValidation();
- // Subscribe to pending copy-down validation
- const pendingValidation = useValidationStore((state) => state.pendingCopyDownValidation);
+ // Subscribe to pending validations
+ const pendingUpcValidation = useValidationStore((state) => state.pendingCopyDownValidation);
+ const pendingInlineAiValidation = useValidationStore((state) => state.pendingInlineAiValidation);
const clearPendingCopyDownValidation = useValidationStore((state) => state.clearPendingCopyDownValidation);
+ const clearPendingInlineAiValidation = useValidationStore((state) => state.clearPendingInlineAiValidation);
+ // Handle UPC validation
useEffect(() => {
- if (!pendingValidation) return;
+ if (!pendingUpcValidation) return;
- const { fieldKey, affectedRows } = pendingValidation;
-
- // Get current rows to check supplier and UPC values
+ const { affectedRows } = pendingUpcValidation;
const rows = useValidationStore.getState().rows;
- // Process each affected row
const validationPromises = affectedRows.map(async (rowIndex) => {
const row = rows[rowIndex];
if (!row) return;
- // Get supplier and UPC values
const supplierId = row.supplier ? String(row.supplier) : '';
const upcValue = row.upc ? String(row.upc) : (row.barcode ? String(row.barcode) : '');
- // Only validate if we have both supplier and UPC
if (supplierId && upcValue) {
await validateUpc(rowIndex, supplierId, upcValue);
}
});
- // Run all validations and then clear the pending state
Promise.all(validationPromises).then(() => {
clearPendingCopyDownValidation();
});
- }, [pendingValidation, validateUpc, clearPendingCopyDownValidation]);
+ }, [pendingUpcValidation, validateUpc, clearPendingCopyDownValidation]);
+
+ // Handle inline AI validation (triggered by line copy-down)
+ useEffect(() => {
+ if (!pendingInlineAiValidation) return;
+
+ const { nameRows, descriptionRows } = pendingInlineAiValidation;
+ const state = useValidationStore.getState();
+ const { rows, fields, setInlineAiValidating, setInlineAiSuggestion } = state;
+
+ console.log(`[InlineAI] Line copy-down: validating ${nameRows.length} names, ${descriptionRows.length} descriptions`);
+
+ const validationPromises: Promise[] = [];
+
+ // Trigger name validation for applicable rows
+ for (const rowIndex of nameRows) {
+ validationPromises.push(
+ triggerInlineAiValidation(rowIndex, 'name', rows, fields, setInlineAiValidating, setInlineAiSuggestion)
+ );
+ }
+
+ // Trigger description validation for applicable rows
+ for (const rowIndex of descriptionRows) {
+ validationPromises.push(
+ triggerInlineAiValidation(rowIndex, 'description', rows, fields, setInlineAiValidating, setInlineAiSuggestion)
+ );
+ }
+
+ Promise.all(validationPromises).then(() => {
+ clearPendingInlineAiValidation();
+ });
+ }, [pendingInlineAiValidation, clearPendingInlineAiValidation]);
};
diff --git a/inventory/src/components/product-import/steps/ValidationStep/index.tsx b/inventory/src/components/product-import/steps/ValidationStep/index.tsx
index 5f0576d..7880b5c 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/index.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/index.tsx
@@ -18,6 +18,7 @@ import { useTemplateManagement } from './hooks/useTemplateManagement';
import { useUpcValidation } from './hooks/useUpcValidation';
import { useValidationActions } from './hooks/useValidationActions';
import { useProductLines } from './hooks/useProductLines';
+import { useAutoInlineAiValidation } from './hooks/useAutoInlineAiValidation';
import { BASE_IMPORT_FIELDS } from '../../config';
import config from '@/config';
import type { ValidationStepProps } from './store/types';
@@ -120,6 +121,9 @@ export const ValidationStep = ({
const { validateAllRows } = useValidationActions();
const { prefetchAllLines } = useProductLines();
+ // Auto inline AI validation - triggers when ready phase is reached
+ useAutoInlineAiValidation();
+
// Fetch field options
const { data: fieldOptions, isLoading: optionsLoading, error: optionsError } = useQuery({
queryKey: ['field-options'],
@@ -128,6 +132,9 @@ export const ValidationStep = ({
retry: 2,
});
+ // Get current store state to check if we're returning to an already-initialized store
+ const storeRows = useValidationStore((state) => state.rows);
+
// Initialize store with data
useEffect(() => {
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
@@ -140,6 +147,17 @@ export const ValidationStep = ({
return;
}
+ // IMPORTANT: Skip initialization if we're returning to an already-ready store
+ // This happens when navigating back from ImageUploadStep - the store still has
+ // all the validated data, so we don't need to re-run the initialization sequence.
+ // We check that the store is 'ready' and has matching row count to avoid
+ // false positives from stale store data.
+ if (initPhase === 'ready' && storeRows.length === initialData.length && storeRows.length > 0) {
+ console.log('[ValidationStep] Skipping init - returning to already-ready store with', storeRows.length, 'rows');
+ initStartedRef.current = true;
+ return;
+ }
+
initStartedRef.current = true;
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
@@ -154,7 +172,7 @@ export const ValidationStep = ({
console.log('[ValidationStep] Calling initialize()');
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field[], file);
console.log('[ValidationStep] initialize() called');
- }, [initialData, file, initialize, initPhase]);
+ }, [initialData, file, initialize, initPhase, storeRows.length]);
// Update fields when options are loaded
// CRITICAL: Check store state (not ref) because initialize() resets the store
diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts
index 3765a11..72dbeac 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts
+++ b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts
@@ -160,6 +160,18 @@ export interface PendingCopyDownValidation {
affectedRows: number[];
}
+/**
+ * Tracks rows that need inline AI validation after line copy-down.
+ * When line is copied to rows that already have company + name/description,
+ * those rows now have sufficient context for validation.
+ */
+export interface PendingInlineAiValidation {
+ /** Row indices that need name validation */
+ nameRows: number[];
+ /** Row indices that need description validation */
+ descriptionRows: number[];
+}
+
// =============================================================================
// Dialog State Types
// =============================================================================
@@ -291,8 +303,14 @@ export interface InlineAiSuggestion {
export interface InlineAiState {
/** Map of product __index to their inline suggestions */
suggestions: Map;
- /** Products currently being validated */
+ /** Products currently being validated (format: "productIndex-field") */
validating: Set;
+ /**
+ * Fields that have been auto-validated on load (format: "productIndex-field")
+ * This prevents re-validation when blur fires for a field that was just auto-validated,
+ * and prevents auto-validation from firing for fields that were manually edited.
+ */
+ autoValidated: Set;
}
// =============================================================================
@@ -370,6 +388,7 @@ export interface ValidationState {
// === Copy-Down Mode ===
copyDownMode: CopyDownState;
pendingCopyDownValidation: PendingCopyDownValidation | null;
+ pendingInlineAiValidation: PendingInlineAiValidation | null;
// === Dialogs ===
dialogs: DialogState;
@@ -458,6 +477,7 @@ export interface ValidationActions {
completeCopyDown: (targetRowIndex: number) => void;
setTargetRowHover: (rowIndex: number | null) => void;
clearPendingCopyDownValidation: () => void;
+ clearPendingInlineAiValidation: () => void;
// === Dialogs ===
setDialogs: (updates: Partial) => void;
@@ -484,6 +504,8 @@ export interface ValidationActions {
acceptInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => void;
clearInlineAiSuggestion: (productIndex: string, field?: 'name' | 'description') => void;
setInlineAiValidating: (productIndex: string, validating: boolean) => void;
+ markInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => void;
+ isInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => boolean;
// === Output ===
getCleanedData: () => CleanRowData[];
diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts
index 3469655..17785eb 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts
+++ b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts
@@ -110,6 +110,7 @@ const getInitialState = (): ValidationState => ({
// Copy-Down Mode
copyDownMode: { ...initialCopyDownState },
pendingCopyDownValidation: null,
+ pendingInlineAiValidation: null,
// Dialogs
dialogs: { ...initialDialogState },
@@ -129,6 +130,7 @@ const getInitialState = (): ValidationState => ({
inlineAi: {
suggestions: new Map(),
validating: new Set(),
+ autoValidated: new Set(),
},
// File
@@ -256,13 +258,45 @@ export const useValidationStore = create()(
copyDown: (fromRowIndex: number, fieldKey: string, toRowIndex?: number) => {
set((state) => {
- const sourceValue = state.rows[fromRowIndex]?.[fieldKey];
+ const sourceRow = state.rows[fromRowIndex];
+ const sourceValue = sourceRow?.[fieldKey];
if (sourceValue === undefined) return;
const endIndex = toRowIndex ?? state.rows.length - 1;
+ const isInlineAiField = fieldKey === 'name' || fieldKey === 'description';
+
+ // For inline AI fields, check if source was validated/dismissed
+ // If so, we'll mark targets as autoValidated to skip re-validation
+ let sourceIsDismissed = false;
+ if (isInlineAiField && sourceRow?.__index) {
+ const sourceSuggestion = state.inlineAi.suggestions.get(sourceRow.__index);
+ sourceIsDismissed = sourceSuggestion?.dismissed?.[fieldKey as 'name' | 'description'] ?? false;
+ }
+
for (let i = fromRowIndex + 1; i <= endIndex; i++) {
- if (state.rows[i]) {
- state.rows[i][fieldKey] = sourceValue;
+ const targetRow = state.rows[i];
+ if (targetRow) {
+ targetRow[fieldKey] = sourceValue;
+
+ // For name/description fields:
+ // 1. Mark as autoValidated so blur won't re-validate
+ // 2. Clear any existing suggestion for this field (value changed)
+ // 3. Set dismissed state based on source (if source was dismissed, targets are also valid)
+ if (isInlineAiField && targetRow.__index) {
+ const field = fieldKey as 'name' | 'description';
+ state.inlineAi.autoValidated.add(`${targetRow.__index}-${field}`);
+
+ // Clear existing suggestion and set dismissed state
+ const existing = state.inlineAi.suggestions.get(targetRow.__index) || {};
+ state.inlineAi.suggestions.set(targetRow.__index, {
+ ...existing,
+ [field]: undefined, // Clear the suggestion for this field
+ dismissed: {
+ ...existing.dismissed,
+ [field]: sourceIsDismissed, // Copy dismissed state from source
+ },
+ });
+ }
}
}
});
@@ -620,6 +654,43 @@ export const useValidationStore = create()(
affectedRows,
};
}
+
+ // If line is being copied, check which rows now have sufficient context
+ // for inline AI validation (company + line + name/description)
+ if (fieldKey === 'line' && affectedRows.length > 0) {
+ const nameRows: number[] = [];
+ const descriptionRows: number[] = [];
+
+ for (const rowIdx of affectedRows) {
+ const row = state.rows[rowIdx];
+ if (!row?.__index) continue;
+
+ // Check if row has company + line (just set) + name
+ const hasNameContext = row.company && sourceValue && row.name &&
+ typeof row.name === 'string' && row.name.trim();
+
+ if (hasNameContext) {
+ // Check if name hasn't been dismissed
+ const suggestion = state.inlineAi.suggestions.get(row.__index);
+ const nameIsDismissed = suggestion?.dismissed?.name;
+ if (!nameIsDismissed) {
+ nameRows.push(rowIdx);
+ }
+
+ // Check if description also has sufficient context
+ const hasDescContext = row.description &&
+ typeof row.description === 'string' && row.description.trim();
+ const descIsDismissed = suggestion?.dismissed?.description;
+ if (hasDescContext && !descIsDismissed) {
+ descriptionRows.push(rowIdx);
+ }
+ }
+ }
+
+ if (nameRows.length > 0 || descriptionRows.length > 0) {
+ state.pendingInlineAiValidation = { nameRows, descriptionRows };
+ }
+ }
});
},
@@ -637,6 +708,12 @@ export const useValidationStore = create()(
});
},
+ clearPendingInlineAiValidation: () => {
+ set((state) => {
+ state.pendingInlineAiValidation = null;
+ });
+ },
+
// =========================================================================
// Dialogs
// =========================================================================
@@ -853,6 +930,16 @@ export const useValidationStore = create()(
});
},
+ markInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => {
+ set((state) => {
+ state.inlineAi.autoValidated.add(`${productIndex}-${field}`);
+ });
+ },
+
+ isInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => {
+ return get().inlineAi.autoValidated.has(`${productIndex}-${field}`);
+ },
+
// =========================================================================
// Output
// =========================================================================