More validate step changes to get closer to original, made the default step now
This commit is contained in:
@@ -50,7 +50,6 @@ export type GlobalSelections = {
|
|||||||
company?: string
|
company?: string
|
||||||
line?: string
|
line?: string
|
||||||
subline?: string
|
subline?: string
|
||||||
useNewValidation?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ColumnType {
|
export enum ColumnType {
|
||||||
@@ -1708,26 +1707,13 @@ export const MatchColumnsStep = React.memo(<T extends string>({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
<div className="flex items-center space-x-4">
|
className="ml-auto"
|
||||||
<div className="flex items-center space-x-2 ml-4">
|
disabled={isLoading}
|
||||||
<Switch
|
onClick={handleOnContinue}
|
||||||
id="use-new-validation"
|
>
|
||||||
checked={!!globalSelections.useNewValidation}
|
{translations.matchColumnsStep.nextButtonTitle}
|
||||||
onCheckedChange={(checked) =>
|
</Button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -200,37 +200,9 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case StepType.validateData:
|
case StepType.validateData:
|
||||||
// Check if new validation component should be used
|
// Always use the new ValidationStepNew component
|
||||||
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
|
|
||||||
return (
|
return (
|
||||||
<ValidationStep
|
<ValidationStepNew
|
||||||
initialData={state.data}
|
initialData={state.data}
|
||||||
file={uploadedFile!}
|
file={uploadedFile!}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
@@ -249,7 +221,6 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
globalSelections: state.globalSelections
|
globalSelections: state.globalSelections
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
globalSelections={state.globalSelections}
|
|
||||||
isFromScratch={state.isFromScratch}
|
isFromScratch={state.isFromScratch}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1939,6 +1939,7 @@ export const ValidationStep = <T extends string>({
|
|||||||
file,
|
file,
|
||||||
onBack,
|
onBack,
|
||||||
onNext,
|
onNext,
|
||||||
|
globalSelections,
|
||||||
isFromScratch
|
isFromScratch
|
||||||
}: Props<T>) => {
|
}: Props<T>) => {
|
||||||
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<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
|
// Reference to store hook timeouts to prevent race conditions
|
||||||
const hookTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
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
|
// Define updateData function for validation hooks
|
||||||
const updateData = useCallback(
|
const updateData = useCallback(
|
||||||
async (rows: (Data<T> & ExtendedMeta)[], indexes?: number[]) => {
|
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
|
// Track which rows are currently being validated to maintain loading state
|
||||||
const rowsBeingValidatedRef = useRef<Set<number>>(new Set());
|
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
|
// Define the validation function - not using useCallback to avoid dependency issues
|
||||||
const validateUpcAndGenerateItemNumbers = async (forceValidation = false) => {
|
const validateUpcAndGenerateItemNumbers = async (forceValidation = false) => {
|
||||||
let newData = [...data];
|
let newData = [...data];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,12 +2,14 @@ import React, { useState } from 'react'
|
|||||||
import { useValidationState, Props } from '../hooks/useValidationState'
|
import { useValidationState, Props } from '../hooks/useValidationState'
|
||||||
import ValidationTable from './ValidationTable'
|
import ValidationTable from './ValidationTable'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { toast } from 'sonner'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { useRsi } from '../../../hooks/useRsi'
|
import { useRsi } from '../../../hooks/useRsi'
|
||||||
import { ProductSearchDialog } from '@/components/products/ProductSearchDialog'
|
import { ProductSearchDialog } from '@/components/products/ProductSearchDialog'
|
||||||
import SearchableTemplateSelect from './SearchableTemplateSelect'
|
import SearchableTemplateSelect from './SearchableTemplateSelect'
|
||||||
|
import { useAiValidation } from '../hooks/useAiValidation'
|
||||||
|
import { AiValidationDialogs } from './AiValidationDialogs'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ValidationContainer component - the main wrapper for the validation step
|
* ValidationContainer component - the main wrapper for the validation step
|
||||||
@@ -53,9 +55,19 @@ const ValidationContainer = <T extends string>({
|
|||||||
templateState,
|
templateState,
|
||||||
saveTemplate,
|
saveTemplate,
|
||||||
loadTemplates,
|
loadTemplates,
|
||||||
setData
|
setData,
|
||||||
|
fields
|
||||||
} = validationState
|
} = validationState
|
||||||
|
|
||||||
|
// Use AI validation hook
|
||||||
|
const aiValidation = useAiValidation<T>(
|
||||||
|
data,
|
||||||
|
setData,
|
||||||
|
fields,
|
||||||
|
validationState.rowHook,
|
||||||
|
validationState.tableHook
|
||||||
|
);
|
||||||
|
|
||||||
const { translations } = useRsi<T>()
|
const { translations } = useRsi<T>()
|
||||||
|
|
||||||
// State for product search dialog
|
// State for product search dialog
|
||||||
@@ -232,6 +244,37 @@ const ValidationContainer = <T extends string>({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
disabled={hasErrors}
|
disabled={hasErrors}
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
@@ -242,6 +285,20 @@ const ValidationContainer = <T extends string>({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Product Search Dialog */}
|
||||||
<ProductSearchDialog
|
<ProductSearchDialog
|
||||||
isOpen={isProductSearchDialogOpen}
|
isOpen={isProductSearchDialogOpen}
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -782,6 +782,10 @@ export const useValidationState = <T extends string>({
|
|||||||
isValidatingUpc,
|
isValidatingUpc,
|
||||||
|
|
||||||
// Fields reference
|
// Fields reference
|
||||||
fields
|
fields,
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
rowHook,
|
||||||
|
tableHook
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
10
package-lock.json
generated
10
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"diff": "^7.0.0",
|
||||||
"shadcn": "^1.0.0"
|
"shadcn": "^1.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -75,6 +76,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.0.0",
|
"version": "19.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"diff": "^7.0.0",
|
||||||
"shadcn": "^1.0.0"
|
"shadcn": "^1.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user