More validate step changes to get closer to original, made the default step now

This commit is contained in:
2025-03-03 21:46:22 -05:00
parent e21da8330e
commit 7a43428e76
10 changed files with 1062 additions and 147 deletions

View File

@@ -50,7 +50,6 @@ export type GlobalSelections = {
company?: string
line?: string
subline?: string
useNewValidation?: boolean
}
export enum ColumnType {
@@ -1708,26 +1707,13 @@ export const MatchColumnsStep = React.memo(<T extends string>({
</Button>
)}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2 ml-4">
<Switch
id="use-new-validation"
checked={!!globalSelections.useNewValidation}
onCheckedChange={(checked) =>
setGlobalSelections(prev => ({ ...prev, useNewValidation: checked }))
}
/>
<Label htmlFor="use-new-validation">Use new validation component</Label>
</div>
<Button
className="ml-auto"
disabled={isLoading}
onClick={handleOnContinue}
>
{translations.matchColumnsStep.nextButtonTitle}
</Button>
</div>
<Button
className="ml-auto"
disabled={isLoading}
onClick={handleOnContinue}
>
{translations.matchColumnsStep.nextButtonTitle}
</Button>
</div>
</div>
</div>

View File

@@ -200,37 +200,9 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
/>
)
case StepType.validateData:
// Check if new validation component should be used
if (state.globalSelections?.useNewValidation) {
// Use the new ValidationStepNew component
return (
<ValidationStepNew
initialData={state.data}
file={uploadedFile!}
onBack={() => {
if (onBack) {
// When going back, preserve the global selections
setPersistedGlobalSelections(state.globalSelections)
onBack()
}
}}
onNext={(validatedData) => {
// Go to image upload step with the validated data
onNext({
type: StepType.imageUpload,
data: validatedData,
file: uploadedFile!,
globalSelections: state.globalSelections
});
}}
isFromScratch={state.isFromScratch}
/>
);
}
// Otherwise, use the original ValidationStep component
// Always use the new ValidationStepNew component
return (
<ValidationStep
<ValidationStepNew
initialData={state.data}
file={uploadedFile!}
onBack={() => {
@@ -249,7 +221,6 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
globalSelections: state.globalSelections
});
}}
globalSelections={state.globalSelections}
isFromScratch={state.isFromScratch}
/>
)

View File

@@ -1939,6 +1939,7 @@ export const ValidationStep = <T extends string>({
file,
onBack,
onNext,
globalSelections,
isFromScratch
}: Props<T>) => {
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>();
@@ -1971,6 +1972,93 @@ export const ValidationStep = <T extends string>({
// Reference to store hook timeouts to prevent race conditions
const hookTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Fetch product lines when company is selected
const { data: productLines } = useQuery({
queryKey: ["product-lines", data.length > 0 ? data[0]?.company : null],
queryFn: async () => {
if (!data.length || !data[0]?.company) return [];
console.log('Fetching product lines for company:', data[0].company);
const response = await fetch(`${config.apiUrl}/import/product-lines/${data[0].company}`);
if (!response.ok) {
console.error('Failed to fetch product lines:', response.status, response.statusText);
throw new Error("Failed to fetch product lines");
}
const productLinesData = await response.json();
console.log('Received product lines:', productLinesData);
return productLinesData;
},
enabled: !!(data.length > 0 && data[0]?.company),
staleTime: 30000, // Cache for 30 seconds
});
// Fetch sublines when line is selected
const { data: sublines } = useQuery({
queryKey: ["sublines", data.length > 0 ? data[0]?.line : null],
queryFn: async () => {
if (!data.length || !data[0]?.line) return [];
console.log('Fetching sublines for line:', data[0].line);
const response = await fetch(`${config.apiUrl}/import/sublines/${data[0].line}`);
if (!response.ok) {
console.error('Failed to fetch sublines:', response.status, response.statusText);
throw new Error("Failed to fetch sublines");
}
const sublinesData = await response.json();
console.log('Received sublines:', sublinesData);
return sublinesData;
},
enabled: !!(data.length > 0 && data[0]?.line),
staleTime: 30000, // Cache for 30 seconds
});
// Update field options with fetched data
const fieldsWithUpdatedOptions = useMemo(() => {
return Array.from(fields as ReadonlyFields<T>).map(field => {
if (field.key === 'line') {
// Check if we have product lines available
const hasProductLines = productLines && productLines.length > 0;
// For line field, ensure we have the proper options
return {
...field,
fieldType: {
...field.fieldType,
// Use fetched product lines if available, otherwise keep existing options
options: hasProductLines
? productLines
: (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select')
? field.fieldType.options
: []
},
// The line field should only be disabled if no company is selected AND no product lines available
disabled: !hasProductLines
} as Field<T>;
}
if (field.key === 'subline') {
// Check if we have sublines available
const hasSublines = sublines && sublines.length > 0;
// For subline field, ensure we have the proper options
return {
...field,
fieldType: {
...field.fieldType,
// Use fetched sublines if available, otherwise keep existing options
options: hasSublines
? sublines
: (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select')
? field.fieldType.options
: []
},
// The subline field should only be disabled if no line is selected AND no sublines available
disabled: !hasSublines
} as Field<T>;
}
return field;
});
}, [fields, productLines, sublines]);
// Define updateData function for validation hooks
const updateData = useCallback(
async (rows: (Data<T> & ExtendedMeta)[], indexes?: number[]) => {
@@ -2217,96 +2305,6 @@ export const ValidationStep = <T extends string>({
// Track which rows are currently being validated to maintain loading state
const rowsBeingValidatedRef = useRef<Set<number>>(new Set());
// Fetch product lines when company is selected
const { data: productLines } = useQuery({
queryKey: ["product-lines", data.length > 0 ? data[0]?.company : null],
queryFn: async () => {
if (!data.length || !data[0]?.company) return [];
console.log('Fetching product lines for company:', data[0].company);
const response = await fetch(`${config.apiUrl}/import/product-lines/${data[0].company}`);
if (!response.ok) {
console.error('Failed to fetch product lines:', response.status, response.statusText);
throw new Error("Failed to fetch product lines");
}
const productLinesData = await response.json();
console.log('Received product lines:', productLinesData);
return productLinesData;
},
enabled: !!(data.length > 0 && data[0]?.company),
staleTime: 30000, // Cache for 30 seconds
});
// Fetch sublines when line is selected
const { data: sublines } = useQuery({
queryKey: ["sublines", data.length > 0 ? data[0]?.line : null],
queryFn: async () => {
if (!data.length || !data[0]?.line) return [];
console.log('Fetching sublines for line:', data[0].line);
const response = await fetch(`${config.apiUrl}/import/sublines/${data[0].line}`);
if (!response.ok) {
console.error('Failed to fetch sublines:', response.status, response.statusText);
throw new Error("Failed to fetch sublines");
}
const sublinesData = await response.json();
console.log('Received sublines:', sublinesData);
return sublinesData;
},
enabled: !!(data.length > 0 && data[0]?.line),
staleTime: 30000, // Cache for 30 seconds
});
// Helper function to safely set a field value and update options if needed
// This function is used when setting field values
// Update field options with fetched data
const fieldsWithUpdatedOptions = useMemo(() => {
return Array.from(fields as ReadonlyFields<T>).map(field => {
if (field.key === 'line') {
// Check if we have product lines available
const hasProductLines = productLines && productLines.length > 0;
// For line field, ensure we have the proper options
return {
...field,
fieldType: {
...field.fieldType,
// Use fetched product lines if available, otherwise keep existing options
options: hasProductLines
? productLines
: (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select')
? field.fieldType.options
: []
},
// The line field should only be disabled if no company is selected AND no product lines available
disabled: !hasProductLines
} as Field<T>;
}
if (field.key === 'subline') {
// Check if we have sublines available
const hasSublines = sublines && sublines.length > 0;
// For subline field, ensure we have the proper options
return {
...field,
fieldType: {
...field.fieldType,
// Use fetched sublines if available, otherwise keep existing options
options: hasSublines
? sublines
: (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select')
? field.fieldType.options
: []
},
// The subline field should only be disabled if no line is selected AND no sublines available
disabled: !hasSublines
} as Field<T>;
}
return field;
});
}, [fields, productLines, sublines]);
// Define the validation function - not using useCallback to avoid dependency issues
const validateUpcAndGenerateItemNumbers = async (forceValidation = false) => {
let newData = [...data];

View File

@@ -0,0 +1,248 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Loader2, CheckIcon } from 'lucide-react';
import { Code } from '@/components/ui/code';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AiValidationDetails, AiValidationProgress, CurrentPrompt, ProductChangeDetail } from '../hooks/useAiValidation';
interface AiValidationDialogsProps {
aiValidationProgress: AiValidationProgress;
aiValidationDetails: AiValidationDetails;
currentPrompt: CurrentPrompt;
setAiValidationProgress: React.Dispatch<React.SetStateAction<AiValidationProgress>>;
setAiValidationDetails: React.Dispatch<React.SetStateAction<AiValidationDetails>>;
setCurrentPrompt: React.Dispatch<React.SetStateAction<CurrentPrompt>>;
revertAiChange: (productIndex: number, fieldKey: string) => void;
isChangeReverted: (productIndex: number, fieldKey: string) => boolean;
getFieldDisplayValueWithHighlight: (fieldKey: string, originalValue: any, correctedValue: any) => { originalHtml: string, correctedHtml: string };
fields: readonly any[];
}
export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
aiValidationProgress,
aiValidationDetails,
currentPrompt,
setAiValidationProgress,
setAiValidationDetails,
setCurrentPrompt,
revertAiChange,
isChangeReverted,
getFieldDisplayValueWithHighlight,
fields
}) => {
return (
<>
{/* Current Prompt Dialog */}
<Dialog
open={currentPrompt.isOpen}
onOpenChange={(open) => setCurrentPrompt(prev => ({ ...prev, isOpen: open }))}
>
<DialogContent className="max-w-4xl h-[80vh]">
<DialogHeader>
<DialogTitle>Current AI Prompt</DialogTitle>
<DialogDescription>
This is the exact prompt that would be sent to the AI for validation
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1">
{currentPrompt.isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : (
<Code className="whitespace-pre-wrap p-4">{currentPrompt.prompt}</Code>
)}
</ScrollArea>
</DialogContent>
</Dialog>
{/* AI Validation Progress Dialog */}
<Dialog
open={aiValidationProgress.isOpen}
onOpenChange={(open) => {
// Only allow closing if validation failed
if (!open && aiValidationProgress.step === -1) {
setAiValidationProgress(prev => ({ ...prev, isOpen: false }));
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>AI Validation Progress</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{
width: `${aiValidationProgress.progressPercent ?? (aiValidationProgress.step / 5) * 100}%`,
backgroundColor: aiValidationProgress.step === -1 ? 'var(--destructive)' : undefined
}}
/>
</div>
</div>
<div className="text-sm text-muted-foreground w-12 text-right">
{aiValidationProgress.step === -1 ? '❌' : `${aiValidationProgress.progressPercent ?? Math.round((aiValidationProgress.step / 5) * 100)}%`}
</div>
</div>
<p className="text-center text-sm text-muted-foreground">
{aiValidationProgress.status}
</p>
{(() => {
// Only show time remaining if we have an estimate and are in progress
return aiValidationProgress.estimatedSeconds &&
aiValidationProgress.elapsedSeconds !== undefined &&
aiValidationProgress.step > 0 &&
aiValidationProgress.step < 5 && (
<div className="text-center text-sm">
{(() => {
// Calculate time remaining using the elapsed seconds
const elapsedSeconds = aiValidationProgress.elapsedSeconds;
const totalEstimatedSeconds = aiValidationProgress.estimatedSeconds;
const remainingSeconds = Math.max(0, totalEstimatedSeconds - elapsedSeconds);
// Format time remaining
if (remainingSeconds < 60) {
return `Approximately ${Math.round(remainingSeconds)} seconds remaining`;
} else {
const minutes = Math.floor(remainingSeconds / 60);
const seconds = Math.round(remainingSeconds % 60);
return `Approximately ${minutes}m ${seconds}s remaining`;
}
})()}
{aiValidationProgress.promptLength && (
<p className="mt-1 text-xs text-muted-foreground">
Prompt length: {aiValidationProgress.promptLength.toLocaleString()} characters
</p>
)}
</div>
);
})()}
</div>
</DialogContent>
</Dialog>
{/* AI Validation Results Dialog */}
<Dialog
open={aiValidationDetails.isOpen}
onOpenChange={(open) => setAiValidationDetails(prev => ({ ...prev, isOpen: open }))}
>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>AI Validation Results</DialogTitle>
<DialogDescription>
Review the changes and warnings suggested by the AI
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
{aiValidationDetails.changeDetails && aiValidationDetails.changeDetails.length > 0 ? (
<div className="mb-6 space-y-6">
<h3 className="font-semibold text-lg">Detailed Changes:</h3>
{aiValidationDetails.changeDetails.map((product, i) => {
// Find the title change if it exists
const titleChange = product.changes.find(c => c.field === 'title');
const titleValue = titleChange ? titleChange.corrected : product.title;
return (
<div key={`product-${i}`} className="border rounded-md p-4">
<h4 className="font-medium text-base mb-3">
{titleValue || `Product ${product.productIndex + 1}`}
</h4>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">Field</TableHead>
<TableHead>Original Value</TableHead>
<TableHead>Corrected Value</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{product.changes.map((change, j) => {
const field = fields.find(f => f.key === change.field);
const fieldLabel = field ? field.label : change.field;
const isReverted = isChangeReverted(product.productIndex, change.field);
// Get highlighted differences
const { originalHtml, correctedHtml } = getFieldDisplayValueWithHighlight(
change.field,
change.original,
change.corrected
);
return (
<TableRow key={`change-${j}`}>
<TableCell className="font-medium">{fieldLabel}</TableCell>
<TableCell>
<div
dangerouslySetInnerHTML={{ __html: originalHtml }}
className={isReverted ? "font-medium" : ""}
/>
</TableCell>
<TableCell>
<div
dangerouslySetInnerHTML={{ __html: correctedHtml }}
className={!isReverted ? "font-medium" : ""}
/>
</TableCell>
<TableCell className="text-right">
<div className="mt-2">
{isReverted ? (
<Button
variant="ghost"
size="sm"
className="text-green-600 bg-green-50 hover:bg-green-100 hover:text-green-700"
disabled
>
<CheckIcon className="w-4 h-4 mr-1" />
Reverted
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => {
// Call the revert function directly
revertAiChange(product.productIndex, change.field);
}}
>
Revert Change
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
})}
</div>
) : (
<div className="py-8 text-center text-muted-foreground">
{aiValidationDetails.warnings && aiValidationDetails.warnings.length > 0 ? (
<div>
<p className="mb-4">No changes were made, but the AI provided some warnings:</p>
<ul className="list-disc pl-8 text-left">
{aiValidationDetails.warnings.map((warning, i) => (
<li key={`warning-${i}`} className="mb-2">{warning}</li>
))}
</ul>
</div>
) : (
<p>No changes or warnings were suggested by the AI.</p>
)}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -2,12 +2,14 @@ import React, { useState } from 'react'
import { useValidationState, Props } from '../hooks/useValidationState'
import ValidationTable from './ValidationTable'
import { Button } from '@/components/ui/button'
import { Loader2, X, Plus, Edit3 } from 'lucide-react'
import { Loader2, X, Plus, Edit3, Sparkles, FileText } from 'lucide-react'
import { toast } from 'sonner'
import { Switch } from '@/components/ui/switch'
import { useRsi } from '../../../hooks/useRsi'
import { ProductSearchDialog } from '@/components/products/ProductSearchDialog'
import SearchableTemplateSelect from './SearchableTemplateSelect'
import { useAiValidation } from '../hooks/useAiValidation'
import { AiValidationDialogs } from './AiValidationDialogs'
/**
* ValidationContainer component - the main wrapper for the validation step
@@ -53,9 +55,19 @@ const ValidationContainer = <T extends string>({
templateState,
saveTemplate,
loadTemplates,
setData
setData,
fields
} = validationState
// Use AI validation hook
const aiValidation = useAiValidation<T>(
data,
setData,
fields,
validationState.rowHook,
validationState.tableHook
);
const { translations } = useRsi<T>()
// State for product search dialog
@@ -232,6 +244,37 @@ const ValidationContainer = <T extends string>({
</Button>
)}
<div className="flex items-center gap-2">
{/* Show Prompt Button */}
<Button
variant="outline"
onClick={aiValidation.showCurrentPrompt}
disabled={aiValidation.isAiValidating}
className="flex items-center gap-1"
>
<FileText className="h-4 w-4" />
Show Prompt
</Button>
{/* AI Validate Button */}
<Button
variant="outline"
onClick={aiValidation.handleAiValidation}
disabled={aiValidation.isAiValidating || data.length === 0}
className="flex items-center gap-1"
>
{aiValidation.isAiValidating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Validating...
</>
) : (
<>
<Sparkles className="h-4 w-4" />
AI Validate
</>
)}
</Button>
<Button
disabled={hasErrors}
onClick={handleNext}
@@ -242,6 +285,20 @@ const ValidationContainer = <T extends string>({
</div>
</div>
{/* AI Validation Dialogs */}
<AiValidationDialogs
aiValidationProgress={aiValidation.aiValidationProgress}
aiValidationDetails={aiValidation.aiValidationDetails}
currentPrompt={aiValidation.currentPrompt}
setAiValidationProgress={aiValidation.setAiValidationProgress}
setAiValidationDetails={aiValidation.setAiValidationDetails}
setCurrentPrompt={aiValidation.setCurrentPrompt}
revertAiChange={aiValidation.revertAiChange}
isChangeReverted={aiValidation.isChangeReverted}
getFieldDisplayValueWithHighlight={aiValidation.getFieldDisplayValueWithHighlight}
fields={fields}
/>
{/* Product Search Dialog */}
<ProductSearchDialog
isOpen={isProductSearchDialogOpen}
@@ -252,4 +309,4 @@ const ValidationContainer = <T extends string>({
)
}
export default ValidationContainer
export default ValidationContainer

View File

@@ -0,0 +1,640 @@
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { getApiUrl, RowData } from './useValidationState';
import { Fields } from '../../../types';
import { addErrorsAndRunHooks } from '../../ValidationStep/utils/dataMutations';
import * as Diff from 'diff';
// Define interfaces for AI validation
export interface ChangeDetail {
field: string;
original: any;
corrected: any;
}
export interface ProductChangeDetail {
productIndex: number;
title: string;
changes: ChangeDetail[];
}
export interface AiValidationDetails {
changes: string[];
warnings: string[];
changeDetails: ProductChangeDetail[];
isOpen: boolean;
originalData?: RowData<string>[];
}
export interface AiValidationProgress {
isOpen: boolean;
status: string;
step: number;
estimatedSeconds?: number;
startTime?: Date;
promptLength?: number;
elapsedSeconds?: number;
progressPercent?: number;
}
export interface CurrentPrompt {
isOpen: boolean;
prompt: string | null;
isLoading: boolean;
}
// Declare global interface for the timer
declare global {
interface Window {
aiValidationTimer?: NodeJS.Timeout;
}
}
export const useAiValidation = <T extends string>(
data: RowData<T>[],
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
fields: Fields<T>,
rowHook?: (row: RowData<T>) => Promise<RowData<T>>,
tableHook?: (data: RowData<T>[]) => Promise<RowData<T>[]>
) => {
// State for AI validation
const [isAiValidating, setIsAiValidating] = useState(false);
const [aiValidationDetails, setAiValidationDetails] = useState<AiValidationDetails>({
changes: [],
warnings: [],
changeDetails: [],
isOpen: false,
});
const [aiValidationProgress, setAiValidationProgress] = useState<AiValidationProgress>({
isOpen: false,
status: "",
step: 0,
});
const [currentPrompt, setCurrentPrompt] = useState<CurrentPrompt>({
isOpen: false,
prompt: null,
isLoading: false,
});
// Track reverted changes
const [revertedChanges, setRevertedChanges] = useState<Set<string>>(new Set());
// Get field display value
const getFieldDisplayValue = useCallback((fieldKey: string, value: any): string => {
const field = fields.find(f => f.key === fieldKey);
if (!field) return String(value);
// Handle different field types
if (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select') {
const options = field.fieldType.options || [];
// Handle array of values (multi-select)
if (Array.isArray(value)) {
return value.map(v => {
const option = options.find(opt => String(opt.value) === String(v));
return option ? option.label : v;
}).join(', ');
}
// Handle single value
const option = options.find(opt => String(opt.value) === String(value));
return option ? option.label : String(value);
}
return String(value);
}, [fields]);
// Function to highlight differences between two text values
const highlightDifferences = useCallback((original: string | null | undefined, corrected: string | null | undefined): { originalHtml: string, correctedHtml: string } => {
// Handle null/undefined values
let originalStr = original === null || original === undefined ? '' : String(original);
let correctedStr = corrected === null || corrected === undefined ? '' : String(corrected);
// If they're identical, return without highlighting
if (originalStr === correctedStr) {
return {
originalHtml: originalStr,
correctedHtml: correctedStr
};
}
const diff = Diff.diffWords(originalStr, correctedStr);
let originalHtml = '';
let correctedHtml = '';
diff.forEach((part: Diff.Change) => {
// Create escaped HTML
const escapedValue = part.value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
if (part.added) {
// Added parts only show in the corrected version (green)
correctedHtml += `<span class="text-green-600 font-medium">${escapedValue}</span>`;
} else if (part.removed) {
// Removed parts only show in the original version (red)
originalHtml += `<span class="text-red-600 font-medium">${escapedValue}</span>`;
} else {
// Unchanged parts show in both versions
originalHtml += escapedValue;
correctedHtml += escapedValue;
}
});
return {
originalHtml,
correctedHtml
};
}, []);
// Function to get field display value with highlighted differences
const getFieldDisplayValueWithHighlight = useCallback((fieldKey: string, originalValue: any, correctedValue: any): { originalHtml: string, correctedHtml: string } => {
const originalDisplay = getFieldDisplayValue(fieldKey, originalValue);
const correctedDisplay = getFieldDisplayValue(fieldKey, correctedValue);
return highlightDifferences(originalDisplay, correctedDisplay);
}, [getFieldDisplayValue, highlightDifferences]);
// Function to check if a change has been reverted
const isChangeReverted = useCallback((productIndex: number, fieldKey: string): boolean => {
const revertKey = `${productIndex}:${fieldKey}`;
return revertedChanges.has(revertKey);
}, [revertedChanges]);
// Function to revert a specific AI validation change
const revertAiChange = useCallback((productIndex: number, fieldKey: string) => {
// Ensure we have the original data
if (!aiValidationDetails.originalData) {
console.error('Cannot revert: original data not available');
return;
}
// Find the original product and current product
const originalProduct = aiValidationDetails.originalData[productIndex];
if (!originalProduct) {
console.error(`Cannot revert: original product at index ${productIndex} not found`);
return;
}
const currentDataIndex = data.findIndex(d => d.__index === originalProduct.__index);
if (currentDataIndex === -1) {
console.error(`Cannot revert: current product with __index ${originalProduct.__index} not found`);
return;
}
const currentProduct = data[currentDataIndex];
// Get the original value in a type-safe way
const originalValue = fieldKey in originalProduct ?
(originalProduct as Record<string, any>)[fieldKey] : undefined;
console.log(`Reverting change to field "${fieldKey}" for product at index ${currentDataIndex}`, {
originalIndex: productIndex,
currentIndex: currentDataIndex,
original: originalValue,
current: fieldKey in currentProduct ? (currentProduct as Record<string, any>)[fieldKey] : undefined
});
// Create a new data array with the reverted field
const newData = [...data];
newData[currentDataIndex] = {
...newData[currentDataIndex],
[fieldKey]: originalValue
};
// Update the data state
setData(newData);
// Add to the set of reverted changes
const revertKey = `${productIndex}:${fieldKey}`;
setRevertedChanges(prev => {
const newSet = new Set(prev);
newSet.add(revertKey);
return newSet;
});
// Show success notification
toast.success("Change reverted");
}, [aiValidationDetails.originalData, data, setData]);
// Function to show current prompt
const showCurrentPrompt = useCallback(async () => {
try {
setCurrentPrompt(prev => ({ ...prev, isLoading: true, isOpen: true }));
// Debug log the data being sent
console.log('Sending products data:', {
dataLength: data.length,
firstProduct: data[0],
lastProduct: data[data.length - 1]
});
// Clean the data to ensure we only send what's needed
const cleanedData = data.map(item => {
const { __errors, __index, ...rest } = item;
return rest;
});
console.log('Cleaned data sample:', {
length: cleanedData.length,
firstProduct: cleanedData[0],
lastProduct: cleanedData[cleanedData.length - 1]
});
// Use POST to send products in request body
const response = await fetch(`${getApiUrl()}/ai-validation/debug`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ products: cleanedData })
});
if (!response.ok) {
throw new Error(`Failed to get prompt: ${response.status} ${response.statusText}`);
}
const result = await response.json();
console.log('Debug response:', result);
// Check for different possible property names based on the API response
const promptContent = result.sampleFullPrompt || result.basePrompt || result.prompt;
if (promptContent) {
setCurrentPrompt(prev => ({
...prev,
prompt: promptContent,
isLoading: false
}));
} else {
throw new Error('No prompt returned from server');
}
} catch (error) {
console.error('Error getting prompt:', error);
toast.error("Failed to get current prompt");
setCurrentPrompt(prev => ({
...prev,
isLoading: false,
prompt: "Error loading prompt"
}));
}
}, [data]);
// Main AI validation function
const handleAiValidation = useCallback(async () => {
try {
if (isAiValidating) return;
// Store the original data before any changes
const originalDataCopy = [...data];
// Reset reverted changes when starting a new validation
setRevertedChanges(new Set());
setIsAiValidating(true);
const startTime = new Date();
setAiValidationProgress({
isOpen: true,
status: "Preparing data for validation...",
step: 1,
startTime,
elapsedSeconds: 0,
progressPercent: 0,
...(aiValidationProgress.estimatedSeconds ? {
estimatedSeconds: aiValidationProgress.estimatedSeconds,
promptLength: aiValidationProgress.promptLength
} : {})
});
console.log('Sending data for validation:', data);
// Set up progress update interval
window.aiValidationTimer = setInterval(() => {
setAiValidationProgress(prev => {
if (!prev.startTime) return prev;
const now = new Date();
const elapsedSeconds = Math.floor((now.getTime() - prev.startTime.getTime()) / 1000);
// Calculate progress percentage based on step and elapsed time
let progressPercent = 0;
if (prev.estimatedSeconds && prev.estimatedSeconds > 0) {
// If we have an estimated time, use that for progress calculation
progressPercent = Math.min(95, (elapsedSeconds / prev.estimatedSeconds) * 100);
console.log('Using time-based progress:', progressPercent);
} else {
// Otherwise use step-based progress with some time-based adjustment
const baseProgress = (prev.step / 5) * 100;
const timeAdjustment = Math.min(20, elapsedSeconds * 0.5);
const stepProgress = prev.step === 1 ? timeAdjustment : 0;
progressPercent = Math.min(95, baseProgress + stepProgress);
console.log('Using step-based progress:', progressPercent);
}
// Extract the base status message without any time information
const baseStatus = prev.status.replace(/\s\(\d+[ms].+\)$/, '').replace(/\s\(\d+m \d+s.+\)$/, '');
return {
...prev,
elapsedSeconds,
progressPercent,
// Just use the base status message without time information
status: baseStatus
};
});
}, 1000) as unknown as NodeJS.Timeout;
// Clean the data to ensure we only send what's needed
const cleanedData = data.map(item => {
const { __errors, __index, ...cleanProduct } = item;
return cleanProduct;
});
console.log('Cleaned data for validation:', cleanedData);
// If we don't have an estimated time yet, try to get one
if (!aiValidationProgress.estimatedSeconds) {
try {
const debugResponse = await fetch(`${getApiUrl()}/ai-validation/debug`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ products: cleanedData })
});
if (debugResponse.ok) {
const debugData = await debugResponse.json();
console.log('Debug response details:', {
hasEstimatedTime: !!debugData.estimatedProcessingTime,
estimatedTimeSeconds: debugData.estimatedProcessingTime?.seconds,
calculationMethod: debugData.estimatedProcessingTime?.calculationMethod || 'unknown',
avgRate: debugData.estimatedProcessingTime?.avgRate,
promptLength: debugData.promptLength,
fullResponse: debugData
});
if (debugData.estimatedProcessingTime?.seconds) {
console.log('Setting estimated time:', debugData.estimatedProcessingTime.seconds);
setAiValidationProgress(prev => {
const newState = {
...prev,
estimatedSeconds: debugData.estimatedProcessingTime.seconds,
promptLength: debugData.promptLength
};
console.log('New progress state with time:', newState);
return newState;
});
} else {
console.log('No estimated time in debug response');
}
} else {
console.error('Debug response not OK:', debugResponse.status);
}
} catch (estimateError) {
console.error('Error getting time estimate:', estimateError);
// Continue without an estimate
}
}
setAiValidationProgress(prev => ({
...prev,
status: "Sending data to AI service...",
step: 2,
estimatedSeconds: prev.estimatedSeconds,
promptLength: prev.promptLength
}));
const response = await fetch(`${getApiUrl()}/ai-validation/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ products: cleanedData }),
});
if (!response.ok) {
const errorText = await response.text();
console.error('AI validation error response:', {
status: response.status,
statusText: response.statusText,
body: errorText
});
throw new Error(`AI validation failed: ${response.status} ${response.statusText}`);
}
const result = await response.json();
console.log('AI validation response:', result);
if (!result.success) {
throw new Error(result.error || 'AI validation failed');
}
// Update progress with actual processing time if available
if (result.performanceMetrics) {
console.log('Performance metrics:', result.performanceMetrics);
setAiValidationProgress(prev => ({
...prev,
status: "Processing AI response...",
step: 3,
// Update with actual metrics from the server
estimatedSeconds: result.performanceMetrics.estimatedSeconds ||
result.performanceMetrics.processingTimeSeconds ||
prev.estimatedSeconds,
promptLength: result.performanceMetrics.promptLength || prev.promptLength,
progressPercent: 75 // 75% complete when we're processing the AI response
}));
} else {
setAiValidationProgress(prev => ({
...prev,
status: "Processing AI response...",
step: 3,
progressPercent: 75
}));
}
// Update the data with AI suggestions
if (result.correctedData && Array.isArray(result.correctedData)) {
// Process data to properly handle comma-separated values for multi-select fields
const processedData = result.correctedData.map((corrected: any, index: number) => {
// Start with original data to preserve __index and other metadata
const original = data[index] || {};
const processed = { ...original, ...corrected };
// Process each field
Object.keys(processed).forEach(key => {
if (key.startsWith('__')) return; // Skip metadata fields
const fieldConfig = fields.find(f => f.key === key);
if (!fieldConfig) return; // Skip if no matching field found
// Handle multi-select fields (comma-separated values)
if (fieldConfig?.fieldType.type === 'multi-select' && typeof processed[key] === 'string') {
// Split comma-separated values and trim each value
processed[key] = processed[key].split(',').map((v: string) => v.trim()).filter(Boolean);
}
// Handle select fields (convert labels to values if needed)
if ((fieldConfig?.fieldType.type === 'select' || fieldConfig?.fieldType.type === 'multi-select') &&
'options' in fieldConfig.fieldType) {
const options = fieldConfig.fieldType.options || [];
// Handle single value (select)
if (typeof processed[key] === 'string' && fieldConfig.fieldType.type === 'select') {
// Check if the value is already an ID
const isAlreadyId = options.some(opt => String(opt.value) === String(processed[key]));
if (!isAlreadyId) {
// Try to find the option by label (case insensitive)
const matchingOption = options.find(opt =>
typeof processed[key] === 'string' && typeof opt.label === 'string' &&
opt.label.toLowerCase() === processed[key].toLowerCase()
);
if (matchingOption) {
// Convert label to ID
processed[key] = matchingOption.value;
}
}
}
// Handle array of values (multi-select)
if (Array.isArray(processed[key])) {
processed[key] = processed[key].map((val: string | number) => {
if (val === null || val === undefined) return val;
// Check if the value is already an ID
const isAlreadyId = options.some(opt => String(opt.value) === String(val));
if (!isAlreadyId) {
// Try to find the option by label (case insensitive)
const matchingOption = options.find(opt =>
typeof val === 'string' && typeof opt.label === 'string' &&
opt.label.toLowerCase() === val.toLowerCase()
);
if (matchingOption) {
// Convert label to ID
return matchingOption.value;
}
}
return val;
});
}
}
});
return processed;
});
console.log('About to update data with AI corrections:', {
originalDataSample: data.slice(0, 2),
processedDataSample: processedData.slice(0, 2),
correctionCount: result.changes?.length || 0
});
// First validate the new data to ensure all validation rules are applied
try {
// Validate the data with the hooks
const validatedData = await addErrorsAndRunHooks<T>(
processedData,
fields,
rowHook,
tableHook
);
// Update the component state with the validated data
setData(validatedData as RowData<T>[]);
// Force a small delay to ensure React updates the state before showing the results
await new Promise(resolve => setTimeout(resolve, 50));
console.log('Data updated after AI validation:', {
dataLength: validatedData.length,
hasErrors: validatedData.some(row => row.__errors && Object.keys(row.__errors).length > 0)
});
// Show changes and warnings in dialog after data is updated
setAiValidationDetails({
changes: result.changes || [],
warnings: result.warnings || [],
changeDetails: result.changeDetails || [],
isOpen: true,
originalData: originalDataCopy // Use the stored original data
});
} catch (error) {
console.error('Error validating AI corrections:', error);
// Fall back to basic update without validation
setData(processedData);
// Still show the result dialog even if validation failed
setAiValidationDetails({
changes: result.changes || [],
warnings: result.warnings || [],
changeDetails: result.changeDetails || [],
isOpen: true,
originalData: originalDataCopy, // Use the stored original data
});
}
}
setAiValidationProgress(prev => ({
...prev,
status: "Validation complete!",
step: 5,
estimatedSeconds: prev.estimatedSeconds,
promptLength: prev.promptLength
}));
setTimeout(() => {
setAiValidationProgress(prev => ({ ...prev, isOpen: false }));
}, 1000);
} catch (error) {
console.error('AI Validation Error:', error);
toast.error(error instanceof Error ? error.message : "An error occurred during AI validation");
setAiValidationProgress(prev => ({
...prev,
status: "Validation failed",
step: -1,
estimatedSeconds: prev.estimatedSeconds,
promptLength: prev.promptLength
}));
} finally {
// Clear the interval when we're done (success or error)
if (window.aiValidationTimer) {
clearInterval(window.aiValidationTimer);
window.aiValidationTimer = undefined;
}
setIsAiValidating(false);
// Only set to 100% when actually complete (or in error state)
setAiValidationProgress(prev => ({
...prev,
progressPercent: prev.step === -1 ? prev.progressPercent : 100, // Only show 100% if successful completion
estimatedSeconds: prev.estimatedSeconds,
promptLength: prev.promptLength,
elapsedSeconds: prev.elapsedSeconds
}));
}
}, [isAiValidating, data, aiValidationProgress.estimatedSeconds, aiValidationProgress.promptLength, fields, rowHook, tableHook]);
return {
isAiValidating,
aiValidationDetails,
aiValidationProgress,
currentPrompt,
handleAiValidation,
showCurrentPrompt,
setAiValidationDetails,
setAiValidationProgress,
setCurrentPrompt,
getFieldDisplayValue,
getFieldDisplayValueWithHighlight,
revertAiChange,
isChangeReverted
};
};

View File

@@ -782,6 +782,10 @@ export const useValidationState = <T extends string>({
isValidatingUpc,
// Fields reference
fields
fields,
// Hooks
rowHook,
tableHook
}
}

File diff suppressed because one or more lines are too long

10
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"diff": "^7.0.0",
"shadcn": "^1.0.0"
},
"devDependencies": {
@@ -75,6 +76,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",

View File

@@ -3,6 +3,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"diff": "^7.0.0",
"shadcn": "^1.0.0"
},
"devDependencies": {