Add AI embeddings and suggestions for categories, a few validation step tweaks/fixes
This commit is contained in:
@@ -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 = {
|
||||
|
||||
+192
@@ -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;
|
||||
+99
-64
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+65
-20
@@ -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>
|
||||
|
||||
|
||||
+145
-34
@@ -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>
|
||||
|
||||
+38
-14
@@ -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) }}
|
||||
|
||||
+429
@@ -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;
|
||||
+54
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user