New AI tasks tweaks/fixes

This commit is contained in:
2026-01-20 19:38:35 -05:00
parent 1dcb47cfc5
commit 3d1e8862f9
10 changed files with 858 additions and 181 deletions

View File

@@ -121,7 +121,7 @@ export const BASE_IMPORT_FIELDS = [
type: "input", type: "input",
price: true price: true
}, },
width: 100, width: 110,
validations: [ validations: [
{ rule: "required", errorMessage: "Required", level: "error" }, { rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" }, { rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
@@ -148,7 +148,7 @@ export const BASE_IMPORT_FIELDS = [
type: "input", type: "input",
price: true price: true
}, },
width: 110, width: 120,
validations: [ validations: [
{ rule: "required", errorMessage: "Required", level: "error" }, { rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" }, { rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },

View File

@@ -9,7 +9,7 @@ import { useContext } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; 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 { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -83,52 +83,38 @@ export const ValidationFooter = ({
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Skip sanity check toggle - only for admin:debug users */} {/* Skip sanity check toggle - only for admin:debug users */}
{hasDebugPermission && onSkipSanityCheckChange && ( {hasDebugPermission && onSkipSanityCheckChange && !hasRunSanityCheck && (
<TooltipProvider> <TooltipProvider>
<Tooltip delayDuration={300}> <Tooltip delayDuration={300}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-2 mr-2"> <div className="flex items-center gap-2 mr-4">
<Switch <Switch
id="skip-sanity" id="skip-sanity"
checked={skipSanityCheck} checked={skipSanityCheck}
onCheckedChange={onSkipSanityCheckChange} onCheckedChange={onSkipSanityCheckChange}
className="data-[state=checked]:bg-amber-500"
/> />
<Label <Label
htmlFor="skip-sanity" htmlFor="skip-sanity"
className="text-xs text-muted-foreground cursor-pointer flex items-center gap-1" className="text-sm text-muted-foreground cursor-pointer flex items-center gap-1"
> >
<Bug className="h-3 w-3" /> Skip Consistency Check
Skip
</Label> </Label>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p>Debug: Skip sanity check</p> <p>Debug: Skip consistency check</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
{/* Before first sanity check: single "Continue" button that runs the check */} {/* Before first sanity check: single "Next" button that runs the check */}
{!hasRunSanityCheck && !skipSanityCheck && ( {!hasRunSanityCheck && !skipSanityCheck && (
<Button <Button
onClick={onRunCheck} onClick={onRunCheck}
disabled={isSanityChecking || rowCount === 0} disabled={isSanityChecking || rowCount === 0}
title={
!canProceed
? `There are ${errorCount} validation errors`
: 'Continue to image upload'
}
> >
{isSanityChecking ? ( Next
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Checking...
</>
) : (
'Continue'
)}
</Button> </Button>
)} )}
@@ -141,16 +127,15 @@ export const ValidationFooter = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="outline" variant="outline"
size="sm"
onClick={onViewResults} onClick={onViewResults}
disabled={isSanityChecking} disabled={isSanityChecking}
> >
<Eye className="h-4 w-4 mr-1" /> <Eye className="h-4 w-4 mr-1" />
Results Review Check Results
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p>View previous sanity check results</p> <p>Review previous consistency check results</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -161,7 +146,6 @@ export const ValidationFooter = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="outline" variant="outline"
size="sm"
onClick={onRunCheck} onClick={onRunCheck}
disabled={isSanityChecking || rowCount === 0} disabled={isSanityChecking || rowCount === 0}
> >
@@ -170,11 +154,11 @@ export const ValidationFooter = ({
) : ( ) : (
<RefreshCw className="h-4 w-4 mr-1" /> <RefreshCw className="h-4 w-4 mr-1" />
)} )}
{isSanityChecking ? 'Checking...' : 'Recheck'} {isSanityChecking ? 'Checking...' : 'Check Again'}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p>Run a fresh sanity check</p> <p>Run a fresh consistency check</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -189,8 +173,7 @@ export const ValidationFooter = ({
: 'Continue to image upload' : 'Continue to image upload'
} }
> >
Continue Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button> </Button>
</> </>
)} )}
@@ -202,8 +185,7 @@ export const ValidationFooter = ({
disabled={rowCount === 0} disabled={rowCount === 0}
title="Continue to image upload (sanity check skipped)" title="Continue to image upload (sanity check skipped)"
> >
Continue Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button> </Button>
)} )}
</div> </div>

View File

@@ -16,7 +16,7 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Checkbox } from '@/components/ui/checkbox'; 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -258,9 +258,61 @@ const CellWrapper = memo(({
throw new Error(payload?.error || 'Unexpected response while generating UPC'); throw new Error(payload?.error || 'Unexpected response while generating UPC');
} }
// Update the cell value // Update the cell value and clear any validation errors
useValidationStore.getState().updateCell(rowIndex, 'upc', payload.upc); const { updateCell, clearFieldError, setUpcStatus, setGeneratedItemNumber,
cacheUpcResult, getCachedItemNumber, startValidatingCell, stopValidatingCell,
setError } = useValidationStore.getState();
updateCell(rowIndex, 'upc', payload.upc);
clearFieldError(rowIndex, 'upc');
toast.success('UPC generated'); 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) { } catch (error) {
console.error('Error generating UPC:', error); console.error('Error generating UPC:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to generate UPC'; 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) // Clear subline when line changes (it's no longer valid)
updateCell(rowIndex, 'subline', ''); 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 // Trigger UPC validation if supplier or UPC changed
@@ -559,11 +718,27 @@ const CellWrapper = memo(({
const currentRow = useValidationStore.getState().rows[rowIndex]; const currentRow = useValidationStore.getState().rows[rowIndex];
const fields = useValidationStore.getState().fields; const fields = useValidationStore.getState().fields;
if (currentRow) { if (currentRow) {
const { setInlineAiValidating, setInlineAiSuggestion } = useValidationStore.getState(); const { setInlineAiValidating, setInlineAiSuggestion, markInlineAiAutoValidated, inlineAi } = useValidationStore.getState();
const fieldKey = field.key as 'name' | 'description'; const fieldKey = field.key as 'name' | 'description';
const validationKey = `${productIndex}-${fieldKey}`;
// Mark as validating // Skip if validation is already in progress (auto-validation may have started)
setInlineAiValidating(`${productIndex}-${fieldKey}`, true); 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 // Helper to look up field option label
const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => { const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => {
@@ -744,8 +919,9 @@ const CellWrapper = memo(({
onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined} onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined}
isLoadingOptions={isLoadingOptions} isLoadingOptions={isLoadingOptions}
// Pass AI suggestion props for description field (MultilineInput handles it internally) // Pass AI suggestion props for description field (MultilineInput handles it internally)
// Only pass aiSuggestion when showSuggestion is true (respects dismissed state)
{...(field.key === 'description' && { {...(field.key === 'description' && {
aiSuggestion: fieldSuggestion, aiSuggestion: showSuggestion ? fieldSuggestion : undefined,
isAiValidating: isInlineAiValidating, isAiValidating: isInlineAiValidating,
onDismissAiSuggestion: () => { onDismissAiSuggestion: () => {
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description'); useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
@@ -1433,6 +1609,144 @@ const HeaderCheckbox = memo(() => {
HeaderCheckbox.displayName = 'HeaderCheckbox'; 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 (
<div
className="flex items-center gap-1 truncate w-full group relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setIsHovered(false)}
>
<span className="">{label}</span>
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
{isHovered && hasFillableCells && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
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'
)}
>
<Calculator className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
});
PriceColumnHeader.displayName = 'PriceColumnHeader';
/** /**
* Main table component * Main table component
* *
@@ -1535,18 +1849,29 @@ export const ValidationTable = () => {
}; };
// Data columns from fields with widths from config.ts // Data columns from fields with widths from config.ts
const dataColumns: ColumnDef<RowData>[] = fields.map((field) => ({ const dataColumns: ColumnDef<RowData>[] = 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, id: field.key,
header: () => ( header: () => isPriceColumn ? (
<PriceColumnHeader
fieldKey={field.key as 'msrp' | 'cost_each'}
label={field.label}
isRequired={isRequired}
/>
) : (
<div className="flex items-center gap-1 truncate"> <div className="flex items-center gap-1 truncate">
<span className="truncate">{field.label}</span> <span className="truncate">{field.label}</span>
{field.validations?.some((v: Validation) => v.rule === 'required') && ( {isRequired && (
<span className="text-destructive flex-shrink-0">*</span> <span className="text-destructive flex-shrink-0">*</span>
)} )}
</div> </div>
), ),
size: field.width || 150, size: field.width || 150,
})); };
});
return [selectionColumn, templateColumn, ...dataColumns]; return [selectionColumn, templateColumn, ...dataColumns];
}, [fields]); // CRITICAL: No selection-related deps! }, [fields]); // CRITICAL: No selection-related deps!

View File

@@ -197,8 +197,8 @@ const MultilineInputComponent = ({
<div <div
onClick={handleTriggerClick} onClick={handleTriggerClick}
className={cn( className={cn(
'px-2 py-1 rounded-md text-sm w-full cursor-pointer relative', 'pl-2 pr-4 py-1 rounded-md text-sm w-full cursor-pointer relative',
'overflow-hidden leading-tight h-[65px]', 'overflow-hidden leading-tight h-[65px] top-0',
'border', 'border',
hasError ? 'border-destructive bg-destructive/5' : 'border-input', hasError ? 'border-destructive bg-destructive/5' : 'border-input',
hasAiSuggestion && !hasError && 'border-purple-300 bg-purple-50/50 dark:border-purple-700 dark:bg-purple-950/20', hasAiSuggestion && !hasError && 'border-purple-300 bg-purple-50/50 dark:border-purple-700 dark:bg-purple-950/20',
@@ -249,7 +249,7 @@ const MultilineInputComponent = ({
align="start" align="start"
side="bottom" side="bottom"
alignOffset={0} alignOffset={0}
sideOffset={4} sideOffset={-65}
> >
<div className="flex flex-col"> <div className="flex flex-col">
{/* Close button */} {/* Close button */}
@@ -267,7 +267,7 @@ const MultilineInputComponent = ({
value={editValue} value={editValue}
onChange={handleChange} onChange={handleChange}
onWheel={handleTextareaWheel} 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'}...`} placeholder={`Enter ${field.label || 'text'}...`}
autoFocus autoFocus
/> />
@@ -337,7 +337,7 @@ const MultilineInputComponent = ({
onClick={handleAcceptSuggestion} onClick={handleAcceptSuggestion}
> >
<Check className="h-3 w-3 mr-1" /> <Check className="h-3 w-3 mr-1" />
Apply Replace With Suggestion
</Button> </Button>
<Button <Button
size="sm" size="sm"

View File

@@ -12,12 +12,10 @@ import {
AlertTriangle, AlertTriangle,
ChevronRight, ChevronRight,
XCircle, XCircle,
RefreshCw
} from 'lucide-react'; } from 'lucide-react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle DialogTitle
@@ -60,7 +58,6 @@ export function SanityCheckDialog({
result, result,
onProceed, onProceed,
onGoBack, onGoBack,
onRefresh,
onScrollToProduct, onScrollToProduct,
productNames = {}, productNames = {},
validationErrorCount = 0 validationErrorCount = 0
@@ -88,63 +85,35 @@ export function SanityCheckDialog({
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{isChecking ? ( {isChecking ? (
<> <>
<Loader2 className="h-5 w-5 animate-spin text-purple-500" /> Running Consistency Check...
Running Sanity Check...
</> </>
) : error ? ( ) : error ? (
<> <>
<XCircle className="h-5 w-5 text-red-500" /> <XCircle className="h-5 w-5 text-red-500" />
Sanity Check Failed Consistency Check Failed
</> </>
) : hasAnyIssues ? ( ) : hasAnyIssues ? (
<> <>
<AlertTriangle className="h-5 w-5 text-amber-500" /> <AlertTriangle className="h-5 w-5 text-amber-500" />
{hasValidationErrors && hasSanityIssues {hasValidationErrors && hasSanityIssues
? 'Validation Errors & Issues Found' ? 'Validation Errors & Consistency Issues Found'
: hasValidationErrors : hasValidationErrors
? 'Validation Errors' ? 'Validation Errors'
: 'Issues Found'} : 'Consistency Issues Found'}
</> </>
) : allClear ? ( ) : allClear ? (
<> <>
<CheckCircle className="h-5 w-5 text-green-500" /> Continue
Ready to Continue
</> </>
) : ( ) : (
'Pre-flight Check' <>
<CheckCircle className="h-5 w-5 text-green-500" />
Consistency Check
</>
)} )}
</DialogTitle> </DialogTitle>
{/* Refresh button - only show when not checking */}
{!isChecking && onRefresh && result && (
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="h-8 px-2 text-muted-foreground hover:text-foreground"
title="Run sanity check again"
>
<RefreshCw className="h-4 w-4 mr-1" />
Refresh
</Button>
)}
</div> </div>
<DialogDescription>
{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 && (
<span className="block text-xs text-muted-foreground mt-1">
Last checked {formatTimeAgo(result.checkedAt)}
</span>
)}
</DialogDescription>
</DialogHeader> </DialogHeader>
{/* Content */} {/* Content */}
@@ -173,9 +142,7 @@ export function SanityCheckDialog({
{/* Success state - only show if no validation errors either */} {/* Success state - only show if no validation errors either */}
{allClear && !hasValidationErrors && !isChecking && ( {allClear && !hasValidationErrors && !isChecking && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-green-50 border border-green-200"> <div className="flex items-start gap-3 p-4 rounded-lg bg-green-50 border border-green-200">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium text-green-800">All Clear!</p>
<p className="text-sm text-green-600 mt-1"> <p className="text-sm text-green-600 mt-1">
{result?.summary || 'No consistency issues detected in your products.'} {result?.summary || 'No consistency issues detected in your products.'}
</p> </p>
@@ -185,28 +152,33 @@ export function SanityCheckDialog({
{/* Validation errors warning */} {/* Validation errors warning */}
{hasValidationErrors && !isChecking && ( {hasValidationErrors && !isChecking && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-50 border border-red-200 mb-4"> <div className="flex items-start gap-3 p-4 rounded-lg bg-red-50 border border-red-200 mb-2">
<XCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<div> <div>
<p className="font-medium text-red-800">Validation Errors</p> <p className="text-sm text-red-600">
<p className="text-sm text-red-600 mt-1"> There {validationErrorCount === 1 ? 'is' : 'are'} {validationErrorCount} validation error{validationErrorCount === 1 ? '' : 's'} (required fields missing, invalid values, etc.) in your data.
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. These should be fixed before continuing.
</p> </p>
</div> </div>
</div> </div>
)} )}
{hasSanityIssues && !isChecking && (
<>
{/* Summary */}
{result?.summary && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-50 border border-amber-200">
<div>
<p className="text-sm text-amber-800">{result.summary}</p>
</div>
</div>
)}
</>
)}
{/* Sanity check issues list */} {/* Sanity check issues list */}
{hasSanityIssues && !isChecking && ( {hasSanityIssues && !isChecking && (
<ScrollArea className="max-h-[400px] pr-4"> <ScrollArea className="max-h-[400px] overflow-y-auto mt-4">
<div className="space-y-4"> <div className="space-y-4">
{/* Summary */}
{result?.summary && (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200">
<p className="text-sm text-amber-800">{result.summary}</p>
</div>
)}
{/* Issues grouped by field */} {/* Issues grouped by field */}
{Object.entries(issuesByField).map(([field, fieldIssues]) => ( {Object.entries(issuesByField).map(([field, fieldIssues]) => (
@@ -249,7 +221,7 @@ export function SanityCheckDialog({
<p className="text-sm text-gray-600">{issue.issue}</p> <p className="text-sm text-gray-600">{issue.issue}</p>
{issue.suggestion && ( {issue.suggestion && (
<p className="text-xs text-blue-600 mt-1"> <p className="text-xs text-blue-600 mt-1">
💡 {issue.suggestion} {issue.suggestion}
</p> </p>
)} )}
</div> </div>
@@ -288,8 +260,7 @@ export function SanityCheckDialog({
Go Back & Fix Go Back & Fix
</Button> </Button>
<Button onClick={onProceed} variant={hasValidationErrors ? 'destructive' : 'default'}> <Button onClick={onProceed} variant={hasValidationErrors ? 'destructive' : 'default'}>
{hasValidationErrors ? 'Proceed Despite Errors' : 'Proceed Anyway'} Proceed Anyway
<ChevronRight className="h-4 w-4 ml-1" />
</Button> </Button>
</> </>
) : null} ) : null}
@@ -321,46 +292,3 @@ function formatFieldName(field: string): string {
return fieldNames[field] || field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); 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.';
}

View File

@@ -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<string>[],
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<typeof buildProductPayload>
) {
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;

View File

@@ -1,54 +1,168 @@
/** /**
* useCopyDownValidation Hook * useCopyDownValidation Hook
* *
* Watches for copy-down operations on UPC-related fields (supplier, upc, barcode) * Watches for copy-down operations and triggers appropriate validations:
* and triggers UPC validation for affected rows using the existing validateUpc function. * - 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 * This avoids duplicating validation logic - we reuse the same code paths
* that handles individual cell blur events. * that handle individual cell blur events.
*/ */
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useValidationStore } from '../store/validationStore'; import { useValidationStore } from '../store/validationStore';
import { useUpcValidation } from './useUpcValidation'; 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<string>[], 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<typeof useValidationStore.getState>['rows'],
fields: Field<string>[],
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. * Should be called once in ValidationContainer to ensure validation runs.
*/ */
export const useCopyDownValidation = () => { export const useCopyDownValidation = () => {
const { validateUpc } = useUpcValidation(); const { validateUpc } = useUpcValidation();
// Subscribe to pending copy-down validation // Subscribe to pending validations
const pendingValidation = useValidationStore((state) => state.pendingCopyDownValidation); const pendingUpcValidation = useValidationStore((state) => state.pendingCopyDownValidation);
const pendingInlineAiValidation = useValidationStore((state) => state.pendingInlineAiValidation);
const clearPendingCopyDownValidation = useValidationStore((state) => state.clearPendingCopyDownValidation); const clearPendingCopyDownValidation = useValidationStore((state) => state.clearPendingCopyDownValidation);
const clearPendingInlineAiValidation = useValidationStore((state) => state.clearPendingInlineAiValidation);
// Handle UPC validation
useEffect(() => { useEffect(() => {
if (!pendingValidation) return; if (!pendingUpcValidation) return;
const { fieldKey, affectedRows } = pendingValidation; const { affectedRows } = pendingUpcValidation;
// Get current rows to check supplier and UPC values
const rows = useValidationStore.getState().rows; const rows = useValidationStore.getState().rows;
// Process each affected row
const validationPromises = affectedRows.map(async (rowIndex) => { const validationPromises = affectedRows.map(async (rowIndex) => {
const row = rows[rowIndex]; const row = rows[rowIndex];
if (!row) return; if (!row) return;
// Get supplier and UPC values
const supplierId = row.supplier ? String(row.supplier) : ''; const supplierId = row.supplier ? String(row.supplier) : '';
const upcValue = row.upc ? String(row.upc) : (row.barcode ? String(row.barcode) : ''); const upcValue = row.upc ? String(row.upc) : (row.barcode ? String(row.barcode) : '');
// Only validate if we have both supplier and UPC
if (supplierId && upcValue) { if (supplierId && upcValue) {
await validateUpc(rowIndex, supplierId, upcValue); await validateUpc(rowIndex, supplierId, upcValue);
} }
}); });
// Run all validations and then clear the pending state
Promise.all(validationPromises).then(() => { Promise.all(validationPromises).then(() => {
clearPendingCopyDownValidation(); 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<void>[] = [];
// 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]);
}; };

View File

@@ -18,6 +18,7 @@ import { useTemplateManagement } from './hooks/useTemplateManagement';
import { useUpcValidation } from './hooks/useUpcValidation'; import { useUpcValidation } from './hooks/useUpcValidation';
import { useValidationActions } from './hooks/useValidationActions'; import { useValidationActions } from './hooks/useValidationActions';
import { useProductLines } from './hooks/useProductLines'; import { useProductLines } from './hooks/useProductLines';
import { useAutoInlineAiValidation } from './hooks/useAutoInlineAiValidation';
import { BASE_IMPORT_FIELDS } from '../../config'; import { BASE_IMPORT_FIELDS } from '../../config';
import config from '@/config'; import config from '@/config';
import type { ValidationStepProps } from './store/types'; import type { ValidationStepProps } from './store/types';
@@ -120,6 +121,9 @@ export const ValidationStep = ({
const { validateAllRows } = useValidationActions(); const { validateAllRows } = useValidationActions();
const { prefetchAllLines } = useProductLines(); const { prefetchAllLines } = useProductLines();
// Auto inline AI validation - triggers when ready phase is reached
useAutoInlineAiValidation();
// Fetch field options // Fetch field options
const { data: fieldOptions, isLoading: optionsLoading, error: optionsError } = useQuery({ const { data: fieldOptions, isLoading: optionsLoading, error: optionsError } = useQuery({
queryKey: ['field-options'], queryKey: ['field-options'],
@@ -128,6 +132,9 @@ export const ValidationStep = ({
retry: 2, 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 // Initialize store with data
useEffect(() => { useEffect(() => {
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase); console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
@@ -140,6 +147,17 @@ export const ValidationStep = ({
return; 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; initStartedRef.current = true;
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows'); console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
@@ -154,7 +172,7 @@ export const ValidationStep = ({
console.log('[ValidationStep] Calling initialize()'); console.log('[ValidationStep] Calling initialize()');
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file); initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
console.log('[ValidationStep] initialize() called'); console.log('[ValidationStep] initialize() called');
}, [initialData, file, initialize, initPhase]); }, [initialData, file, initialize, initPhase, storeRows.length]);
// Update fields when options are loaded // Update fields when options are loaded
// CRITICAL: Check store state (not ref) because initialize() resets the store // CRITICAL: Check store state (not ref) because initialize() resets the store

View File

@@ -160,6 +160,18 @@ export interface PendingCopyDownValidation {
affectedRows: number[]; 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 // Dialog State Types
// ============================================================================= // =============================================================================
@@ -291,8 +303,14 @@ export interface InlineAiSuggestion {
export interface InlineAiState { export interface InlineAiState {
/** Map of product __index to their inline suggestions */ /** Map of product __index to their inline suggestions */
suggestions: Map<string, InlineAiSuggestion>; suggestions: Map<string, InlineAiSuggestion>;
/** Products currently being validated */ /** Products currently being validated (format: "productIndex-field") */
validating: Set<string>; validating: Set<string>;
/**
* 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<string>;
} }
// ============================================================================= // =============================================================================
@@ -370,6 +388,7 @@ export interface ValidationState {
// === Copy-Down Mode === // === Copy-Down Mode ===
copyDownMode: CopyDownState; copyDownMode: CopyDownState;
pendingCopyDownValidation: PendingCopyDownValidation | null; pendingCopyDownValidation: PendingCopyDownValidation | null;
pendingInlineAiValidation: PendingInlineAiValidation | null;
// === Dialogs === // === Dialogs ===
dialogs: DialogState; dialogs: DialogState;
@@ -458,6 +477,7 @@ export interface ValidationActions {
completeCopyDown: (targetRowIndex: number) => void; completeCopyDown: (targetRowIndex: number) => void;
setTargetRowHover: (rowIndex: number | null) => void; setTargetRowHover: (rowIndex: number | null) => void;
clearPendingCopyDownValidation: () => void; clearPendingCopyDownValidation: () => void;
clearPendingInlineAiValidation: () => void;
// === Dialogs === // === Dialogs ===
setDialogs: (updates: Partial<DialogState>) => void; setDialogs: (updates: Partial<DialogState>) => void;
@@ -484,6 +504,8 @@ export interface ValidationActions {
acceptInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => void; acceptInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => void;
clearInlineAiSuggestion: (productIndex: string, field?: 'name' | 'description') => void; clearInlineAiSuggestion: (productIndex: string, field?: 'name' | 'description') => void;
setInlineAiValidating: (productIndex: string, validating: boolean) => void; setInlineAiValidating: (productIndex: string, validating: boolean) => void;
markInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => void;
isInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => boolean;
// === Output === // === Output ===
getCleanedData: () => CleanRowData[]; getCleanedData: () => CleanRowData[];

View File

@@ -110,6 +110,7 @@ const getInitialState = (): ValidationState => ({
// Copy-Down Mode // Copy-Down Mode
copyDownMode: { ...initialCopyDownState }, copyDownMode: { ...initialCopyDownState },
pendingCopyDownValidation: null, pendingCopyDownValidation: null,
pendingInlineAiValidation: null,
// Dialogs // Dialogs
dialogs: { ...initialDialogState }, dialogs: { ...initialDialogState },
@@ -129,6 +130,7 @@ const getInitialState = (): ValidationState => ({
inlineAi: { inlineAi: {
suggestions: new Map(), suggestions: new Map(),
validating: new Set(), validating: new Set(),
autoValidated: new Set(),
}, },
// File // File
@@ -256,13 +258,45 @@ export const useValidationStore = create<ValidationStore>()(
copyDown: (fromRowIndex: number, fieldKey: string, toRowIndex?: number) => { copyDown: (fromRowIndex: number, fieldKey: string, toRowIndex?: number) => {
set((state) => { set((state) => {
const sourceValue = state.rows[fromRowIndex]?.[fieldKey]; const sourceRow = state.rows[fromRowIndex];
const sourceValue = sourceRow?.[fieldKey];
if (sourceValue === undefined) return; if (sourceValue === undefined) return;
const endIndex = toRowIndex ?? state.rows.length - 1; 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++) { for (let i = fromRowIndex + 1; i <= endIndex; i++) {
if (state.rows[i]) { const targetRow = state.rows[i];
state.rows[i][fieldKey] = sourceValue; 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<ValidationStore>()(
affectedRows, 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<ValidationStore>()(
}); });
}, },
clearPendingInlineAiValidation: () => {
set((state) => {
state.pendingInlineAiValidation = null;
});
},
// ========================================================================= // =========================================================================
// Dialogs // Dialogs
// ========================================================================= // =========================================================================
@@ -853,6 +930,16 @@ export const useValidationStore = create<ValidationStore>()(
}); });
}, },
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 // Output
// ========================================================================= // =========================================================================