Add AI embeddings and suggestions for categories, a few validation step tweaks/fixes

This commit is contained in:
2026-01-19 11:34:55 -05:00
parent 9ce84fe5b9
commit 43d76e011d
20 changed files with 5311 additions and 176 deletions
@@ -93,7 +93,7 @@ export const BASE_IMPORT_FIELDS = [
description: "Internal notions number",
alternateMatches: ["notions #","nmc"],
fieldType: { type: "input" },
width: 100,
width: 110,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
@@ -106,7 +106,7 @@ export const BASE_IMPORT_FIELDS = [
description: "Product name/title",
alternateMatches: ["sku description","product name"],
fieldType: { type: "input" },
width: 500,
width: 400,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
@@ -133,7 +133,7 @@ export const BASE_IMPORT_FIELDS = [
description: "Quantity of items per individual unit",
alternateMatches: ["inner pack", "inner", "min qty", "unit qty", "min. order qty", "supplier qty/unit"],
fieldType: { type: "input" },
width: 80,
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
@@ -148,7 +148,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" },
@@ -312,7 +312,7 @@ export const BASE_IMPORT_FIELDS = [
type: "multi-select",
options: [], // Will be populated from API
},
width: 350,
width: 400,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
},
{
@@ -1917,18 +1917,11 @@ const MatchColumnsStepComponent = <T extends string>({
)}
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
disabled={isLoading}
onClick={() => handleOnContinue(false)}
>
{translations.matchColumnsStep.nextButtonTitle}
</Button>
<Button
disabled={isLoading}
onClick={() => handleOnContinue(true)}
>
{translations.matchColumnsStep.nextButtonTitle} (New Validation)
{translations.matchColumnsStep.nextButtonTitle}
</Button>
</div>
</div>
@@ -4,7 +4,6 @@ import { UploadStep } from "./UploadStep/UploadStep"
import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStepNew } from "./ValidationStepNew"
import { ValidationStep } from "./ValidationStep"
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
@@ -220,36 +219,8 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
/>
)
case StepType.validateData:
// Always use the new ValidationStepNew component
return (
<ValidationStepNew
initialData={state.data}
file={uploadedFile || new File([], "empty.xlsx")}
onBack={() => {
// If we started from scratch, we need to go back to the upload step
if (state.isFromScratch) {
onNext({
type: StepType.upload
});
} else if (onBack) {
// Use the provided onBack function
onBack();
}
}}
onNext={(validatedData: any[]) => {
// Go to image upload step with the validated data
onNext({
type: StepType.imageUpload,
data: validatedData,
file: uploadedFile || new File([], "empty.xlsx"),
globalSelections: state.globalSelections
});
}}
isFromScratch={state.isFromScratch}
/>
)
case StepType.validateDataNew:
// New Zustand-based ValidationStep component
// Zustand-based ValidationStep component (both cases now use this)
return (
<ValidationStep
initialData={state.data}
@@ -282,7 +253,15 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
<ImageUploadStep
data={state.data}
file={state.file}
onBack={onBack}
onBack={() => {
// Go back to the validation step with the current data
onNext({
type: StepType.validateDataNew,
data: state.data,
file: state.file,
globalSelections: state.globalSelections
});
}}
onSubmit={(data, file, options) => {
// Create a Result object from the array data
const result = {
@@ -0,0 +1,192 @@
/**
* Suggestion Badges Component
*
* Displays AI-suggested options inline for categories, themes, and colors.
* Shows similarity scores and allows one-click selection.
*/
import { Sparkles, Loader2, Plus, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { TaxonomySuggestion } from '../store/types';
interface SuggestionBadgesProps {
/** Suggestions to display */
suggestions: TaxonomySuggestion[];
/** Currently selected values (IDs) */
selectedValues: (string | number)[];
/** Callback when a suggestion is clicked */
onSelect: (id: number) => void;
/** Whether suggestions are loading */
isLoading?: boolean;
/** Maximum suggestions to show */
maxSuggestions?: number;
/** Minimum similarity to show (0-1) */
minSimilarity?: number;
/** Label for the section */
label?: string;
/** Compact mode for smaller displays */
compact?: boolean;
/** Show similarity scores */
showScores?: boolean;
/** Custom class name */
className?: string;
}
export function SuggestionBadges({
suggestions,
selectedValues,
onSelect,
isLoading = false,
maxSuggestions = 5,
minSimilarity = 0,
label = 'Suggested',
compact = false,
showScores = true,
className,
}: SuggestionBadgesProps) {
// Filter and limit suggestions
const filteredSuggestions = suggestions
.filter(s => s.similarity >= minSimilarity)
.slice(0, maxSuggestions);
// Don't render if no suggestions and not loading
if (!isLoading && filteredSuggestions.length === 0) {
return null;
}
const isSelected = (id: number) => {
return selectedValues.some(v => String(v) === String(id));
};
return (
<div className={cn('flex flex-wrap items-center gap-1.5', className)}>
{/* Label */}
<div className={cn(
'flex items-center gap-1 text-purple-600 dark:text-purple-400',
compact ? 'text-[10px]' : 'text-xs'
)}>
<Sparkles className={compact ? 'h-2.5 w-2.5' : 'h-3 w-3'} />
{!compact && <span className="font-medium">{label}:</span>}
</div>
{/* Loading state */}
{isLoading && (
<div className="flex items-center gap-1 text-gray-400">
<Loader2 className={cn('animate-spin', compact ? 'h-2.5 w-2.5' : 'h-3 w-3')} />
{!compact && <span className="text-xs">Loading...</span>}
</div>
)}
{/* Suggestion badges */}
{filteredSuggestions.map((suggestion) => {
const selected = isSelected(suggestion.id);
const similarityPercent = Math.round(suggestion.similarity * 100);
return (
<button
key={suggestion.id}
type="button"
onClick={() => onSelect(suggestion.id)}
disabled={selected}
className={cn(
'inline-flex items-center gap-1 rounded-full border transition-colors',
compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
selected
? 'border-green-300 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-950 dark:text-green-400'
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100 dark:border-purple-800 dark:bg-purple-950/50 dark:text-purple-300 dark:hover:bg-purple-900/50'
)}
title={suggestion.fullPath || suggestion.name}
>
{selected ? (
<Check className={compact ? 'h-2 w-2' : 'h-2.5 w-2.5'} />
) : (
<Plus className={compact ? 'h-2 w-2' : 'h-2.5 w-2.5'} />
)}
<span className="truncate max-w-[120px]">{suggestion.name}</span>
{showScores && !compact && (
<span className={cn(
'opacity-60',
selected ? 'text-green-600' : 'text-purple-500'
)}>
{similarityPercent}%
</span>
)}
</button>
);
})}
</div>
);
}
/**
* Inline suggestion for a single field (used inside dropdowns)
*/
interface InlineSuggestionProps {
suggestion: TaxonomySuggestion;
isSelected: boolean;
onSelect: () => void;
showScore?: boolean;
}
export function InlineSuggestion({
suggestion,
isSelected,
onSelect,
showScore = true,
}: InlineSuggestionProps) {
const similarityPercent = Math.round(suggestion.similarity * 100);
return (
<div
className={cn(
'flex items-center justify-between px-2 py-1.5 cursor-pointer',
isSelected
? 'bg-green-50 dark:bg-green-950/30'
: 'bg-purple-50/50 hover:bg-purple-100/50 dark:bg-purple-950/20 dark:hover:bg-purple-900/30'
)}
onClick={onSelect}
>
<div className="flex items-center gap-2 min-w-0">
<Sparkles className="h-3 w-3 text-purple-500 flex-shrink-0" />
<span className="truncate text-sm">
{suggestion.fullPath || suggestion.name}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
{showScore && (
<span className="text-xs text-purple-500 dark:text-purple-400">
{similarityPercent}%
</span>
)}
{isSelected ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Plus className="h-3.5 w-3.5 text-purple-400" />
)}
</div>
</div>
);
}
/**
* Suggestion section header for dropdowns
*/
interface SuggestionSectionHeaderProps {
isLoading?: boolean;
count?: number;
}
export function SuggestionSectionHeader({ isLoading, count }: SuggestionSectionHeaderProps) {
return (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900">
<Sparkles className="h-3 w-3" />
<span>AI Suggested</span>
{isLoading && <Loader2 className="h-3 w-3 animate-spin" />}
{!isLoading && count !== undefined && (
<span className="text-purple-400 dark:text-purple-500">({count})</span>
)}
</div>
);
}
export default SuggestionBadges;
@@ -6,7 +6,7 @@
* Note: Initialization effects are in index.tsx so they run before this mounts.
*/
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { useValidationStore } from '../store/validationStore';
import {
useTotalErrorCount,
@@ -21,11 +21,13 @@ import { FloatingSelectionBar } from './FloatingSelectionBar';
import { useAiValidationFlow } from '../hooks/useAiValidation';
import { useFieldOptions } from '../hooks/useFieldOptions';
import { useTemplateManagement } from '../hooks/useTemplateManagement';
import { useCopyDownValidation } from '../hooks/useCopyDownValidation';
import { AiValidationProgressDialog } from '../dialogs/AiValidationProgress';
import { AiValidationResultsDialog } from '../dialogs/AiValidationResults';
import { AiDebugDialog } from '../dialogs/AiDebugDialog';
import { TemplateForm } from '@/components/templates/TemplateForm';
import type { CleanRowData } from '../store/types';
import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext';
import type { CleanRowData, RowData } from '../store/types';
interface ValidationContainerProps {
onBack?: () => void;
@@ -57,6 +59,32 @@ export const ValidationContainer = ({
const { data: fieldOptionsData } = useFieldOptions();
const { loadTemplates } = useTemplateManagement();
// Handle UPC validation after copy-down operations on supplier/upc fields
useCopyDownValidation();
// Get initial products for AI suggestions (read once via ref to avoid re-fetching)
const initialProductsRef = useRef<RowData[] | null>(null);
if (initialProductsRef.current === null) {
initialProductsRef.current = useValidationStore.getState().rows;
}
// Create stable lookup functions for company/line names
const getCompanyName = useCallback((id: string): string | undefined => {
const companies = fieldOptionsData?.companies || [];
const company = companies.find(c => c.value === id);
return company?.label;
}, [fieldOptionsData?.companies]);
const getLineName = useCallback((id: string): string | undefined => {
// Lines are fetched dynamically per company, check the cache
const cache = useValidationStore.getState().productLinesCache;
for (const lines of cache.values()) {
const line = lines.find(l => l.value === id);
if (line) return line.label;
}
return undefined;
}, []);
// Convert field options to TemplateForm format
const templateFormFieldOptions = useMemo(() => {
if (!fieldOptionsData) return null;
@@ -94,69 +122,76 @@ export const ValidationContainer = ({
}, [onBack]);
return (
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
{/* Toolbar */}
<ValidationToolbar
rowCount={rowCount}
errorCount={totalErrorCount}
rowsWithErrors={rowsWithErrorsCount}
/>
<AiSuggestionsProvider
getCompanyName={getCompanyName}
getLineName={getLineName}
initialProducts={initialProductsRef.current || undefined}
autoInitialize={!!fieldOptionsData}
>
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
{/* Toolbar */}
<ValidationToolbar
rowCount={rowCount}
errorCount={totalErrorCount}
rowsWithErrors={rowsWithErrorsCount}
/>
{/* Main table area */}
<div className="flex-1 overflow-hidden">
<ValidationTable />
{/* Main table area */}
<div className="flex-1 overflow-hidden">
<ValidationTable />
</div>
{/* Footer with navigation */}
<ValidationFooter
onBack={handleBack}
onNext={handleNext}
canGoBack={!!onBack}
canProceed={totalErrorCount === 0}
errorCount={totalErrorCount}
rowCount={rowCount}
onAiValidate={aiValidation.validate}
isAiValidating={aiValidation.isValidating}
onShowDebug={aiValidation.showPromptPreview}
/>
{/* Floating selection bar - appears when rows selected */}
<FloatingSelectionBar />
{/* AI Validation dialogs */}
{aiValidation.isValidating && aiValidation.progress && (
<AiValidationProgressDialog
progress={aiValidation.progress}
onCancel={aiValidation.cancel}
/>
)}
{aiValidation.results && !aiValidation.isValidating && (
<AiValidationResultsDialog
results={aiValidation.results}
revertedChanges={aiValidation.revertedChanges}
onRevert={aiValidation.revertChange}
onAccept={aiValidation.acceptChange}
onDismiss={aiValidation.dismissResults}
/>
)}
{/* AI Debug Dialog - for viewing prompt */}
<AiDebugDialog
open={aiValidation.showDebugDialog}
onClose={aiValidation.closePromptPreview}
debugData={aiValidation.debugPrompt}
/>
{/* Template form dialog - for saving row as template */}
<TemplateForm
isOpen={isTemplateFormOpen}
onClose={closeTemplateForm}
onSuccess={handleTemplateFormSuccess}
initialData={templateFormData as Parameters<typeof TemplateForm>[0]['initialData']}
mode="create"
fieldOptions={templateFormFieldOptions}
/>
</div>
{/* Footer with navigation */}
<ValidationFooter
onBack={handleBack}
onNext={handleNext}
canGoBack={!!onBack}
canProceed={totalErrorCount === 0}
errorCount={totalErrorCount}
rowCount={rowCount}
onAiValidate={aiValidation.validate}
isAiValidating={aiValidation.isValidating}
onShowDebug={aiValidation.showPromptPreview}
/>
{/* Floating selection bar - appears when rows selected */}
<FloatingSelectionBar />
{/* AI Validation dialogs */}
{aiValidation.isValidating && aiValidation.progress && (
<AiValidationProgressDialog
progress={aiValidation.progress}
onCancel={aiValidation.cancel}
/>
)}
{aiValidation.results && !aiValidation.isValidating && (
<AiValidationResultsDialog
results={aiValidation.results}
revertedChanges={aiValidation.revertedChanges}
onRevert={aiValidation.revertChange}
onAccept={aiValidation.acceptChange}
onDismiss={aiValidation.dismissResults}
/>
)}
{/* AI Debug Dialog - for viewing prompt */}
<AiDebugDialog
open={aiValidation.showDebugDialog}
onClose={aiValidation.closePromptPreview}
debugData={aiValidation.debugPrompt}
/>
{/* Template form dialog - for saving row as template */}
<TemplateForm
isOpen={isTemplateFormOpen}
onClose={closeTemplateForm}
onSuccess={handleTemplateFormSuccess}
initialData={templateFormData as Parameters<typeof TemplateForm>[0]['initialData']}
mode="create"
fieldOptions={templateFormFieldOptions}
/>
</div>
</AiSuggestionsProvider>
);
};
@@ -47,6 +47,12 @@ import { ComboboxCell } from './cells/ComboboxCell';
import { MultiSelectCell } from './cells/MultiSelectCell';
import { MultilineInput } from './cells/MultilineInput';
// AI Suggestions context
import { useAiSuggestionsContext } from '../contexts/AiSuggestionsContext';
// Fields that trigger AI suggestion refresh when changed
const AI_EMBEDDING_FIELDS = ['company', 'line', 'name', 'description'] as const;
// Threshold for switching to ComboboxCell (with search) instead of SelectCell
const COMBOBOX_OPTION_THRESHOLD = 50;
@@ -96,6 +102,8 @@ const EMPTY_ROW_ERRORS: Record<string, ValidationError[]> = {};
interface CellWrapperProps {
field: Field<string>;
rowIndex: number;
/** Product's unique __index for AI suggestions */
productIndex: string;
value: unknown;
errors: ValidationError[];
isValidating: boolean;
@@ -124,6 +132,7 @@ interface CellWrapperProps {
const CellWrapper = memo(({
field,
rowIndex,
productIndex,
value,
errors,
isValidating,
@@ -142,6 +151,11 @@ const CellWrapper = memo(({
const needsCompany = field.key === 'line';
const needsLine = field.key === 'subline';
// AI suggestions context - for notifying when embedding fields change
// This uses stable callbacks so it won't cause re-renders
const aiSuggestions = useAiSuggestionsContext();
const isEmbeddingField = AI_EMBEDDING_FIELDS.includes(field.key as typeof AI_EMBEDDING_FIELDS[number]);
// Check if cell has a value (for showing copy-down button)
const hasValue = value !== undefined && value !== null && value !== '';
@@ -390,8 +404,17 @@ const CellWrapper = memo(({
}
}
}
// Notify AI suggestions system when embedding fields change
// This triggers a refresh of category/theme/color suggestions
if (isEmbeddingField && aiSuggestions) {
const currentRow = useValidationStore.getState().rows[rowIndex];
if (currentRow) {
aiSuggestions.handleFieldBlur(currentRow, field.key);
}
}
}, 0);
}, [rowIndex, field.key]);
}, [rowIndex, field.key, isEmbeddingField, aiSuggestions]);
// Stable callback for fetching options (for line/subline dropdowns)
const handleFetchOptions = useCallback(async () => {
@@ -481,6 +504,7 @@ const CellWrapper = memo(({
field={field}
options={options}
rowIndex={rowIndex}
productIndex={productIndex}
isValidating={isValidating}
errors={errors}
onChange={handleChange}
@@ -553,6 +577,7 @@ CellWrapper.displayName = 'CellWrapper';
* Template column width
*/
const TEMPLATE_COLUMN_WIDTH = 200;
const NAME_COLUMN_STICKY_LEFT = 0;
/**
* TemplateCell Component
@@ -777,9 +802,12 @@ const VirtualRow = memo(({
useCallback((state) => state.errors.get(rowIndex) ?? EMPTY_ROW_ERRORS, [rowIndex])
);
// DON'T subscribe to validatingCells - check it during render instead
// DON'T subscribe to validatingCells for most fields - check it during render instead
// This avoids creating new objects in selectors which causes infinite loops
// Validation status changes are rare, so reading via getState() is fine
// EXCEPTION: Subscribe specifically for item_number so it shows loading state during UPC validation
const isItemNumberValidating = useValidationStore(
useCallback((state) => state.validatingCells.has(`${rowIndex}-item_number`), [rowIndex])
);
// Subscribe to selection status
const isSelected = useValidationStore(
@@ -860,7 +888,10 @@ const VirtualRow = memo(({
const columnWidth = columns[fieldIndex + 2]?.size || field.width || 150;
const fieldErrors = rowErrors[field.key] || EMPTY_ERRORS;
// Check validating status via getState() - not subscribed to avoid object creation
const isValidating = useValidationStore.getState().validatingCells.has(`${rowIndex}-${field.key}`);
// EXCEPTION: item_number uses the subscribed isItemNumberValidating for reactive loading state
const isValidating = field.key === 'item_number'
? isItemNumberValidating
: useValidationStore.getState().validatingCells.has(`${rowIndex}-${field.key}`);
// CRITICAL: Only pass company/line to cells that need them!
// Passing to all cells breaks memoization - when company changes, ALL cells re-render
@@ -889,20 +920,27 @@ const VirtualRow = memo(({
copyDownMode.targetRowIndex !== null &&
rowIndex <= copyDownMode.targetRowIndex;
const isNameColumn = field.key === 'name';
return (
<div
key={field.key}
data-cell-field={field.key}
className="px-2 py-1 border-r last:border-r-0 flex items-center overflow-hidden"
className={cn(
"px-2 py-1 border-r last:border-r-0 flex items-center overflow-hidden",
isNameColumn && "lg:sticky lg:z-10 lg:bg-background lg:shadow-md"
)}
style={{
width: columnWidth,
minWidth: columnWidth,
flexShrink: 0,
...(isNameColumn && { left: NAME_COLUMN_STICKY_LEFT }),
}}
>
<CellWrapper
field={field}
rowIndex={rowIndex}
productIndex={rowId}
value={rowData?.[field.key]}
errors={fieldErrors}
isValidating={isValidating}
@@ -1088,21 +1126,28 @@ export const ValidationTable = () => {
className="flex h-full"
style={{ minWidth: totalTableWidth }}
>
{columns.map((column, index) => (
<div
key={column.id || index}
className="px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0"
style={{
width: column.size || 150,
minWidth: column.size || 150,
flexShrink: 0,
}}
>
{typeof column.header === 'function'
? column.header({} as any)
: column.header}
</div>
))}
{columns.map((column, index) => {
const isNameColumn = column.id === 'name';
return (
<div
key={column.id || index}
className={cn(
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0",
isNameColumn && "lg:sticky lg:z-20 lg:bg-muted lg:shadow-md"
)}
style={{
width: column.size || 150,
minWidth: column.size || 150,
flexShrink: 0,
...(isNameColumn && { left: NAME_COLUMN_STICKY_LEFT }),
}}
>
{typeof column.header === 'function'
? column.header({} as any)
: column.header}
</div>
);
})}
</div>
</div>
@@ -6,10 +6,14 @@
*
* PERFORMANCE: Uses uncontrolled open state for Popover.
* Controlled open state can cause delays due to React state processing.
*
* AI SUGGESTIONS: For categories, themes, and colors fields, this component
* displays AI-powered suggestions based on product embeddings. Suggestions
* appear at the top of the dropdown with similarity scores.
*/
import { useCallback, useMemo, memo, useState } from 'react';
import { Check, ChevronsUpDown, AlertCircle } from 'lucide-react';
import { Check, ChevronsUpDown, AlertCircle, Sparkles, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
@@ -34,8 +38,9 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
import type { ValidationError, TaxonomySuggestion } from '../../store/types';
import { ErrorType } from '../../store/types';
import { useCellSuggestions } from '../../contexts/AiSuggestionsContext';
// Extended option type to include hex color values
interface MultiSelectOption extends SelectOption {
@@ -49,6 +54,8 @@ interface MultiSelectCellProps {
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
/** Product's unique __index for AI suggestions */
productIndex?: string;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
@@ -56,6 +63,10 @@ interface MultiSelectCellProps {
onFetchOptions?: () => void;
}
// Fields that support AI suggestions
const SUGGESTION_FIELDS = ['categories', 'themes', 'colors'] as const;
type SuggestionField = typeof SUGGESTION_FIELDS[number];
/**
* Helper to extract hex color from option
* Supports hex, hexColor, and hex_color field names
@@ -79,6 +90,7 @@ const MultiSelectCellComponent = ({
value,
field,
options = [],
productIndex,
isValidating,
errors,
onChange: _onChange, // Unused - onBlur handles both update and validation
@@ -86,6 +98,21 @@ const MultiSelectCellComponent = ({
}: MultiSelectCellProps) => {
const [open, setOpen] = useState(false);
// Get AI suggestions for categories, themes, and colors
const supportsSuggestions = SUGGESTION_FIELDS.includes(field.key as SuggestionField);
const suggestions = useCellSuggestions(productIndex || '');
// Get the right suggestions based on field type
const fieldSuggestions: TaxonomySuggestion[] = useMemo(() => {
if (!supportsSuggestions || !productIndex) return [];
switch (field.key) {
case 'categories': return suggestions.categories;
case 'themes': return suggestions.themes;
case 'colors': return suggestions.colors;
default: return [];
}
}, [supportsSuggestions, productIndex, field.key, suggestions]);
// Handle wheel scroll in dropdown - stop propagation to prevent table scroll
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
@@ -216,48 +243,132 @@ const MultiSelectCellComponent = ({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder={`Search ${field.label}...`} />
<CommandList>
<CommandEmpty>No options found.</CommandEmpty>
<div
className="max-h-[200px] overflow-y-auto overscroll-contain"
className="max-h-[250px] overflow-y-auto overscroll-contain"
onWheel={handleWheel}
>
<CommandGroup>
{options.map((option) => {
const hexColor = field.key === 'colors' ? getOptionHex(option as MultiSelectOption) : undefined;
const isWhite = hexColor ? isWhiteColor(hexColor) : false;
{/* Selected items section - floats to top of dropdown */}
{selectedValues.length > 0 && (
<CommandGroup>
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50/80 dark:bg-green-950/40 border-b border-green-100 dark:border-green-900">
<Check className="h-3 w-3" />
<span>Selected ({selectedValues.length})</span>
</div>
{selectedValues.map((selectedVal) => {
const option = options.find((opt) => opt.value === selectedVal) as MultiSelectOption | undefined;
const hexColor = field.key === 'colors' && option ? getOptionHex(option) : undefined;
const isWhite = hexColor ? isWhiteColor(hexColor) : false;
const label = option?.label || selectedVal;
return (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(option.value)
? 'opacity-100'
: 'opacity-0'
return (
<CommandItem
key={`selected-${selectedVal}`}
value={`selected-${label}`}
onSelect={() => handleSelect(selectedVal)}
className="bg-green-50/50 dark:bg-green-950/30"
>
<Check className="mr-2 h-4 w-4 opacity-100 text-green-600" />
{field.key === 'colors' && hexColor && (
<span
className={cn(
'inline-block h-3.5 w-3.5 rounded-full mr-2 flex-shrink-0',
isWhite && 'border border-black'
)}
style={{ backgroundColor: hexColor }}
/>
)}
/>
{/* Color circle for colors field */}
{field.key === 'colors' && hexColor && (
<span
className={cn(
'inline-block h-3.5 w-3.5 rounded-full mr-2 flex-shrink-0',
isWhite && 'border border-black'
{label}
</CommandItem>
);
})}
</CommandGroup>
)}
{/* AI Suggestions section - shown below selected items */}
{supportsSuggestions && (fieldSuggestions.length > 0 || suggestions.isLoading) && (
<CommandGroup>
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900">
<Sparkles className="h-3 w-3" />
<span>Suggested</span>
{suggestions.isLoading && <Loader2 className="h-3 w-3 animate-spin" />}
</div>
{fieldSuggestions.slice(0, 5).map((suggestion) => {
const isSelected = selectedValues.includes(String(suggestion.id));
// Skip suggestions that are already in the Selected section
if (isSelected) return null;
const similarityPercent = Math.round(suggestion.similarity * 100);
const hexColor = field.key === 'colors'
? options.find(o => o.value === String(suggestion.id)) as MultiSelectOption | undefined
: undefined;
const suggestionHex = hexColor ? getOptionHex(hexColor) : undefined;
return (
<CommandItem
key={`suggestion-${suggestion.id}`}
value={`suggestion-${suggestion.name}`}
onSelect={() => handleSelect(String(suggestion.id))}
className="bg-purple-50/30 dark:bg-purple-950/20"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Check className="h-4 w-4 flex-shrink-0 opacity-0" />
{/* Color circle for colors */}
{field.key === 'colors' && suggestionHex && (
<span
className={cn(
'inline-block h-3.5 w-3.5 rounded-full flex-shrink-0',
isWhiteColor(suggestionHex) && 'border border-black'
)}
style={{ backgroundColor: suggestionHex }}
/>
)}
style={{ backgroundColor: hexColor }}
/>
)}
{option.label}
</CommandItem>
);
})}
{/* Show full path for categories/themes, just name for colors */}
<span className="" title={suggestion.fullPath || suggestion.name}>
{field.key === 'colors' ? suggestion.name : (suggestion.fullPath || suggestion.name)}
</span>
</div>
<span className="text-xs text-purple-500 dark:text-purple-400 ml-2 flex-shrink-0">
{similarityPercent}%
</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{/* Regular options - excludes selected items (shown in Selected section above) */}
<CommandGroup heading={selectedValues.length > 0 || (supportsSuggestions && fieldSuggestions.length > 0) ? "All Options" : undefined}>
{options
.filter((option) => !selectedValues.includes(option.value))
.map((option) => {
const hexColor = field.key === 'colors' ? getOptionHex(option as MultiSelectOption) : undefined;
const isWhite = hexColor ? isWhiteColor(hexColor) : false;
return (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
>
<Check className="mr-2 h-4 w-4 opacity-0" />
{/* Color circle for colors field */}
{field.key === 'colors' && hexColor && (
<span
className={cn(
'inline-block h-3.5 w-3.5 rounded-full mr-2 flex-shrink-0',
isWhite && 'border border-black'
)}
style={{ backgroundColor: hexColor }}
/>
)}
{option.label}
</CommandItem>
);
})}
</CommandGroup>
</div>
</CommandList>
@@ -9,6 +9,12 @@ import { useState, useCallback, useRef, useEffect, memo } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { X, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { Field, SelectOption } from '../../../../types';
@@ -114,24 +120,42 @@ const MultilineInputComponent = ({
// Calculate display value
const displayValue = localDisplayValue !== null ? localDisplayValue : String(value ?? '');
// Tooltip content - show full description or error message
const tooltipContent = errorMessage || displayValue;
const showTooltip = tooltipContent && tooltipContent.length > 30;
return (
<div className="w-full relative" ref={cellRef}>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<div
onClick={handleTriggerClick}
className={cn(
'px-2 py-1 h-8 rounded-md text-sm w-full cursor-pointer',
'overflow-hidden whitespace-nowrap text-ellipsis',
'border',
hasError ? 'border-destructive bg-destructive/5' : 'border-input',
isValidating && 'opacity-50'
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<div
onClick={handleTriggerClick}
className={cn(
'px-2 py-1 h-8 rounded-md text-sm w-full cursor-pointer',
'overflow-hidden whitespace-nowrap text-ellipsis',
'border',
hasError ? 'border-destructive bg-destructive/5' : 'border-input',
isValidating && 'opacity-50'
)}
>
{displayValue}
</div>
</PopoverTrigger>
</TooltipTrigger>
{showTooltip && !popoverOpen && (
<TooltipContent
side="top"
align="start"
className="max-w-[400px] whitespace-pre-wrap"
>
<p>{tooltipContent}</p>
</TooltipContent>
)}
title={errorMessage || displayValue}
>
{displayValue}
</div>
</PopoverTrigger>
</Tooltip>
</TooltipProvider>
<PopoverContent
className="p-0 shadow-lg rounded-md"
style={{ width: Math.max(cellRef.current?.offsetWidth || 300, 300) }}
@@ -0,0 +1,429 @@
/**
* AI Suggestions Context
*
* Provides embedding-based suggestions to cells without causing re-renders.
* Uses refs to store suggestion data and callbacks, so consumers can read
* values on-demand without subscribing to state changes.
*
* PERFORMANCE: This context deliberately uses refs instead of state to avoid
* cascading re-renders through the virtualized table. Cells read suggestions
* when they need them (e.g., when dropdown opens).
*/
import React, { createContext, useContext, useRef, useCallback, useEffect, useState } from 'react';
import type { RowData, ProductSuggestions, TaxonomySuggestion } from '../store/types';
// ============================================================================
// Types
// ============================================================================
interface AiSuggestionsContextValue {
/** Check if service is initialized */
isInitialized: boolean;
/** Get suggestions for a product by index */
getSuggestions: (productIndex: string) => ProductSuggestions | undefined;
/** Check if suggestions are loading for a product */
isLoading: (productIndex: string) => boolean;
/** Trigger suggestion fetch for a product */
fetchSuggestions: (product: RowData) => void;
/** Handle field blur - refreshes suggestions if relevant field changed */
handleFieldBlur: (product: RowData, fieldKey: string) => void;
/** Get category suggestions for a product */
getCategorySuggestions: (productIndex: string) => TaxonomySuggestion[];
/** Get theme suggestions for a product */
getThemeSuggestions: (productIndex: string) => TaxonomySuggestion[];
/** Get color suggestions for a product */
getColorSuggestions: (productIndex: string) => TaxonomySuggestion[];
/** Subscribe to suggestion changes for a product (returns unsubscribe fn) */
subscribe: (productIndex: string, callback: () => void) => () => void;
/** Force refresh suggestions for all products */
refreshAll: () => void;
}
interface AiSuggestionsProviderProps {
children: React.ReactNode;
/** Get company name by ID */
getCompanyName?: (id: string) => string | undefined;
/** Get line name by ID */
getLineName?: (id: string) => string | undefined;
/** Initial products to fetch suggestions for */
initialProducts?: RowData[];
/** Whether to auto-initialize (default: true) */
autoInitialize?: boolean;
}
// ============================================================================
// Context
// ============================================================================
const AiSuggestionsContext = createContext<AiSuggestionsContextValue | null>(null);
// Fields that affect embeddings
const EMBEDDING_FIELDS = ['company', 'line', 'name', 'description'];
const API_BASE = '/api/ai';
// ============================================================================
// Provider
// ============================================================================
export function AiSuggestionsProvider({
children,
getCompanyName,
getLineName,
initialProducts,
autoInitialize = true,
}: AiSuggestionsProviderProps) {
// State for initialization status (this can cause re-render, but it's rare)
const [isInitialized, setIsInitialized] = useState(false);
// Refs for data that shouldn't trigger re-renders
const suggestionsRef = useRef<Map<string, ProductSuggestions>>(new Map());
const loadingRef = useRef<Set<string>>(new Set());
const fieldValuesRef = useRef<Map<string, Record<string, unknown>>>(new Map());
const subscribersRef = useRef<Map<string, Set<() => void>>>(new Map());
// Ref for lookup functions (updated on each render)
const lookupFnsRef = useRef({ getCompanyName, getLineName });
lookupFnsRef.current = { getCompanyName, getLineName };
/**
* Notify subscribers when suggestions change
*/
const notifySubscribers = useCallback((productIndex: string) => {
const callbacks = subscribersRef.current.get(productIndex);
if (callbacks) {
callbacks.forEach(cb => cb());
}
}, []);
/**
* Initialize the AI service
*/
const initialize = useCallback(async (): Promise<boolean> => {
if (isInitialized) return true;
try {
const response = await fetch(`${API_BASE}/initialize`, { method: 'POST' });
const data = await response.json();
if (!response.ok || !data.success) {
console.error('[AiSuggestions] Initialization failed:', data.error);
return false;
}
setIsInitialized(true);
return true;
} catch (error) {
console.error('[AiSuggestions] Initialization error:', error);
return false;
}
}, [isInitialized]);
/**
* Build product data for API request
*/
const buildProductRequest = useCallback((product: RowData) => {
const { getCompanyName: getCompany, getLineName: getLine } = lookupFnsRef.current;
return {
name: product.name,
description: product.description,
company_name: product.company ? getCompany?.(String(product.company)) : undefined,
line_name: product.line ? getLine?.(String(product.line)) : undefined,
};
}, []);
/**
* Fetch suggestions for a single product
*/
const fetchSuggestions = useCallback(async (product: RowData) => {
const productIndex = product.__index;
if (!productIndex) return;
// Skip if already loading
if (loadingRef.current.has(productIndex)) return;
// Ensure initialized
const ready = await initialize();
if (!ready) return;
// Check if product has enough data for meaningful suggestions
const productData = buildProductRequest(product);
const hasText = productData.name || productData.description || productData.company_name;
if (!hasText) return;
// Mark as loading
loadingRef.current.add(productIndex);
notifySubscribers(productIndex);
try {
const response = await fetch(`${API_BASE}/suggestions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: productData }),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const suggestions: ProductSuggestions = await response.json();
// Store suggestions
suggestionsRef.current.set(productIndex, suggestions);
// Store field values for change detection
fieldValuesRef.current.set(productIndex, {
company: product.company,
line: product.line,
name: product.name,
description: product.description,
});
} catch (error) {
console.error('[AiSuggestions] Fetch error:', error);
} finally {
loadingRef.current.delete(productIndex);
notifySubscribers(productIndex);
}
}, [initialize, buildProductRequest, notifySubscribers]);
/**
* Fetch suggestions for multiple products in batch
*/
const fetchBatchSuggestions = useCallback(async (products: RowData[]) => {
// Ensure initialized
const ready = await initialize();
if (!ready) return;
// Filter to products that need fetching
const productsToFetch = products.filter(p => {
if (!p.__index) return false;
if (loadingRef.current.has(p.__index)) return false;
if (suggestionsRef.current.has(p.__index)) return false;
const productData = buildProductRequest(p);
return productData.name || productData.description || productData.company_name;
});
if (productsToFetch.length === 0) return;
// Mark all as loading
productsToFetch.forEach(p => {
loadingRef.current.add(p.__index);
notifySubscribers(p.__index);
});
try {
const response = await fetch(`${API_BASE}/suggestions/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
products: productsToFetch.map(p => ({
_index: p.__index,
...buildProductRequest(p),
})),
}),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
// Store results
for (const result of data.results || []) {
const product = productsToFetch[result.index];
if (product?.__index) {
suggestionsRef.current.set(product.__index, {
categories: result.categories,
themes: result.themes,
colors: result.colors,
});
fieldValuesRef.current.set(product.__index, {
company: product.company,
line: product.line,
name: product.name,
description: product.description,
});
}
}
} catch (error) {
console.error('[AiSuggestions] Batch fetch error:', error);
} finally {
productsToFetch.forEach(p => {
loadingRef.current.delete(p.__index);
notifySubscribers(p.__index);
});
}
}, [initialize, buildProductRequest, notifySubscribers]);
/**
* Handle field blur - refresh suggestions if embedding field changed
*/
const handleFieldBlur = useCallback((product: RowData, fieldKey: string) => {
if (!EMBEDDING_FIELDS.includes(fieldKey)) return;
const productIndex = product.__index;
if (!productIndex) return;
// Check if value actually changed
const prevValues = fieldValuesRef.current.get(productIndex);
if (prevValues) {
const prevValue = prevValues[fieldKey];
const currentValue = product[fieldKey];
if (prevValue === currentValue) return;
}
// Clear existing suggestions and refetch
suggestionsRef.current.delete(productIndex);
fieldValuesRef.current.delete(productIndex);
// Debounce the fetch (simple timeout-based debounce)
setTimeout(() => fetchSuggestions(product), 300);
}, [fetchSuggestions]);
/**
* Get suggestions for a product
*/
const getSuggestions = useCallback((productIndex: string): ProductSuggestions | undefined => {
return suggestionsRef.current.get(productIndex);
}, []);
/**
* Check if loading
*/
const isLoading = useCallback((productIndex: string): boolean => {
return loadingRef.current.has(productIndex);
}, []);
/**
* Get category suggestions
*/
const getCategorySuggestions = useCallback((productIndex: string): TaxonomySuggestion[] => {
return suggestionsRef.current.get(productIndex)?.categories || [];
}, []);
/**
* Get theme suggestions
*/
const getThemeSuggestions = useCallback((productIndex: string): TaxonomySuggestion[] => {
return suggestionsRef.current.get(productIndex)?.themes || [];
}, []);
/**
* Get color suggestions
*/
const getColorSuggestions = useCallback((productIndex: string): TaxonomySuggestion[] => {
return suggestionsRef.current.get(productIndex)?.colors || [];
}, []);
/**
* Subscribe to suggestion changes
*/
const subscribe = useCallback((productIndex: string, callback: () => void): (() => void) => {
if (!subscribersRef.current.has(productIndex)) {
subscribersRef.current.set(productIndex, new Set());
}
subscribersRef.current.get(productIndex)!.add(callback);
return () => {
subscribersRef.current.get(productIndex)?.delete(callback);
};
}, []);
/**
* Refresh all products
*/
const refreshAll = useCallback(() => {
// Clear all cached suggestions
suggestionsRef.current.clear();
fieldValuesRef.current.clear();
// If we have initial products, refetch them
if (initialProducts && initialProducts.length > 0) {
fetchBatchSuggestions(initialProducts);
}
}, [initialProducts, fetchBatchSuggestions]);
// Auto-initialize and fetch initial products
useEffect(() => {
if (!autoInitialize) return;
const init = async () => {
const ready = await initialize();
if (ready && initialProducts && initialProducts.length > 0) {
// Small delay to avoid blocking initial render
setTimeout(() => fetchBatchSuggestions(initialProducts), 100);
}
};
init();
}, [autoInitialize, initialize, initialProducts, fetchBatchSuggestions]);
const contextValue: AiSuggestionsContextValue = {
isInitialized,
getSuggestions,
isLoading,
fetchSuggestions,
handleFieldBlur,
getCategorySuggestions,
getThemeSuggestions,
getColorSuggestions,
subscribe,
refreshAll,
};
return (
<AiSuggestionsContext.Provider value={contextValue}>
{children}
</AiSuggestionsContext.Provider>
);
}
// ============================================================================
// Hook
// ============================================================================
export function useAiSuggestionsContext(): AiSuggestionsContextValue | null {
return useContext(AiSuggestionsContext);
}
/**
* Hook for cells to get suggestions with re-render on update
* Only use this in cell components that need to display suggestions
*/
export function useCellSuggestions(productIndex: string) {
const context = useAiSuggestionsContext();
const [, forceUpdate] = useState({});
useEffect(() => {
if (!context) return;
// Subscribe to changes for this product
const unsubscribe = context.subscribe(productIndex, () => {
forceUpdate({});
});
return unsubscribe;
}, [context, productIndex]);
if (!context) {
return {
categories: [] as TaxonomySuggestion[],
themes: [] as TaxonomySuggestion[],
colors: [] as TaxonomySuggestion[],
isLoading: false,
};
}
return {
categories: context.getCategorySuggestions(productIndex),
themes: context.getThemeSuggestions(productIndex),
colors: context.getColorSuggestions(productIndex),
isLoading: context.isLoading(productIndex),
};
}
export default AiSuggestionsContext;
@@ -0,0 +1,54 @@
/**
* 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.
*
* This avoids duplicating UPC validation logic - we reuse the same code path
* that handles individual cell blur events.
*/
import { useEffect } from 'react';
import { useValidationStore } from '../store/validationStore';
import { useUpcValidation } from './useUpcValidation';
/**
* Hook that handles UPC 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);
const clearPendingCopyDownValidation = useValidationStore((state) => state.clearPendingCopyDownValidation);
useEffect(() => {
if (!pendingValidation) return;
const { fieldKey, affectedRows } = pendingValidation;
// Get current rows to check supplier and UPC values
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]);
};
@@ -151,6 +151,15 @@ export interface CopyDownState {
targetRowIndex: number | null; // Hover preview - which row the user is hovering on
}
/**
* Tracks rows that need UPC validation after copy-down completes.
* This allows reusing the existing validateUpc logic instead of duplicating it.
*/
export interface PendingCopyDownValidation {
fieldKey: string;
affectedRows: number[];
}
// =============================================================================
// Dialog State Types
// =============================================================================
@@ -218,6 +227,37 @@ export interface AiValidationState {
revertedChanges: Set<string>; // Format: "productIndex:fieldKey"
}
// =============================================================================
// AI Suggestions Types (Embedding-based)
// =============================================================================
export interface TaxonomySuggestion {
id: number;
name: string;
fullPath?: string;
similarity: number;
}
export interface ProductSuggestions {
categories: TaxonomySuggestion[];
themes: TaxonomySuggestion[];
colors: TaxonomySuggestion[];
latencyMs?: number;
}
export interface AiSuggestionsState {
initialized: boolean;
initializing: boolean;
/** Map of product __index to their embedding */
embeddings: Map<string, number[]>;
/** Map of product __index to their suggestions */
suggestions: Map<string, ProductSuggestions>;
/** Products currently being processed */
processing: Set<string>;
/** Last error if any */
error: string | null;
}
// =============================================================================
// Initialization Types
// =============================================================================
@@ -292,6 +332,7 @@ export interface ValidationState {
// === Copy-Down Mode ===
copyDownMode: CopyDownState;
pendingCopyDownValidation: PendingCopyDownValidation | null;
// === Dialogs ===
dialogs: DialogState;
@@ -376,6 +417,7 @@ export interface ValidationActions {
cancelCopyDown: () => void;
completeCopyDown: (targetRowIndex: number) => void;
setTargetRowHover: (rowIndex: number | null) => void;
clearPendingCopyDownValidation: () => void;
// === Dialogs ===
setDialogs: (updates: Partial<DialogState>) => void;
@@ -29,6 +29,7 @@ import type {
AiValidationResults,
CopyDownState,
DialogState,
PendingCopyDownValidation,
} from './types';
import type { Field, SelectOption } from '../../../types';
@@ -57,6 +58,9 @@ const initialCopyDownState: CopyDownState = {
targetRowIndex: null,
};
// Fields that require UPC validation when changed via copy-down
const UPC_VALIDATION_FIELDS = ['supplier', 'upc', 'barcode'];
const initialDialogState: DialogState = {
templateFormOpen: false,
templateFormData: null,
@@ -105,6 +109,7 @@ const getInitialState = (): ValidationState => ({
// Copy-Down Mode
copyDownMode: { ...initialCopyDownState },
pendingCopyDownValidation: null,
// Dialogs
dialogs: { ...initialDialogState },
@@ -574,9 +579,13 @@ export const useValidationStore = create<ValidationStore>()(
const hasValue = sourceValue !== null && sourceValue !== '' &&
!(Array.isArray(sourceValue) && sourceValue.length === 0);
// Track affected rows for UPC validation
const affectedRows: number[] = [];
for (let i = sourceRowIndex + 1; i <= targetRowIndex; i++) {
if (state.rows[i]) {
state.rows[i][fieldKey] = cloneValue(sourceValue);
affectedRows.push(i);
// Clear validation errors for this field if value is non-empty
if (hasValue) {
@@ -596,6 +605,15 @@ export const useValidationStore = create<ValidationStore>()(
// Reset copy-down mode
state.copyDownMode = { ...initialCopyDownState };
// If this field affects UPC validation, store the affected rows
// so a hook can trigger validation using the existing validateUpc function
if (UPC_VALIDATION_FIELDS.includes(fieldKey) && affectedRows.length > 0) {
state.pendingCopyDownValidation = {
fieldKey,
affectedRows,
};
}
});
},
@@ -607,6 +625,12 @@ export const useValidationStore = create<ValidationStore>()(
});
},
clearPendingCopyDownValidation: () => {
set((state) => {
state.pendingCopyDownValidation = null;
});
},
// =========================================================================
// Dialogs
// =========================================================================