Fix issues with validation errors showing and problems with concurrent editing, improve scroll position saving
This commit is contained in:
@@ -1188,7 +1188,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
|
||||
})) as unknown as Fields<T>
|
||||
|
||||
const unmatched = findUnmatchedRequiredFields(typedFields, columns);
|
||||
console.log("Unmatched required fields:", unmatched);
|
||||
return unmatched;
|
||||
}, [fields, columns])
|
||||
|
||||
@@ -1200,7 +1199,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
|
||||
// Type assertion to handle the DeepReadonly<T> vs string type mismatch
|
||||
return !unmatchedRequiredFields.includes(key as any);
|
||||
});
|
||||
console.log("Matched required fields:", matched);
|
||||
return matched;
|
||||
}, [requiredFields, unmatchedRequiredFields]);
|
||||
|
||||
|
||||
@@ -100,8 +100,6 @@ const BaseCellContent = React.memo(({
|
||||
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
|
||||
field.fieldType.price === true;
|
||||
|
||||
console.log(`BaseCellContent: field.key=${field.key}, fieldType=${fieldType}, disabled=${field.disabled}, options=`, options);
|
||||
|
||||
if (fieldType === 'select') {
|
||||
return (
|
||||
<SelectCell
|
||||
@@ -175,20 +173,17 @@ export interface ValidationCellProps {
|
||||
}
|
||||
|
||||
// Add efficient error message extraction function
|
||||
const getErrorMessage = (error: ErrorObject): string => error.message;
|
||||
|
||||
// Add a utility function to process errors with appropriate caching
|
||||
// Highly optimized error processing function with fast paths for common cases
|
||||
function processErrors(value: any, errors: ErrorObject[]): {
|
||||
filteredErrors: ErrorObject[];
|
||||
hasError: boolean;
|
||||
isRequiredButEmpty: boolean;
|
||||
shouldShowErrorIcon: boolean;
|
||||
errorMessages: string;
|
||||
} {
|
||||
// Fast path - if no errors, return immediately
|
||||
// Fast path - if no errors or empty error array, return immediately
|
||||
if (!errors || errors.length === 0) {
|
||||
return {
|
||||
filteredErrors: [],
|
||||
hasError: false,
|
||||
isRequiredButEmpty: false,
|
||||
shouldShowErrorIcon: false,
|
||||
@@ -196,45 +191,37 @@ function processErrors(value: any, errors: ErrorObject[]): {
|
||||
};
|
||||
}
|
||||
|
||||
// Use the shared isEmpty function instead of defining a local one
|
||||
// Use the shared isEmpty function for value checking
|
||||
const valueIsEmpty = isEmpty(value);
|
||||
|
||||
// If not empty, filter out required errors
|
||||
// Create a new array only if we need to filter (avoid unnecessary allocations)
|
||||
let filteredErrors: ErrorObject[];
|
||||
let hasRequiredError = false;
|
||||
|
||||
if (valueIsEmpty) {
|
||||
// For empty values, check if there are required errors
|
||||
hasRequiredError = errors.some(error => error.type === ErrorType.Required);
|
||||
filteredErrors = errors;
|
||||
} else {
|
||||
// For non-empty values, filter out required errors
|
||||
filteredErrors = errors.filter(error => error.type !== ErrorType.Required);
|
||||
// Fast path for the most common case - required field with empty value
|
||||
if (valueIsEmpty && errors.length === 1 && errors[0].type === ErrorType.Required) {
|
||||
return {
|
||||
hasError: true,
|
||||
isRequiredButEmpty: true,
|
||||
shouldShowErrorIcon: false,
|
||||
errorMessages: ''
|
||||
};
|
||||
}
|
||||
|
||||
// Determine if any actual errors exist after filtering
|
||||
const hasError = filteredErrors.length > 0 && filteredErrors.some(error =>
|
||||
error.level === 'error' || error.level === 'warning'
|
||||
);
|
||||
// For non-empty values with errors, we need to show error icons
|
||||
const hasError = errors.some(error => error.level === 'error' || error.level === 'warning');
|
||||
|
||||
// Check if field is required but empty
|
||||
const isRequiredButEmpty = valueIsEmpty && hasRequiredError;
|
||||
// For empty values with required errors, show only a border
|
||||
const isRequiredButEmpty = valueIsEmpty && errors.some(error => error.type === ErrorType.Required);
|
||||
|
||||
// Only show error icons for non-empty fields with actual errors
|
||||
const shouldShowErrorIcon = hasError && (!valueIsEmpty || !hasRequiredError);
|
||||
// Show error icons for non-empty fields with errors, or for empty fields with non-required errors
|
||||
const shouldShowErrorIcon = hasError && (!valueIsEmpty || !errors.every(error => error.type === ErrorType.Required));
|
||||
|
||||
// Get error messages for the tooltip - only if we need to show icon
|
||||
let errorMessages = '';
|
||||
if (shouldShowErrorIcon) {
|
||||
errorMessages = filteredErrors
|
||||
// Only compute error messages if we're going to show an icon
|
||||
const errorMessages = shouldShowErrorIcon
|
||||
? errors
|
||||
.filter(e => e.level === 'error' || e.level === 'warning')
|
||||
.map(getErrorMessage)
|
||||
.join('\n');
|
||||
}
|
||||
.map(e => e.message)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
return {
|
||||
filteredErrors,
|
||||
hasError,
|
||||
isRequiredButEmpty,
|
||||
shouldShowErrorIcon,
|
||||
@@ -242,7 +229,7 @@ function processErrors(value: any, errors: ErrorObject[]): {
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to compare error arrays efficiently
|
||||
// Helper function to compare error arrays efficiently with a hash-based approach
|
||||
function compareErrorArrays(prevErrors: ErrorObject[], nextErrors: ErrorObject[]): boolean {
|
||||
// Fast path for referential equality
|
||||
if (prevErrors === nextErrors) return true;
|
||||
@@ -251,15 +238,21 @@ function compareErrorArrays(prevErrors: ErrorObject[], nextErrors: ErrorObject[]
|
||||
if (!prevErrors || !nextErrors) return prevErrors === nextErrors;
|
||||
if (prevErrors.length !== nextErrors.length) return false;
|
||||
|
||||
// Check if errors are equivalent
|
||||
return prevErrors.every((error, index) => {
|
||||
const nextError = nextErrors[index];
|
||||
return (
|
||||
error.message === nextError.message &&
|
||||
error.level === nextError.level &&
|
||||
error.type === nextError.type
|
||||
);
|
||||
});
|
||||
// Generate simple hash from error properties
|
||||
const getErrorHash = (error: ErrorObject): string => {
|
||||
return `${error.message}|${error.level}|${error.type || ''}`;
|
||||
};
|
||||
|
||||
// Compare using hashes
|
||||
const prevHashes = prevErrors.map(getErrorHash);
|
||||
const nextHashes = nextErrors.map(getErrorHash);
|
||||
|
||||
// Sort hashes to ensure consistent order
|
||||
prevHashes.sort();
|
||||
nextHashes.sort();
|
||||
|
||||
// Compare sorted hash arrays
|
||||
return prevHashes.join(',') === nextHashes.join(',');
|
||||
}
|
||||
|
||||
const ValidationCell = React.memo(({
|
||||
@@ -300,15 +293,18 @@ const ValidationCell = React.memo(({
|
||||
// Add state for hover on target row
|
||||
const [isTargetRowHovered, setIsTargetRowHovered] = React.useState(false);
|
||||
|
||||
// Force isValidating to be a boolean
|
||||
const isLoading = isValidating === true;
|
||||
|
||||
// Handle copy down button click
|
||||
const handleCopyDownClick = () => {
|
||||
const handleCopyDownClick = React.useCallback(() => {
|
||||
if (copyDown && totalRows > rowIndex + 1) {
|
||||
// Enter copy down mode
|
||||
copyDownContext.setIsInCopyDownMode(true);
|
||||
copyDownContext.setSourceRowIndex(rowIndex);
|
||||
copyDownContext.setSourceFieldKey(fieldKey);
|
||||
}
|
||||
};
|
||||
}, [copyDown, copyDownContext, fieldKey, rowIndex, totalRows]);
|
||||
|
||||
// Check if this cell is in a row that can be a target for copy down
|
||||
const isInTargetRow = copyDownContext.isInCopyDownMode &&
|
||||
@@ -319,7 +315,7 @@ const ValidationCell = React.memo(({
|
||||
const isSelectedTarget = isInTargetRow && rowIndex <= (copyDownContext.targetRowIndex || 0);
|
||||
|
||||
// Handle click on a potential target cell
|
||||
const handleTargetCellClick = () => {
|
||||
const handleTargetCellClick = React.useCallback(() => {
|
||||
if (isInTargetRow && copyDownContext.sourceRowIndex !== null && copyDownContext.sourceFieldKey !== null) {
|
||||
copyDownContext.handleCopyDownComplete(
|
||||
copyDownContext.sourceRowIndex,
|
||||
@@ -327,22 +323,35 @@ const ValidationCell = React.memo(({
|
||||
rowIndex
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [copyDownContext, isInTargetRow, rowIndex]);
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
className="p-1 group relative"
|
||||
style={{
|
||||
// Memoize the cell style objects to avoid recreating them on every render
|
||||
const cellStyle = React.useMemo(() => ({
|
||||
width: `${width}px`,
|
||||
minWidth: `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
boxSizing: 'border-box',
|
||||
boxSizing: 'border-box' as const,
|
||||
cursor: isInTargetRow ? 'pointer' : undefined,
|
||||
...(isSourceCell ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, boxShadow: '0 0 0 2px #3b82f6' } :
|
||||
isSelectedTarget ? { backgroundColor: '#bfdbfe', borderRadius: '0.375rem', padding: 0 } :
|
||||
isInTargetRow && isTargetRowHovered ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } :
|
||||
isInTargetRow ? { borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } : {})
|
||||
}}
|
||||
}), [width, isInTargetRow, isSourceCell, isSelectedTarget, isTargetRowHovered]);
|
||||
|
||||
// Memoize the cell class name to prevent re-calculating on every render
|
||||
const cellClassName = React.useMemo(() => {
|
||||
if (isSourceCell || isSelectedTarget || isInTargetRow) {
|
||||
return isSourceCell ? '!bg-blue-100 !border-blue-500 !rounded-md' :
|
||||
isSelectedTarget ? '!bg-blue-200 !border-blue-200 !rounded-md' :
|
||||
isInTargetRow ? 'hover:!bg-blue-100 !border-blue-200 !rounded-md' : '';
|
||||
}
|
||||
return '';
|
||||
}, [isSourceCell, isSelectedTarget, isInTargetRow]);
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
className="p-1 group relative"
|
||||
style={cellStyle}
|
||||
onClick={isInTargetRow ? handleTargetCellClick : undefined}
|
||||
onMouseEnter={isInTargetRow ? () => setIsTargetRowHovered(true) : undefined}
|
||||
onMouseLeave={isInTargetRow ? () => setIsTargetRowHovered(false) : undefined}
|
||||
@@ -401,25 +410,20 @@ const ValidationCell = React.memo(({
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{isValidating ? (
|
||||
{isLoading ? (
|
||||
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-sm px-2 py-1.5`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}>
|
||||
{(() => { console.log(`ValidationCell: fieldKey=${fieldKey}, options=`, options); return null; })()}
|
||||
<BaseCellContent
|
||||
field={field}
|
||||
value={displayValue}
|
||||
onChange={onChange}
|
||||
hasErrors={hasError || isRequiredButEmpty}
|
||||
options={options}
|
||||
className={isSourceCell || isSelectedTarget || isInTargetRow ? `${
|
||||
isSourceCell ? '!bg-blue-100 !border-blue-500 !rounded-md' :
|
||||
isSelectedTarget ? '!bg-blue-200 !border-blue-200 !rounded-md' :
|
||||
isInTargetRow ? 'hover:!bg-blue-100 !border-blue-200 !rounded-md' : ''
|
||||
}` : ''}
|
||||
className={cellClassName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -427,32 +431,48 @@ const ValidationCell = React.memo(({
|
||||
</TableCell>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Deep compare the most important props to avoid unnecessary re-renders
|
||||
const valueEqual = prevProps.value === nextProps.value;
|
||||
const isValidatingEqual = prevProps.isValidating === nextProps.isValidating;
|
||||
const fieldEqual = prevProps.field === nextProps.field;
|
||||
const itemNumberEqual = prevProps.itemNumber === nextProps.itemNumber;
|
||||
// Fast path: if all props are the same object
|
||||
if (prevProps === nextProps) return true;
|
||||
|
||||
// Use enhanced error comparison
|
||||
const errorsEqual = compareErrorArrays(prevProps.errors, nextProps.errors);
|
||||
// Optimize the memo comparison function, checking most impactful props first
|
||||
// Check isValidating first as it's most likely to change frequently
|
||||
if (prevProps.isValidating !== nextProps.isValidating) return false;
|
||||
|
||||
// Shallow options comparison with length check
|
||||
const optionsEqual =
|
||||
prevProps.options === nextProps.options ||
|
||||
// Then check value changes
|
||||
if (prevProps.value !== nextProps.value) return false;
|
||||
|
||||
// Item number is related to validation state
|
||||
if (prevProps.itemNumber !== nextProps.itemNumber) return false;
|
||||
|
||||
// Check errors with our optimized comparison function
|
||||
if (!compareErrorArrays(prevProps.errors, nextProps.errors)) return false;
|
||||
|
||||
// Check field identity
|
||||
if (prevProps.field !== nextProps.field) return false;
|
||||
|
||||
// Shallow options comparison - only if field type is select or multi-select
|
||||
if (prevProps.field.fieldType?.type === 'select' || prevProps.field.fieldType?.type === 'multi-select') {
|
||||
const optionsEqual = prevProps.options === nextProps.options ||
|
||||
(Array.isArray(prevProps.options) &&
|
||||
Array.isArray(nextProps.options) &&
|
||||
prevProps.options.length === nextProps.options.length &&
|
||||
prevProps.options.every((opt, idx) => {
|
||||
// Handle safely when options might be undefined
|
||||
const nextOptions = nextProps.options || [];
|
||||
return opt === nextOptions[idx];
|
||||
}));
|
||||
|
||||
// Skip comparison of props that rarely change
|
||||
// (rowIndex, width, copyDown, totalRows)
|
||||
if (!optionsEqual) return false;
|
||||
}
|
||||
|
||||
return valueEqual && isValidatingEqual && fieldEqual && errorsEqual &&
|
||||
optionsEqual && itemNumberEqual;
|
||||
// Check copy down context changes
|
||||
const copyDownContextChanged =
|
||||
prevProps.rowIndex !== nextProps.rowIndex ||
|
||||
prevProps.fieldKey !== nextProps.fieldKey;
|
||||
|
||||
if (copyDownContextChanged) return false;
|
||||
|
||||
// All essential props are the same - we can skip re-rendering
|
||||
return true;
|
||||
});
|
||||
|
||||
ValidationCell.displayName = 'ValidationCell';
|
||||
|
||||
@@ -9,6 +9,7 @@ import SearchableTemplateSelect from './SearchableTemplateSelect'
|
||||
import { useAiValidation } from '../hooks/useAiValidation'
|
||||
import { AiValidationDialogs } from './AiValidationDialogs'
|
||||
import { Fields } from '../../../types'
|
||||
import { ErrorType, ValidationError, ErrorSources } from '../../../types'
|
||||
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
|
||||
import { TemplateForm } from '@/components/templates/TemplateForm'
|
||||
import axios from 'axios'
|
||||
@@ -16,6 +17,7 @@ import { RowSelectionState } from '@tanstack/react-table'
|
||||
import { useUpcValidation } from '../hooks/useUpcValidation'
|
||||
import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
|
||||
import UpcValidationTableAdapter from './UpcValidationTableAdapter'
|
||||
import { clearAllUniquenessCaches } from '../hooks/useValidation'
|
||||
|
||||
/**
|
||||
* ValidationContainer component - the main wrapper for the validation step
|
||||
@@ -114,6 +116,58 @@ const ValidationContainer = <T extends string>({
|
||||
const [templateFormInitialData, setTemplateFormInitialData] = useState<any>(null)
|
||||
const [fieldOptions, setFieldOptions] = useState<any>(null)
|
||||
|
||||
// Track fields that need revalidation due to value changes
|
||||
const [fieldsToRevalidate, setFieldsToRevalidate] = useState<Set<number>>(new Set());
|
||||
const [fieldsToRevalidateMap, setFieldsToRevalidateMap] = useState<{[rowIndex: number]: string[]}>({});
|
||||
|
||||
// Function to mark a row for revalidation
|
||||
const markRowForRevalidation = useCallback((rowIndex: number, fieldKey?: string) => {
|
||||
setFieldsToRevalidate(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(rowIndex);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Also track which specific field needs to be revalidated
|
||||
if (fieldKey) {
|
||||
setFieldsToRevalidateMap(prev => {
|
||||
const newMap = { ...prev };
|
||||
if (!newMap[rowIndex]) {
|
||||
newMap[rowIndex] = [];
|
||||
}
|
||||
if (!newMap[rowIndex].includes(fieldKey)) {
|
||||
newMap[rowIndex] = [...newMap[rowIndex], fieldKey];
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Add a ref to track the last validation time
|
||||
const lastValidationTime = useRef(0);
|
||||
|
||||
// Trigger revalidation only for specifically marked fields
|
||||
useEffect(() => {
|
||||
if (fieldsToRevalidate.size === 0) return;
|
||||
|
||||
// Revalidate the marked rows
|
||||
const rowsToRevalidate = Array.from(fieldsToRevalidate);
|
||||
|
||||
// Clear the revalidation set
|
||||
setFieldsToRevalidate(new Set());
|
||||
|
||||
// Get the fields map for revalidation
|
||||
const fieldsMap = { ...fieldsToRevalidateMap };
|
||||
|
||||
// Clear the fields map
|
||||
setFieldsToRevalidateMap({});
|
||||
|
||||
console.log(`Validating ${rowsToRevalidate.length} rows with specific fields`);
|
||||
|
||||
// Revalidate each row with specific fields information
|
||||
validationState.revalidateRows(rowsToRevalidate, fieldsMap);
|
||||
}, [fieldsToRevalidate, validationState, fieldsToRevalidateMap]);
|
||||
|
||||
// Function to fetch field options for template form
|
||||
const fetchFieldOptions = useCallback(async () => {
|
||||
try {
|
||||
@@ -246,14 +300,105 @@ const ValidationContainer = <T extends string>({
|
||||
}
|
||||
}, [prepareRowDataForTemplateForm, fetchFieldOptions]);
|
||||
|
||||
// Create a function to validate uniqueness if validateUniqueItemNumbers is not available
|
||||
const validateUniqueValues = useCallback(() => {
|
||||
// Check if validateUniqueItemNumbers exists on validationState using safer method
|
||||
if ('validateUniqueItemNumbers' in validationState &&
|
||||
typeof (validationState as any).validateUniqueItemNumbers === 'function') {
|
||||
(validationState as any).validateUniqueItemNumbers();
|
||||
} else {
|
||||
// Otherwise fall back to revalidating all rows
|
||||
validationState.revalidateRows(Array.from(Array(data.length).keys()));
|
||||
}
|
||||
}, [validationState, data.length]);
|
||||
|
||||
// Apply item numbers to data and trigger revalidation for uniqueness
|
||||
const applyItemNumbersAndValidate = useCallback(() => {
|
||||
// Clear uniqueness validation caches to ensure fresh validation
|
||||
clearAllUniquenessCaches();
|
||||
|
||||
upcValidation.applyItemNumbersToData((updatedRowIds) => {
|
||||
console.log(`Revalidating item numbers for ${updatedRowIds.length} rows`);
|
||||
|
||||
// Force clearing all uniqueness errors for item_number and upc fields first
|
||||
const newValidationErrors = new Map(validationErrors);
|
||||
|
||||
// Clear uniqueness errors for all rows that had their item numbers updated
|
||||
updatedRowIds.forEach(rowIndex => {
|
||||
const rowErrors = newValidationErrors.get(rowIndex);
|
||||
if (rowErrors) {
|
||||
// Create a copy of row errors without uniqueness errors for item_number/upc
|
||||
const filteredErrors: Record<string, ValidationError[]> = { ...rowErrors };
|
||||
let hasChanges = false;
|
||||
|
||||
// Clear item_number errors if they exist and are uniqueness errors
|
||||
if (filteredErrors.item_number &&
|
||||
filteredErrors.item_number.some(e => e.type === ErrorType.Unique)) {
|
||||
delete filteredErrors.item_number;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Also clear upc/barcode errors if they exist and are uniqueness errors
|
||||
if (filteredErrors.upc &&
|
||||
filteredErrors.upc.some(e => e.type === ErrorType.Unique)) {
|
||||
delete filteredErrors.upc;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (filteredErrors.barcode &&
|
||||
filteredErrors.barcode.some(e => e.type === ErrorType.Unique)) {
|
||||
delete filteredErrors.barcode;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Update the map or remove the row entry if no errors remain
|
||||
if (hasChanges) {
|
||||
if (Object.keys(filteredErrors).length > 0) {
|
||||
newValidationErrors.set(rowIndex, filteredErrors);
|
||||
} else {
|
||||
newValidationErrors.delete(rowIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Call the revalidateRows function directly with affected rows
|
||||
validationState.revalidateRows(updatedRowIds);
|
||||
|
||||
// Immediately run full uniqueness validation across all rows if available
|
||||
// This is crucial to properly identify new uniqueness issues
|
||||
setTimeout(() => {
|
||||
validateUniqueValues();
|
||||
}, 0);
|
||||
|
||||
// Mark all updated rows for revalidation
|
||||
updatedRowIds.forEach(rowIndex => {
|
||||
markRowForRevalidation(rowIndex, 'item_number');
|
||||
});
|
||||
});
|
||||
}, [upcValidation.applyItemNumbersToData, markRowForRevalidation, clearAllUniquenessCaches, validationErrors, validationState.revalidateRows, validateUniqueValues]);
|
||||
|
||||
// Handle next button click - memoized
|
||||
const handleNext = useCallback(() => {
|
||||
// Make sure any pending item numbers are applied
|
||||
upcValidation.applyItemNumbersToData();
|
||||
upcValidation.applyItemNumbersToData(updatedRowIds => {
|
||||
// Mark updated rows for revalidation
|
||||
updatedRowIds.forEach(rowIndex => {
|
||||
markRowForRevalidation(rowIndex, 'item_number');
|
||||
});
|
||||
|
||||
// Small delay to ensure all validations complete before proceeding
|
||||
setTimeout(() => {
|
||||
// Call the onNext callback with the validated data
|
||||
onNext?.(data)
|
||||
}, [onNext, data, upcValidation.applyItemNumbersToData]);
|
||||
onNext?.(data);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// If no item numbers to apply, just proceed
|
||||
if (upcValidation.validatingRows.size === 0) {
|
||||
onNext?.(data);
|
||||
}
|
||||
}, [onNext, data, upcValidation, markRowForRevalidation]);
|
||||
|
||||
const deleteSelectedRows = useCallback(() => {
|
||||
// Get selected row keys (which may be UUIDs)
|
||||
@@ -331,9 +476,73 @@ const ValidationContainer = <T extends string>({
|
||||
setRowSelection(newSelection);
|
||||
}, [setRowSelection]);
|
||||
|
||||
// Add scroll container ref at the container level
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollPosition = useRef({ left: 0, top: 0 });
|
||||
const isScrolling = useRef(false);
|
||||
|
||||
// Track if we're currently validating a UPC
|
||||
const isValidatingUpcRef = useRef(false);
|
||||
|
||||
// Track last UPC update to prevent conflicting changes
|
||||
const lastUpcUpdate = useRef({
|
||||
rowIndex: -1,
|
||||
supplier: "",
|
||||
upc: ""
|
||||
});
|
||||
|
||||
// Add these ref declarations here, at component level
|
||||
const lastCompanyFetchTime = useRef<Record<string, number>>({});
|
||||
const lastLineFetchTime = useRef<Record<string, number>>({});
|
||||
|
||||
// Memoize scroll handlers - simplified to avoid performance issues
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement> | Event) => {
|
||||
// Store scroll position directly without conditions
|
||||
const target = event.currentTarget as HTMLDivElement;
|
||||
lastScrollPosition.current = {
|
||||
left: target.scrollLeft,
|
||||
top: target.scrollTop
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add scroll event listener
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
// Convert React event handler to native event handler
|
||||
const nativeHandler = ((evt: Event) => {
|
||||
handleScroll(evt);
|
||||
}) as EventListener;
|
||||
|
||||
container.addEventListener('scroll', nativeHandler, { passive: true });
|
||||
return () => container.removeEventListener('scroll', nativeHandler);
|
||||
}
|
||||
}, [handleScroll]);
|
||||
|
||||
// Use a ref to track if we need to restore scroll position
|
||||
const needScrollRestore = useRef(false);
|
||||
|
||||
// Set flag when data changes
|
||||
useEffect(() => {
|
||||
needScrollRestore.current = true;
|
||||
// Only restore scroll on layout effects to avoid triggering rerenders
|
||||
}, []);
|
||||
|
||||
// Use layout effect for DOM manipulations
|
||||
useLayoutEffect(() => {
|
||||
if (!needScrollRestore.current) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (container && (lastScrollPosition.current.left > 0 || lastScrollPosition.current.top > 0)) {
|
||||
container.scrollLeft = lastScrollPosition.current.left;
|
||||
container.scrollTop = lastScrollPosition.current.top;
|
||||
needScrollRestore.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Ensure manual edits to item numbers persist with minimal changes to validation logic
|
||||
const handleUpdateRow = useCallback(async (rowIndex: number, key: T, value: any) => {
|
||||
// Process value before updating data
|
||||
console.log(`enhancedUpdateRow called: rowIndex=${rowIndex}, fieldKey=${key}, value=`, value);
|
||||
let processedValue = value;
|
||||
|
||||
// Strip dollar signs from price fields
|
||||
@@ -347,158 +556,272 @@ const ValidationContainer = <T extends string>({
|
||||
}
|
||||
}
|
||||
|
||||
// Save current scroll position
|
||||
const scrollPosition = {
|
||||
left: window.scrollX,
|
||||
top: window.scrollY
|
||||
};
|
||||
|
||||
// Find the original index in the data array
|
||||
// Find the row in the data
|
||||
const rowData = filteredData[rowIndex];
|
||||
const originalIndex = data.findIndex(item => item.__index === rowData?.__index);
|
||||
if (!rowData) {
|
||||
console.error(`No row data found for index ${rowIndex}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (originalIndex === -1) {
|
||||
// If we can't find the original row, just do a simple update
|
||||
updateRow(rowIndex, key, processedValue);
|
||||
} else {
|
||||
// Update the data directly
|
||||
// Use __index to find the actual row in the full data array
|
||||
const rowId = rowData.__index;
|
||||
const originalIndex = data.findIndex(item => item.__index === rowId);
|
||||
|
||||
// Detect if this is a direct item_number edit
|
||||
const isItemNumberEdit = key === 'item_number' as T;
|
||||
|
||||
// For item_number edits, we need special handling to ensure they persist
|
||||
if (isItemNumberEdit) {
|
||||
console.log(`Manual edit to item_number: ${value}`);
|
||||
|
||||
// First, update data immediately to ensure edit takes effect
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
const updatedRow = {
|
||||
if (originalIndex >= 0 && originalIndex < newData.length) {
|
||||
newData[originalIndex] = {
|
||||
...newData[originalIndex],
|
||||
[key]: processedValue
|
||||
};
|
||||
|
||||
newData[originalIndex] = updatedRow;
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
|
||||
// Restore scroll position after update
|
||||
// Mark for revalidation after a delay to ensure data update completes first
|
||||
setTimeout(() => {
|
||||
window.scrollTo(scrollPosition.left, scrollPosition.top);
|
||||
markRowForRevalidation(rowIndex, key as string);
|
||||
}, 0);
|
||||
|
||||
// Now handle any additional logic for specific fields
|
||||
if (key === 'company' && value) {
|
||||
// Clear any existing line/subline values for this row if company changes
|
||||
if (originalIndex !== -1) {
|
||||
// Return early to prevent double-updating
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other fields, use standard approach
|
||||
// Always use setData for updating - immediate update for better UX
|
||||
const updatedRow = { ...rowData, [key]: processedValue };
|
||||
|
||||
// Mark this row for revalidation to clear any existing errors
|
||||
markRowForRevalidation(rowIndex, key as string);
|
||||
|
||||
// Update the data immediately to show the change
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
if (originalIndex >= 0 && originalIndex < newData.length) {
|
||||
// Create a new row object with the updated field
|
||||
newData[originalIndex] = {
|
||||
...newData[originalIndex],
|
||||
[key]: processedValue
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Secondary effects - using a timeout to ensure UI updates first
|
||||
setTimeout(() => {
|
||||
// Handle company change - clear line/subline and fetch product lines
|
||||
if (key === 'company' && value) {
|
||||
console.log(`Company changed to ${value} for row ${rowIndex}, updating lines and sublines`);
|
||||
|
||||
// Clear any existing line/subline values immediately
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
const idx = newData.findIndex(item => item.__index === rowId);
|
||||
if (idx >= 0) {
|
||||
console.log(`Clearing line and subline values for row with ID ${rowId}`);
|
||||
newData[idx] = {
|
||||
...newData[idx],
|
||||
line: undefined,
|
||||
subline: undefined
|
||||
};
|
||||
} else {
|
||||
console.warn(`Could not find row with ID ${rowId} to clear line/subline values`);
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
|
||||
// Use cached product lines if available, otherwise fetch
|
||||
if (rowData && rowData.__index) {
|
||||
const companyId = value.toString();
|
||||
if (rowProductLines[companyId]) {
|
||||
// Use cached data
|
||||
console.log(`Using cached product lines for company ${companyId}`);
|
||||
} else {
|
||||
// Fetch product lines for the new company
|
||||
if (value !== undefined) {
|
||||
await fetchProductLines(rowData.__index as string, companyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rowId && value !== undefined) {
|
||||
const companyId = value.toString();
|
||||
|
||||
// If updating supplier field AND there's a UPC value, validate UPC
|
||||
if (key === 'supplier' && value && rowData) {
|
||||
const rowDataAny = rowData as Record<string, any>;
|
||||
if (rowDataAny.upc || rowDataAny.barcode) {
|
||||
const upcValue = rowDataAny.upc || rowDataAny.barcode;
|
||||
// Force immediate fetch for better UX
|
||||
console.log(`Immediately fetching product lines for company ${companyId} for row ${rowId}`);
|
||||
|
||||
try {
|
||||
// Mark the item_number cell as being validated
|
||||
// Set loading state first
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(`${rowIndex}-item_number`);
|
||||
newSet.add(`${rowIndex}-line`);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Use supplier ID (the value being set) to validate UPC
|
||||
await upcValidation.validateUpc(rowIndex, value.toString(), upcValue.toString());
|
||||
} catch (error) {
|
||||
console.error('Error validating UPC:', error);
|
||||
} finally {
|
||||
fetchProductLines(rowId, companyId)
|
||||
.then(lines => {
|
||||
console.log(`Successfully loaded ${lines.length} product lines for company ${companyId}`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error fetching product lines for company ${companyId}:`, err);
|
||||
toast.error("Failed to load product lines");
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear loading indicator
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(`${rowIndex}-line`);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle supplier + UPC validation - using the most recent values
|
||||
if (key === 'supplier' && value) {
|
||||
// Get the latest UPC value from the updated row
|
||||
const upcValue = updatedRow.upc || updatedRow.barcode;
|
||||
|
||||
if (upcValue) {
|
||||
console.log(`Validating UPC: rowIndex=${rowIndex}, supplier=${value}, upc=${upcValue}`);
|
||||
|
||||
// Mark the item_number cell as being validated
|
||||
const cellKey = `${rowIndex}-item_number`;
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Use a regular promise-based approach instead of await
|
||||
upcValidation.validateUpc(rowIndex, value.toString(), upcValue.toString())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
console.log(`UPC validation successful for row ${rowIndex}`);
|
||||
upcValidation.applyItemNumbersToData();
|
||||
|
||||
// Mark for revalidation after item numbers are updated
|
||||
setTimeout(() => {
|
||||
markRowForRevalidation(rowIndex, 'item_number');
|
||||
}, 50);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error validating UPC:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear validation state for the item_number cell
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(`${rowIndex}-item_number`);
|
||||
newSet.delete(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If updating line field, fetch sublines
|
||||
// Handle line change - clear subline and fetch sublines
|
||||
if (key === 'line' && value) {
|
||||
// Clear any existing subline value for this row
|
||||
if (originalIndex !== -1) {
|
||||
console.log(`Line changed to ${value} for row ${rowIndex}, updating sublines`);
|
||||
|
||||
// Clear any existing subline value
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
newData[originalIndex] = {
|
||||
...newData[originalIndex],
|
||||
const idx = newData.findIndex(item => item.__index === rowId);
|
||||
if (idx >= 0) {
|
||||
console.log(`Clearing subline values for row with ID ${rowId}`);
|
||||
newData[idx] = {
|
||||
...newData[idx],
|
||||
subline: undefined
|
||||
};
|
||||
} else {
|
||||
console.warn(`Could not find row with ID ${rowId} to clear subline values`);
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
}
|
||||
|
||||
// Use cached sublines if available, otherwise fetch
|
||||
if (rowData && rowData.__index) {
|
||||
const lineId = value.toString();
|
||||
if (rowSublines[lineId]) {
|
||||
// Use cached data
|
||||
console.log(`Using cached sublines for line ${lineId}`);
|
||||
} else {
|
||||
// Fetch sublines for the new line
|
||||
if (value !== undefined) {
|
||||
await fetchSublines(rowData.__index as string, lineId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rowId && value !== undefined) {
|
||||
const lineId = value.toString();
|
||||
|
||||
// If updating UPC/barcode field AND there's a supplier value, validate UPC
|
||||
if ((key === 'upc' || key === 'barcode') && value && rowData) {
|
||||
const rowDataAny = rowData as Record<string, any>;
|
||||
if (rowDataAny.supplier) {
|
||||
try {
|
||||
// Mark the item_number cell as being validated
|
||||
// Force immediate fetch for better UX
|
||||
console.log(`Immediately fetching sublines for line ${lineId} for row ${rowId}`);
|
||||
|
||||
// Set loading state first
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(`${rowIndex}-item_number`);
|
||||
newSet.add(`${rowIndex}-subline`);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Use supplier ID from the row data to validate UPC
|
||||
await upcValidation.validateUpc(rowIndex, rowDataAny.supplier.toString(), value.toString());
|
||||
} catch (error) {
|
||||
console.error('Error validating UPC:', error);
|
||||
} finally {
|
||||
fetchSublines(rowId, lineId)
|
||||
.then(sublines => {
|
||||
console.log(`Successfully loaded ${sublines.length} sublines for line ${lineId}`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error fetching sublines for line ${lineId}:`, err);
|
||||
toast.error("Failed to load sublines");
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear loading indicator
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(`${rowIndex}-subline`);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the UPC/barcode validation handler back:
|
||||
// Handle UPC/barcode + supplier validation
|
||||
if ((key === 'upc' || key === 'barcode') && value) {
|
||||
// Get latest supplier from the updated row
|
||||
const supplier = updatedRow.supplier;
|
||||
|
||||
if (supplier) {
|
||||
console.log(`Validating UPC from UPC change: rowIndex=${rowIndex}, supplier=${supplier}, upc=${value}`);
|
||||
|
||||
// Mark the item_number cell as being validated
|
||||
const cellKey = `${rowIndex}-item_number`;
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Use a regular promise-based approach
|
||||
upcValidation.validateUpc(rowIndex, supplier.toString(), value.toString())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
console.log(`UPC validation successful for row ${rowIndex}`);
|
||||
upcValidation.applyItemNumbersToData();
|
||||
|
||||
// Mark for revalidation after item numbers are updated
|
||||
setTimeout(() => {
|
||||
markRowForRevalidation(rowIndex, 'item_number');
|
||||
}, 50);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error validating UPC:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear validation state for the item_number cell
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(`${rowIndex}-item_number`);
|
||||
newSet.delete(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [data, filteredData, updateRow, fetchProductLines, fetchSublines, setData, rowProductLines, rowSublines, upcValidation]);
|
||||
}, 0); // Using 0ms timeout to defer execution until after the UI update
|
||||
}, [data, filteredData, setData, fetchProductLines, fetchSublines, upcValidation, markRowForRevalidation]);
|
||||
|
||||
// Create a separate copyDown function that uses handleUpdateRow
|
||||
// Fix the missing loading indicator clear code
|
||||
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => {
|
||||
// Get the value to copy from the source row
|
||||
const sourceRow = data[rowIndex];
|
||||
if (!sourceRow) {
|
||||
console.error(`Source row ${rowIndex} not found for copyDown`);
|
||||
return;
|
||||
}
|
||||
|
||||
const valueToCopy = sourceRow[fieldKey];
|
||||
|
||||
// Create a proper copy of the value to avoid reference issues, especially for arrays (MultiSelectCell)
|
||||
@@ -506,50 +829,183 @@ const ValidationContainer = <T extends string>({
|
||||
|
||||
// Get all rows below the source row, up to endRowIndex if specified
|
||||
const lastRowIndex = endRowIndex !== undefined ? Math.min(endRowIndex, data.length - 1) : data.length - 1;
|
||||
const rowsToUpdate = data.slice(rowIndex + 1, lastRowIndex + 1);
|
||||
const rowsToUpdate = Array.from({ length: lastRowIndex - rowIndex }, (_, i) => rowIndex + i + 1);
|
||||
|
||||
// Create a set of cells that will be in loading state
|
||||
const loadingCells = new Set<string>();
|
||||
|
||||
// Add all target cells to the loading state
|
||||
rowsToUpdate.forEach((_, index) => {
|
||||
const targetRowIndex = rowIndex + 1 + index;
|
||||
loadingCells.add(`${targetRowIndex}-${fieldKey}`);
|
||||
// Mark all cells as updating at once
|
||||
const updatingCells = new Set<string>();
|
||||
rowsToUpdate.forEach(targetRowIndex => {
|
||||
updatingCells.add(`${targetRowIndex}-${fieldKey}`);
|
||||
});
|
||||
|
||||
// Update validatingCells to show loading state
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
loadingCells.forEach(cell => newSet.add(cell));
|
||||
updatingCells.forEach(cell => newSet.add(cell));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Update all rows immediately
|
||||
rowsToUpdate.forEach((_, i) => {
|
||||
const targetRowIndex = rowIndex + 1 + i;
|
||||
// Update all rows at once efficiently with a single state update
|
||||
setData(prevData => {
|
||||
// Create a new copy of the data
|
||||
const newData = [...prevData];
|
||||
|
||||
// Update the row with the copied value
|
||||
handleUpdateRow(targetRowIndex, fieldKey as T, valueCopy);
|
||||
// Update all rows at once
|
||||
rowsToUpdate.forEach(targetRowIndex => {
|
||||
// Find the original row using __index
|
||||
const rowData = filteredData[targetRowIndex];
|
||||
if (!rowData) return;
|
||||
|
||||
// Remove loading state
|
||||
const rowId = rowData.__index;
|
||||
const originalIndex = newData.findIndex(item => item.__index === rowId);
|
||||
|
||||
if (originalIndex !== -1) {
|
||||
// Update the specific field on this row
|
||||
newData[originalIndex] = {
|
||||
...newData[originalIndex],
|
||||
[fieldKey]: valueCopy
|
||||
};
|
||||
} else {
|
||||
// Fall back to direct index if __index not found
|
||||
if (targetRowIndex < newData.length) {
|
||||
newData[targetRowIndex] = {
|
||||
...newData[targetRowIndex],
|
||||
[fieldKey]: valueCopy
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Mark rows for revalidation
|
||||
rowsToUpdate.forEach(targetRowIndex => {
|
||||
markRowForRevalidation(targetRowIndex, fieldKey);
|
||||
});
|
||||
|
||||
// Clear the loading state for all cells after a short delay
|
||||
setTimeout(() => {
|
||||
setValidatingCells(prev => {
|
||||
if (prev.size === 0) return prev;
|
||||
const newSet = new Set(prev);
|
||||
updatingCells.forEach(cell => newSet.delete(cell));
|
||||
return newSet;
|
||||
});
|
||||
}, 100);
|
||||
|
||||
// If copying UPC or supplier fields, validate UPC for all rows
|
||||
if (fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier') {
|
||||
// Process each row in parallel
|
||||
const validationsToRun: {rowIndex: number, supplier: string, upc: string}[] = [];
|
||||
|
||||
// Process each row separately to collect validation tasks
|
||||
rowsToUpdate.forEach(targetRowIndex => {
|
||||
const rowData = filteredData[targetRowIndex];
|
||||
if (!rowData) return;
|
||||
|
||||
// Only validate if both UPC and supplier are present after the update
|
||||
const updatedRow = {
|
||||
...rowData,
|
||||
[fieldKey]: valueCopy
|
||||
};
|
||||
|
||||
const hasUpc = updatedRow.upc || updatedRow.barcode;
|
||||
const hasSupplier = updatedRow.supplier;
|
||||
|
||||
if (hasUpc && hasSupplier) {
|
||||
const upcValue = updatedRow.upc || updatedRow.barcode;
|
||||
const supplierId = updatedRow.supplier;
|
||||
|
||||
// Queue this validation if both values are defined
|
||||
if (supplierId !== undefined && upcValue !== undefined) {
|
||||
validationsToRun.push({
|
||||
rowIndex: targetRowIndex,
|
||||
supplier: supplierId.toString(),
|
||||
upc: upcValue.toString()
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Run validations in parallel but limit the batch size
|
||||
if (validationsToRun.length > 0) {
|
||||
console.log(`Running ${validationsToRun.length} UPC validations for copyDown`);
|
||||
|
||||
// Mark all cells as validating
|
||||
validationsToRun.forEach(({ rowIndex }) => {
|
||||
const cellKey = `${rowIndex}-item_number`;
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(`${targetRowIndex}-${fieldKey}`);
|
||||
newSet.add(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
}, [data, handleUpdateRow, setValidatingCells]);
|
||||
|
||||
// Use UPC validation when data changes
|
||||
useEffect(() => {
|
||||
// Skip if there's no data or already validated
|
||||
if (data.length === 0 || upcValidation.initialValidationDone) return;
|
||||
// Process in smaller batches to avoid overwhelming the system
|
||||
const BATCH_SIZE = 5; // Process 5 validations at a time
|
||||
const processBatch = (startIdx: number) => {
|
||||
const endIdx = Math.min(startIdx + BATCH_SIZE, validationsToRun.length);
|
||||
const batch = validationsToRun.slice(startIdx, endIdx);
|
||||
|
||||
// Run validation immediately without timeout
|
||||
upcValidation.validateAllUPCs();
|
||||
Promise.all(
|
||||
batch.map(({ rowIndex, supplier, upc }) =>
|
||||
upcValidation.validateUpc(rowIndex, supplier, upc)
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
// Apply immediately for better UX
|
||||
if (startIdx + BATCH_SIZE >= validationsToRun.length) {
|
||||
// Apply all updates at the end with callback to mark for revalidation
|
||||
upcValidation.applyItemNumbersToData(updatedRowIds => {
|
||||
// Mark these rows for revalidation after a delay
|
||||
setTimeout(() => {
|
||||
updatedRowIds.forEach(rowIdx => {
|
||||
markRowForRevalidation(rowIdx, 'item_number');
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
return { rowIndex, success: result.success };
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error validating UPC for row ${rowIndex}:`, err);
|
||||
return { rowIndex, success: false };
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear validation state for this cell
|
||||
const cellKey = `${rowIndex}-item_number`;
|
||||
setValidatingCells(prev => {
|
||||
if (!prev.has(cellKey)) return prev;
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
})
|
||||
)
|
||||
).then(() => {
|
||||
// If there are more validations to run, process the next batch
|
||||
if (endIdx < validationsToRun.length) {
|
||||
// Add a small delay between batches to prevent UI freezing
|
||||
setTimeout(() => processBatch(endIdx), 100);
|
||||
} else {
|
||||
console.log(`Completed all ${validationsToRun.length} UPC validations`);
|
||||
// Final application of all item numbers if not done by individual batches
|
||||
upcValidation.applyItemNumbersToData(updatedRowIds => {
|
||||
// Mark these rows for revalidation after a delay
|
||||
setTimeout(() => {
|
||||
updatedRowIds.forEach(rowIdx => {
|
||||
markRowForRevalidation(rowIdx, 'item_number');
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// No cleanup needed since we're not using a timer
|
||||
}, [data, upcValidation]);
|
||||
// Start processing the first batch
|
||||
processBatch(0);
|
||||
}
|
||||
}
|
||||
}, [data, filteredData, setData, setValidatingCells, upcValidation, markRowForRevalidation]);
|
||||
|
||||
// Memoize the rendered validation table
|
||||
const renderValidationTable = useMemo(() => {
|
||||
@@ -611,74 +1067,6 @@ const ValidationContainer = <T extends string>({
|
||||
isLoadingSublines
|
||||
]);
|
||||
|
||||
// Add scroll container ref at the container level
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollPosition = useRef({ left: 0, top: 0 });
|
||||
const isScrolling = useRef(false);
|
||||
|
||||
// Memoize scroll handlers
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement> | Event) => {
|
||||
if (!isScrolling.current) {
|
||||
isScrolling.current = true;
|
||||
// Use type assertion to handle both React.UIEvent and native Event
|
||||
const target = event.currentTarget as HTMLDivElement;
|
||||
lastScrollPosition.current = {
|
||||
left: target.scrollLeft,
|
||||
top: target.scrollTop
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
isScrolling.current = false;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Add scroll event listener
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
// Convert React event handler to native event handler
|
||||
const nativeHandler = ((evt: Event) => {
|
||||
handleScroll(evt);
|
||||
}) as EventListener;
|
||||
|
||||
container.addEventListener('scroll', nativeHandler, { passive: true });
|
||||
return () => container.removeEventListener('scroll', nativeHandler);
|
||||
}
|
||||
}, [handleScroll]);
|
||||
|
||||
// Restore scroll position after data updates
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
const { left, top } = lastScrollPosition.current;
|
||||
if (left > 0 || top > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
if (container) {
|
||||
container.scrollTo({
|
||||
left,
|
||||
top,
|
||||
behavior: 'auto'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [filteredData]);
|
||||
|
||||
// Add cleanup effect to reset scroll position when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Reset the last scroll position reference
|
||||
lastScrollPosition.current = { left: 0, top: 0 };
|
||||
|
||||
// Reset the scroll container if it exists
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
scrollContainerRef.current.scrollLeft = 0;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
||||
@@ -189,10 +189,6 @@ const ValidationTable = <T extends string>({
|
||||
}: ValidationTableProps<T>) => {
|
||||
const { translations } = useRsi<T>();
|
||||
|
||||
// Debug logs
|
||||
console.log('ValidationTable rowProductLines:', rowProductLines);
|
||||
console.log('ValidationTable rowSublines:', rowSublines);
|
||||
|
||||
// Add state for copy down selection mode
|
||||
const [isInCopyDownMode, setIsInCopyDownMode] = useState(false);
|
||||
const [sourceRowIndex, setSourceRowIndex] = useState<number | null>(null);
|
||||
@@ -308,10 +304,13 @@ const ValidationTable = <T extends string>({
|
||||
const cache = new Map<string, readonly any[]>();
|
||||
|
||||
fields.forEach((field) => {
|
||||
// Don't skip disabled fields
|
||||
|
||||
if (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select') {
|
||||
// Get the field key
|
||||
const fieldKey = String(field.key);
|
||||
|
||||
// Handle all select and multi-select fields the same way
|
||||
if (field.fieldType &&
|
||||
(typeof field.fieldType === 'object') &&
|
||||
(field.fieldType.type === 'select' || field.fieldType.type === 'multi-select')) {
|
||||
cache.set(fieldKey, (field.fieldType as any).options || []);
|
||||
}
|
||||
});
|
||||
@@ -357,30 +356,35 @@ const ValidationTable = <T extends string>({
|
||||
|
||||
if (fieldKey === 'line' && rowId && rowProductLines[rowId]) {
|
||||
options = rowProductLines[rowId];
|
||||
console.log(`Setting line options for row ${rowId}:`, options);
|
||||
} else if (fieldKey === 'subline' && rowId && rowSublines[rowId]) {
|
||||
options = rowSublines[rowId];
|
||||
console.log(`Setting subline options for row ${rowId}:`, options);
|
||||
}
|
||||
|
||||
// Determine if this cell is in loading state
|
||||
let isLoading = validatingCells.has(`${row.index}-${field.key}`);
|
||||
// Determine if this cell is in loading state - use a clear consistent approach
|
||||
let isLoading = false;
|
||||
|
||||
// Check the validatingCells Set first (for item_number and other fields)
|
||||
const cellLoadingKey = `${row.index}-${fieldKey}`;
|
||||
if (validatingCells.has(cellLoadingKey)) {
|
||||
isLoading = true;
|
||||
}
|
||||
// Add loading state for line/subline fields
|
||||
if (fieldKey === 'line' && rowId && isLoadingLines[rowId]) {
|
||||
else if (fieldKey === 'line' && rowId && isLoadingLines[rowId]) {
|
||||
isLoading = true;
|
||||
console.log(`Line field for row ${rowId} is loading`);
|
||||
} else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) {
|
||||
isLoading = true;
|
||||
console.log(`Subline field for row ${rowId} is loading`);
|
||||
}
|
||||
else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) {
|
||||
isLoading = true;
|
||||
}
|
||||
|
||||
// Get validation errors for this cell
|
||||
const cellErrors = validationErrors.get(row.index)?.[fieldKey] || [];
|
||||
|
||||
return (
|
||||
<MemoizedCell
|
||||
field={field as Field<string>}
|
||||
value={row.original[field.key as keyof typeof row.original]}
|
||||
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
|
||||
errors={validationErrors.get(row.index)?.[fieldKey] || []}
|
||||
errors={cellErrors}
|
||||
isValidating={isLoading}
|
||||
fieldKey={fieldKey}
|
||||
options={options}
|
||||
@@ -494,10 +498,13 @@ const ValidationTable = <T extends string>({
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
{/* Custom Table Header - Always Visible */}
|
||||
{/* Custom Table Header - Always Visible with GPU acceleration */}
|
||||
<div
|
||||
className={`sticky top-0 z-20 bg-muted border-b shadow-sm`}
|
||||
style={{ width: `${totalWidth}px` }}
|
||||
className="sticky top-0 z-20 bg-muted border-b shadow-sm will-change-transform"
|
||||
style={{
|
||||
width: `${totalWidth}px`,
|
||||
transform: 'translateZ(0)', // Force GPU acceleration
|
||||
}}
|
||||
>
|
||||
<div className="flex">
|
||||
{table.getFlatHeaders().map((header) => {
|
||||
@@ -521,49 +528,57 @@ const ValidationTable = <T extends string>({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body - Restore the original structure */}
|
||||
<Table style={{ width: `${totalWidth}px`, tableLayout: 'fixed', borderCollapse: 'separate', borderSpacing: 0, marginTop: '-1px' }}>
|
||||
{/* Table Body - With optimized rendering */}
|
||||
<Table style={{
|
||||
width: `${totalWidth}px`,
|
||||
tableLayout: 'fixed',
|
||||
borderCollapse: 'separate',
|
||||
borderSpacing: 0,
|
||||
marginTop: '-1px',
|
||||
willChange: 'transform', // Help browser optimize
|
||||
contain: 'content', // Contain paint operations
|
||||
transform: 'translateZ(0)' // Force GPU acceleration
|
||||
}}>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
// Precompute validation error status for this row
|
||||
const hasErrors = validationErrors.has(parseInt(row.id)) &&
|
||||
Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0;
|
||||
|
||||
// Precompute copy down target status
|
||||
const isCopyDownTarget = isInCopyDownMode &&
|
||||
sourceRowIndex !== null &&
|
||||
parseInt(row.id) > sourceRowIndex;
|
||||
|
||||
// Using CSS variables for better performance on hover/state changes
|
||||
const rowStyle = {
|
||||
cursor: isCopyDownTarget ? 'pointer' : undefined,
|
||||
position: 'relative' as const,
|
||||
willChange: isInCopyDownMode ? 'background-color' : 'auto',
|
||||
contain: 'layout',
|
||||
transition: 'background-color 100ms ease-in-out'
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"hover:bg-muted/50",
|
||||
row.getIsSelected() ? "bg-muted/50" : "",
|
||||
validationErrors.get(parseInt(row.id)) &&
|
||||
Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0 ? "bg-red-50/40" : "",
|
||||
// Add cursor-pointer class when in copy down mode for target rows
|
||||
isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? "cursor-pointer copy-down-target-row" : ""
|
||||
hasErrors ? "bg-red-50/40" : "",
|
||||
isCopyDownTarget ? "cursor-pointer copy-down-target-row" : ""
|
||||
)}
|
||||
style={{
|
||||
// Force cursor pointer on all target rows
|
||||
cursor: isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? 'pointer' : undefined,
|
||||
position: 'relative' // Ensure we can position the overlay
|
||||
}}
|
||||
style={rowStyle}
|
||||
onMouseEnter={() => handleRowMouseEnter(parseInt(row.id))}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: any) => {
|
||||
const width = cell.column.getSize();
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
minWidth: `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
boxSizing: 'border-box',
|
||||
padding: '0',
|
||||
// Force cursor pointer on all cells in target rows
|
||||
cursor: isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? 'pointer' : undefined
|
||||
}}
|
||||
className={isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? "target-row-cell" : ""}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: any) => (
|
||||
<React.Fragment key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -107,23 +107,28 @@ const InputCell = <T extends string>({
|
||||
|
||||
// Handle blur event - use transition for non-critical updates
|
||||
const handleBlur = useCallback(() => {
|
||||
// First - lock in the current edit value to prevent it from being lost
|
||||
const finalValue = editValue.trim();
|
||||
|
||||
// Then transition to non-editing state
|
||||
startTransition(() => {
|
||||
setIsEditing(false);
|
||||
|
||||
// Format the value for storage (remove formatting like $ for price)
|
||||
let processedValue = deferredEditValue.trim();
|
||||
let processedValue = finalValue;
|
||||
|
||||
if (isPrice && processedValue) {
|
||||
needsProcessingRef.current = true;
|
||||
}
|
||||
|
||||
// Update local display value immediately
|
||||
// Update local display value immediately to prevent UI flicker
|
||||
setLocalDisplayValue(processedValue);
|
||||
|
||||
// Commit the change to parent component
|
||||
onChange(processedValue);
|
||||
onEndEdit?.();
|
||||
});
|
||||
}, [deferredEditValue, onChange, onEndEdit, isPrice]);
|
||||
}, [editValue, onChange, onEndEdit, isPrice]);
|
||||
|
||||
// Handle direct input change - optimized to be synchronous for typing
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
|
||||
@@ -167,41 +167,59 @@ const MultiSelectCell = <T extends string>({
|
||||
const commandListRef = useRef<HTMLDivElement>(null)
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
// Add ref to track if we need to sync internal state with external value
|
||||
const shouldSyncWithExternalValue = useRef(true)
|
||||
|
||||
// Create a memoized Set for fast lookups of selected values
|
||||
const selectedValueSet = useMemo(() => new Set(internalValue), [internalValue]);
|
||||
|
||||
// Sync internalValue with external value when component mounts or value changes externally
|
||||
// Modified to prevent infinite loop by checking if values are different before updating
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Ensure value is always an array
|
||||
setInternalValue(Array.isArray(value) ? value : [])
|
||||
// Only sync if we should (not during internal edits) and if not open
|
||||
if (shouldSyncWithExternalValue.current && !open) {
|
||||
const externalValue = Array.isArray(value) ? value : [];
|
||||
|
||||
// Only update if values are actually different to prevent infinite loops
|
||||
if (internalValue.length !== externalValue.length ||
|
||||
!internalValue.every(v => externalValue.includes(v)) ||
|
||||
!externalValue.every(v => internalValue.includes(v))) {
|
||||
setInternalValue(externalValue);
|
||||
}
|
||||
}, [value, open])
|
||||
}
|
||||
}, [value, open, internalValue]);
|
||||
|
||||
// Handle open state changes with improved responsiveness
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
if (open && !newOpen) {
|
||||
// Prevent syncing with external value during our internal update
|
||||
shouldSyncWithExternalValue.current = false;
|
||||
|
||||
// Only update parent state when dropdown closes
|
||||
// Avoid expensive deep comparison if lengths are different
|
||||
if (internalValue.length !== value.length ||
|
||||
internalValue.some((v, i) => v !== value[i])) {
|
||||
onChange(internalValue);
|
||||
}
|
||||
// Make a defensive copy to avoid mutations
|
||||
const valuesToCommit = [...internalValue];
|
||||
|
||||
// Immediate UI update
|
||||
setOpen(false);
|
||||
|
||||
// Update parent with the value immediately
|
||||
onChange(valuesToCommit);
|
||||
if (onEndEdit) onEndEdit();
|
||||
|
||||
// Allow syncing with external value again after a short delay
|
||||
setTimeout(() => {
|
||||
shouldSyncWithExternalValue.current = true;
|
||||
}, 0);
|
||||
} else if (newOpen && !open) {
|
||||
// Sync internal state with external state when opening
|
||||
setInternalValue(Array.isArray(value) ? value : []);
|
||||
// When opening the dropdown, sync with external value
|
||||
const externalValue = Array.isArray(value) ? value : [];
|
||||
setInternalValue(externalValue);
|
||||
setSearchQuery(""); // Reset search query on open
|
||||
setOpen(true);
|
||||
if (onStartEdit) onStartEdit();
|
||||
} else if (!newOpen) {
|
||||
// Handle case when dropdown is already closed but handleOpenChange is called
|
||||
// This ensures values are saved when clicking the chevron to close
|
||||
if (internalValue.length !== value.length ||
|
||||
internalValue.some((v, i) => v !== value[i])) {
|
||||
onChange(internalValue);
|
||||
}
|
||||
if (onEndEdit) onEndEdit();
|
||||
setOpen(false);
|
||||
}
|
||||
}, [open, internalValue, value, onChange, onStartEdit, onEndEdit]);
|
||||
|
||||
@@ -302,13 +320,25 @@ const MultiSelectCell = <T extends string>({
|
||||
|
||||
// Update the handleSelect to operate on internalValue instead of directly calling onChange
|
||||
const handleSelect = useCallback((selectedValue: string) => {
|
||||
// Prevent syncing with external value during our internal update
|
||||
shouldSyncWithExternalValue.current = false;
|
||||
|
||||
setInternalValue(prev => {
|
||||
let newValue;
|
||||
if (prev.includes(selectedValue)) {
|
||||
return prev.filter(v => v !== selectedValue);
|
||||
// Remove the value
|
||||
newValue = prev.filter(v => v !== selectedValue);
|
||||
} else {
|
||||
return [...prev, selectedValue];
|
||||
// Add the value - make a new array to avoid mutations
|
||||
newValue = [...prev, selectedValue];
|
||||
}
|
||||
return newValue;
|
||||
});
|
||||
|
||||
// Allow syncing with external value again after a short delay
|
||||
setTimeout(() => {
|
||||
shouldSyncWithExternalValue.current = true;
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
// Handle wheel scroll in dropdown
|
||||
|
||||
@@ -51,8 +51,6 @@ const SelectCell = <T extends string>({
|
||||
// Add state for hover
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
console.log(`SelectCell: field.key=${field.key}, disabled=${disabled}, options=`, options);
|
||||
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = className.split(' ');
|
||||
@@ -68,7 +66,6 @@ const SelectCell = <T extends string>({
|
||||
|
||||
// Memoize options processing to avoid recalculation on every render
|
||||
const selectOptions = useMemo(() => {
|
||||
console.log(`Processing options for ${field.key}:`, options);
|
||||
// Fast path check - if we have raw options, just use those
|
||||
if (options && options.length > 0) {
|
||||
// Check if options already have the correct structure to avoid mapping
|
||||
@@ -126,8 +123,11 @@ const SelectCell = <T extends string>({
|
||||
|
||||
// Handle selection - UPDATE INTERNAL VALUE FIRST
|
||||
const handleSelect = useCallback((selectedValue: string) => {
|
||||
// Store the selected value to prevent it being lost in async operations
|
||||
const valueToCommit = selectedValue;
|
||||
|
||||
// 1. Update internal value immediately to prevent UI flicker
|
||||
setInternalValue(selectedValue);
|
||||
setInternalValue(valueToCommit);
|
||||
|
||||
// 2. Close the dropdown immediately
|
||||
setOpen(false);
|
||||
@@ -139,37 +139,25 @@ const SelectCell = <T extends string>({
|
||||
// This prevents the parent component from re-rendering and causing dropdown to reopen
|
||||
if (onEndEdit) onEndEdit();
|
||||
|
||||
// 5. Call onChange in the next tick to avoid synchronous re-renders
|
||||
// 5. Call onChange synchronously to avoid race conditions with other cells
|
||||
onChange(valueToCommit);
|
||||
|
||||
// 6. Clear processing state after a short delay
|
||||
setTimeout(() => {
|
||||
onChange(selectedValue);
|
||||
}, 0);
|
||||
setIsProcessing(false);
|
||||
}, 200);
|
||||
}, [onChange, onEndEdit]);
|
||||
|
||||
// If disabled, render a static view
|
||||
if (disabled) {
|
||||
const displayText = displayValue;
|
||||
|
||||
// For debugging, let's render the Popover component even if disabled
|
||||
// This will help us determine if the issue is with the disabled state
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen && onStartEdit) onStartEdit();
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
|
||||
"border",
|
||||
!internalValue && "text-muted-foreground",
|
||||
isProcessing && "text-muted-foreground",
|
||||
hasErrors ? "border-destructive" : "",
|
||||
hasErrors ? "border-destructive" : "border-input",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
@@ -182,62 +170,13 @@ const SelectCell = <T extends string>({
|
||||
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
|
||||
undefined,
|
||||
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
|
||||
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
|
||||
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
if (!open && onStartEdit) onStartEdit();
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span className={isProcessing ? "opacity-70" : ""}>
|
||||
{displayValue}
|
||||
</span>
|
||||
<ChevronsUpDown className="mr-1.5 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0 w-[var(--radix-popover-trigger-width)]"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<Command shouldFilter={true}>
|
||||
<CommandInput
|
||||
placeholder="Search..."
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList
|
||||
ref={commandListRef}
|
||||
onWheel={handleWheel}
|
||||
className="max-h-[200px]"
|
||||
>
|
||||
<CommandEmpty>No options found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{selectOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(value) => handleSelect(value)}
|
||||
className="flex w-full"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 flex-shrink-0",
|
||||
internalValue === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate w-full">{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{displayText || ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,24 +39,27 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
|
||||
|
||||
// Fetch product lines from API
|
||||
const productLinesUrl = `/api/import/product-lines/${companyId}`;
|
||||
console.log(`Fetching from URL: ${productLinesUrl}`);
|
||||
const response = await axios.get(productLinesUrl);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to fetch product lines: ${response.status}`);
|
||||
}
|
||||
|
||||
const productLines = response.data;
|
||||
console.log(`Received ${productLines.length} product lines for company ${companyId}`);
|
||||
const lines = response.data;
|
||||
console.log(`Received ${lines.length} product lines for company ${companyId}`);
|
||||
|
||||
// Format the data properly for dropdown display
|
||||
const formattedLines = lines.map((line: any) => ({
|
||||
label: line.name || line.label || String(line.value || line.id),
|
||||
value: String(line.value || line.id)
|
||||
}));
|
||||
|
||||
// Store in company cache
|
||||
setCompanyLinesCache(prev => ({ ...prev, [companyId]: productLines }));
|
||||
setCompanyLinesCache(prev => ({ ...prev, [companyId]: formattedLines }));
|
||||
|
||||
// Store for this specific row
|
||||
setRowProductLines(prev => ({ ...prev, [rowIndex]: productLines }));
|
||||
setRowProductLines(prev => ({ ...prev, [rowIndex]: formattedLines }));
|
||||
|
||||
return productLines;
|
||||
return formattedLines;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching product lines for company ${companyId}:`, error);
|
||||
toast.error(`Failed to load product lines for company ${companyId}`);
|
||||
|
||||
// Set empty array for this company to prevent repeated failed requests
|
||||
setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] }));
|
||||
@@ -92,22 +95,24 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
|
||||
|
||||
// Fetch sublines from API
|
||||
const sublinesUrl = `/api/import/sublines/${lineId}`;
|
||||
console.log(`Fetching from URL: ${sublinesUrl}`);
|
||||
const response = await axios.get(sublinesUrl);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to fetch sublines: ${response.status}`);
|
||||
}
|
||||
|
||||
const sublines = response.data;
|
||||
console.log(`Received ${sublines.length} sublines for line ${lineId}`);
|
||||
|
||||
// Format the data properly for dropdown display
|
||||
const formattedSublines = sublines.map((subline: any) => ({
|
||||
label: subline.name || subline.label || String(subline.value || subline.id),
|
||||
value: String(subline.value || subline.id)
|
||||
}));
|
||||
|
||||
// Store in line cache
|
||||
setLineSublineCache(prev => ({ ...prev, [lineId]: sublines }));
|
||||
setLineSublineCache(prev => ({ ...prev, [lineId]: formattedSublines }));
|
||||
|
||||
// Store for this specific row
|
||||
setRowSublines(prev => ({ ...prev, [rowIndex]: sublines }));
|
||||
setRowSublines(prev => ({ ...prev, [rowIndex]: formattedSublines }));
|
||||
|
||||
return sublines;
|
||||
return formattedSublines;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching sublines for line ${lineId}:`, error);
|
||||
|
||||
@@ -129,6 +134,25 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
|
||||
// Skip if there's no data
|
||||
if (!data.length) return;
|
||||
|
||||
// First check if we need to do anything at all
|
||||
let needsFetching = false;
|
||||
|
||||
// Quick check for any rows that would need fetching
|
||||
for (const row of data) {
|
||||
const rowId = row.__index;
|
||||
if (!rowId) continue;
|
||||
|
||||
if ((row.company && !rowProductLines[rowId]) || (row.line && !rowSublines[rowId])) {
|
||||
needsFetching = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing needs fetching, exit early
|
||||
if (!needsFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Starting to fetch product lines and sublines");
|
||||
|
||||
// Group rows by company and line to minimize API calls
|
||||
@@ -160,6 +184,11 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
|
||||
|
||||
console.log(`Need to fetch product lines for ${companiesNeeded.size} companies and sublines for ${linesNeeded.size} lines`);
|
||||
|
||||
// If nothing to fetch, exit early to prevent unnecessary processing
|
||||
if (companiesNeeded.size === 0 && linesNeeded.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create arrays to hold all fetch promises
|
||||
const fetchPromises: Promise<void>[] = [];
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import config from '@/config'
|
||||
|
||||
|
||||
interface ValidationState {
|
||||
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
|
||||
itemNumbers: Map<number, string>; // Using rowIndex as key
|
||||
validatingRows: Set<number>; // Rows currently being validated
|
||||
activeValidations: Set<string>; // Active validations
|
||||
}
|
||||
|
||||
export const useUpcValidation = (
|
||||
@@ -16,19 +16,27 @@ export const useUpcValidation = (
|
||||
const validationStateRef = useRef<ValidationState>({
|
||||
validatingCells: new Set(),
|
||||
itemNumbers: new Map(),
|
||||
validatingRows: new Set()
|
||||
validatingRows: new Set(),
|
||||
activeValidations: new Set()
|
||||
});
|
||||
|
||||
// Use state only for forcing re-renders of specific cells
|
||||
const [validatingCellKeys, setValidatingCellKeys] = useState<Set<string>>(new Set());
|
||||
const [itemNumberUpdates, setItemNumberUpdates] = useState<Map<number, string>>(new Map());
|
||||
const [, setValidatingCellKeys] = useState<Set<string>>(new Set());
|
||||
const [, setItemNumberUpdates] = useState<Map<number, string>>(new Map());
|
||||
const [validatingRows, setValidatingRows] = useState<Set<number>>(new Set());
|
||||
const [isValidatingUpc, setIsValidatingUpc] = useState(false);
|
||||
const [, setIsValidatingUpc] = useState(false);
|
||||
|
||||
// Cache for UPC validation results
|
||||
const processedUpcMapRef = useRef(new Map<string, string>());
|
||||
const initialUpcValidationDoneRef = useRef(false);
|
||||
|
||||
// For batch validation
|
||||
const validationQueueRef = useRef<Array<{rowIndex: number, supplierId: string, upcValue: string}>>([]);
|
||||
const isProcessingBatchRef = useRef(false);
|
||||
|
||||
// For validation results
|
||||
const [upcValidationResults] = useState<Map<number, { itemNumber: string }>>(new Map());
|
||||
|
||||
// Helper to create cell key
|
||||
const getCellKey = (rowIndex: number, fieldKey: string) => `${rowIndex}-${fieldKey}`;
|
||||
|
||||
@@ -48,6 +56,7 @@ export const useUpcValidation = (
|
||||
|
||||
// Update item number
|
||||
const updateItemNumber = useCallback((rowIndex: number, itemNumber: string) => {
|
||||
console.log(`Setting item number for row ${rowIndex} to ${itemNumber}`);
|
||||
validationStateRef.current.itemNumbers.set(rowIndex, itemNumber);
|
||||
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
||||
}, []);
|
||||
@@ -56,6 +65,7 @@ export const useUpcValidation = (
|
||||
const startValidatingRow = useCallback((rowIndex: number) => {
|
||||
validationStateRef.current.validatingRows.add(rowIndex);
|
||||
setValidatingRows(new Set(validationStateRef.current.validatingRows));
|
||||
setIsValidatingUpc(true);
|
||||
}, []);
|
||||
|
||||
// Mark a row as no longer being validated
|
||||
@@ -84,113 +94,269 @@ export const useUpcValidation = (
|
||||
return validationStateRef.current.itemNumbers.get(rowIndex);
|
||||
}, []);
|
||||
|
||||
// Apply all pending updates to the data state
|
||||
const applyItemNumbersToData = useCallback(() => {
|
||||
if (validationStateRef.current.itemNumbers.size === 0) return;
|
||||
// Fetch product by UPC from API
|
||||
const fetchProductByUpc = useCallback(async (supplierId: string, upcValue: string) => {
|
||||
try {
|
||||
console.log(`Fetching product for UPC ${upcValue} with supplier ${supplierId}`);
|
||||
const response = await fetch(`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`);
|
||||
|
||||
setData((prevData: any[]) => {
|
||||
// Handle error responses
|
||||
if (response.status === 409) {
|
||||
console.log(`UPC ${upcValue} already exists`);
|
||||
return { error: true, message: 'UPC already exists' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`API error: ${response.status}`);
|
||||
return { error: true, message: `API error (${response.status})` };
|
||||
}
|
||||
|
||||
// Process successful response
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
return { error: true, message: data.message || 'Unknown error' };
|
||||
}
|
||||
|
||||
return {
|
||||
error: false,
|
||||
data: {
|
||||
itemNumber: data.itemNumber || '',
|
||||
...data
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Network error:', error);
|
||||
return { error: true, message: 'Network error' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validate a UPC for a row - returns a promise that resolves when complete
|
||||
const validateUpc = useCallback(async (rowIndex: number, supplierId: string, upcValue: string) => {
|
||||
// Clear any previous validation keys for this row to avoid cancellations
|
||||
const previousKeys = Array.from(validationStateRef.current.activeValidations).filter(key =>
|
||||
key.startsWith(`${rowIndex}-`)
|
||||
);
|
||||
previousKeys.forEach(key => validationStateRef.current.activeValidations.delete(key));
|
||||
|
||||
// Start validation - track this with the ref to avoid race conditions
|
||||
startValidatingRow(rowIndex);
|
||||
startValidatingCell(rowIndex, 'item_number');
|
||||
|
||||
console.log(`Validating UPC: rowIndex=${rowIndex}, supplierId=${supplierId}, upc=${upcValue}`);
|
||||
|
||||
try {
|
||||
// Create a unique key for this validation to track it
|
||||
const validationKey = `${rowIndex}-${supplierId}-${upcValue}`;
|
||||
validationStateRef.current.activeValidations.add(validationKey);
|
||||
|
||||
// IMPORTANT: First update the data with the new UPC value to prevent UI flicker
|
||||
// This ensures the UPC field keeps showing the new value while validation runs
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
if (newData[rowIndex]) {
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
upc: upcValue
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Fetch the product by UPC
|
||||
const product = await fetchProductByUpc(supplierId, upcValue);
|
||||
|
||||
// Check if this validation is still relevant (hasn't been superseded by another)
|
||||
if (!validationStateRef.current.activeValidations.has(validationKey)) {
|
||||
console.log(`Validation ${validationKey} was cancelled`);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
// Extract the item number from the API response - check for !error since API returns { error: boolean, data: any }
|
||||
if (product && !product.error && product.data?.itemNumber) {
|
||||
// Store this validation result
|
||||
updateItemNumber(rowIndex, product.data.itemNumber);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
itemNumber: product.data.itemNumber
|
||||
};
|
||||
} else {
|
||||
// No item number found but validation was still attempted
|
||||
console.log(`No item number found for UPC ${upcValue}`);
|
||||
|
||||
// Clear any existing item number to show validation was attempted and failed
|
||||
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
|
||||
validationStateRef.current.itemNumbers.delete(rowIndex);
|
||||
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error validating UPC:', error);
|
||||
return { success: false };
|
||||
} finally {
|
||||
// End validation
|
||||
stopValidatingRow(rowIndex);
|
||||
stopValidatingCell(rowIndex, 'item_number');
|
||||
}
|
||||
}, [fetchProductByUpc, updateItemNumber, startValidatingCell, stopValidatingCell, startValidatingRow, stopValidatingRow, setData]);
|
||||
|
||||
// Apply item numbers to data
|
||||
const applyItemNumbersToData = useCallback((onApplied?: (updatedRowIds: number[]) => void) => {
|
||||
// Create a copy of the current item numbers map to avoid race conditions
|
||||
const currentItemNumbers = new Map(validationStateRef.current.itemNumbers);
|
||||
|
||||
// Only apply if we have any item numbers
|
||||
if (currentItemNumbers.size === 0) return;
|
||||
|
||||
// Track updated row indices to pass to callback
|
||||
const updatedRowIndices: number[] = [];
|
||||
|
||||
// Log for debugging
|
||||
console.log(`Applying ${currentItemNumbers.size} item numbers to data`);
|
||||
|
||||
setData(prevData => {
|
||||
// Create a new copy of the data
|
||||
const newData = [...prevData];
|
||||
|
||||
// Apply all item numbers without changing other data
|
||||
Array.from(validationStateRef.current.itemNumbers.entries()).forEach(([index, itemNumber]) => {
|
||||
if (index >= 0 && index < newData.length) {
|
||||
// Only update the item_number field and leave everything else unchanged
|
||||
newData[index] = {
|
||||
...newData[index],
|
||||
// Update each row with its item number without affecting other fields
|
||||
currentItemNumbers.forEach((itemNumber, rowIndex) => {
|
||||
if (rowIndex < newData.length) {
|
||||
console.log(`Setting item_number for row ${rowIndex} to ${itemNumber}`);
|
||||
|
||||
// Only update the item_number field, leaving other fields unchanged
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
item_number: itemNumber
|
||||
};
|
||||
|
||||
// Track which rows were updated
|
||||
updatedRowIndices.push(rowIndex);
|
||||
}
|
||||
});
|
||||
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Clear the item numbers state after applying
|
||||
validationStateRef.current.itemNumbers.clear();
|
||||
setItemNumberUpdates(new Map());
|
||||
// Call the callback if provided, after state updates are processed
|
||||
if (onApplied && updatedRowIndices.length > 0) {
|
||||
// Use setTimeout to ensure this happens after the state update
|
||||
setTimeout(() => {
|
||||
onApplied(updatedRowIndices);
|
||||
}, 100); // Use 100ms to ensure the data update is fully processed
|
||||
}
|
||||
}, [setData]);
|
||||
|
||||
// Validate a UPC value
|
||||
const validateUpc = useCallback(async (
|
||||
rowIndex: number,
|
||||
supplierId: string,
|
||||
upcValue: string
|
||||
): Promise<{success: boolean, itemNumber?: string}> => {
|
||||
// Process validation queue in batches - faster processing with smaller batches
|
||||
const processBatchValidation = useCallback(async () => {
|
||||
if (isProcessingBatchRef.current) return;
|
||||
if (validationQueueRef.current.length === 0) return;
|
||||
|
||||
console.log(`Processing validation batch with ${validationQueueRef.current.length} items`);
|
||||
isProcessingBatchRef.current = true;
|
||||
|
||||
// Process in smaller batches for better UI responsiveness
|
||||
const BATCH_SIZE = 5;
|
||||
const queue = [...validationQueueRef.current];
|
||||
validationQueueRef.current = [];
|
||||
|
||||
// Track if any updates were made
|
||||
let updatesApplied = false;
|
||||
|
||||
// Track updated row indices
|
||||
const updatedRows: number[] = [];
|
||||
|
||||
try {
|
||||
// Skip if either value is missing
|
||||
if (!supplierId || !upcValue) {
|
||||
return { success: false };
|
||||
}
|
||||
// Process in small batches
|
||||
for (let i = 0; i < queue.length; i += BATCH_SIZE) {
|
||||
const batch = queue.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Add logging to help debug
|
||||
console.log(`Validating UPC for row ${rowIndex}. Supplier ID: ${supplierId}, UPC: ${upcValue}`);
|
||||
|
||||
// Start validating both UPC and item_number cells
|
||||
startValidatingCell(rowIndex, 'upc');
|
||||
startValidatingCell(rowIndex, 'item_number');
|
||||
|
||||
// Also mark the row as being validated
|
||||
startValidatingRow(rowIndex);
|
||||
|
||||
// Check if we've already validated this UPC/supplier combination
|
||||
// Process batch in parallel
|
||||
const results = await Promise.all(batch.map(async ({ rowIndex, supplierId, upcValue }) => {
|
||||
try {
|
||||
// Skip if already validated
|
||||
const cacheKey = `${supplierId}-${upcValue}`;
|
||||
if (processedUpcMapRef.current.has(cacheKey)) {
|
||||
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
|
||||
|
||||
if (cachedItemNumber) {
|
||||
// Use cached item number
|
||||
console.log(`Using cached item number for row ${rowIndex}: ${cachedItemNumber}`);
|
||||
updateItemNumber(rowIndex, cachedItemNumber);
|
||||
return { success: true, itemNumber: cachedItemNumber };
|
||||
updatesApplied = true;
|
||||
updatedRows.push(rowIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
}
|
||||
// Fetch from API
|
||||
const result = await fetchProductByUpc(supplierId, upcValue);
|
||||
|
||||
// Make API call to validate UPC
|
||||
const response = await fetch(`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`);
|
||||
if (!result.error && result.data?.itemNumber) {
|
||||
const itemNumber = result.data.itemNumber;
|
||||
|
||||
// Process the response
|
||||
if (response.status === 409) {
|
||||
// UPC already exists - show validation error
|
||||
return { success: false };
|
||||
} else if (response.ok) {
|
||||
// Successful validation - update item number
|
||||
const responseData = await response.json();
|
||||
|
||||
if (responseData.success && responseData.itemNumber) {
|
||||
// Store in cache
|
||||
processedUpcMapRef.current.set(cacheKey, responseData.itemNumber);
|
||||
processedUpcMapRef.current.set(cacheKey, itemNumber);
|
||||
|
||||
// Update the item numbers state
|
||||
updateItemNumber(rowIndex, responseData.itemNumber);
|
||||
// Update item number
|
||||
updateItemNumber(rowIndex, itemNumber);
|
||||
updatesApplied = true;
|
||||
updatedRows.push(rowIndex);
|
||||
|
||||
return { success: true, itemNumber: responseData.itemNumber };
|
||||
console.log(`Set item number for row ${rowIndex} to ${itemNumber}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(`Error validating UPC for row ${rowIndex}:`, error);
|
||||
return { success: false };
|
||||
console.error(`Error processing row ${rowIndex}:`, error);
|
||||
return false;
|
||||
} finally {
|
||||
// Clear validation state
|
||||
stopValidatingCell(rowIndex, 'upc');
|
||||
stopValidatingCell(rowIndex, 'item_number');
|
||||
stopValidatingRow(rowIndex);
|
||||
}
|
||||
}, [startValidatingCell, stopValidatingCell, updateItemNumber, startValidatingRow, stopValidatingRow]);
|
||||
}));
|
||||
|
||||
// If any updates were applied in this batch, update the data
|
||||
if (results.some(Boolean) && updatesApplied) {
|
||||
applyItemNumbersToData(updatedRowIds => {
|
||||
console.log(`Processed batch UPC validation for rows: ${updatedRowIds.join(', ')}`);
|
||||
});
|
||||
updatesApplied = false;
|
||||
updatedRows.length = 0; // Clear the array
|
||||
}
|
||||
|
||||
// Small delay between batches to allow UI to update
|
||||
if (i + BATCH_SIZE < queue.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in batch processing:', error);
|
||||
} finally {
|
||||
isProcessingBatchRef.current = false;
|
||||
|
||||
// Process any new items
|
||||
if (validationQueueRef.current.length > 0) {
|
||||
setTimeout(processBatchValidation, 0);
|
||||
}
|
||||
}
|
||||
}, [fetchProductByUpc, updateItemNumber, stopValidatingRow, applyItemNumbersToData]);
|
||||
|
||||
// For immediate processing
|
||||
|
||||
// Batch validate all UPCs in the data
|
||||
const validateAllUPCs = useCallback(async () => {
|
||||
// Skip if we've already done the initial validation
|
||||
if (initialUpcValidationDoneRef.current) {
|
||||
console.log('Initial UPC validation already done, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark that we've done the initial validation
|
||||
// Mark that we've started the initial validation
|
||||
initialUpcValidationDoneRef.current = true;
|
||||
|
||||
console.log('Starting UPC validation...');
|
||||
console.log('Starting initial UPC validation...');
|
||||
|
||||
// Set validation state
|
||||
setIsValidatingUpc(true);
|
||||
@@ -206,7 +372,7 @@ export const useUpcValidation = (
|
||||
});
|
||||
|
||||
const totalRows = rowsToValidate.length;
|
||||
console.log(`Found ${totalRows} rows with both supplier and UPC`);
|
||||
console.log(`Found ${totalRows} rows with both supplier and UPC for initial validation`);
|
||||
|
||||
if (totalRows === 0) {
|
||||
setIsValidatingUpc(false);
|
||||
@@ -219,37 +385,102 @@ export const useUpcValidation = (
|
||||
setValidatingRows(newValidatingRows);
|
||||
|
||||
try {
|
||||
// Process all rows in parallel
|
||||
// Process rows in batches for better UX
|
||||
const BATCH_SIZE = 100;
|
||||
const batches = [];
|
||||
|
||||
// Split rows into batches
|
||||
for (let i = 0; i < rowsToValidate.length; i += BATCH_SIZE) {
|
||||
batches.push(rowsToValidate.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
|
||||
console.log(`Processing ${batches.length} batches for ${totalRows} rows`);
|
||||
|
||||
// Process each batch sequentially
|
||||
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
|
||||
const batch = batches[batchIndex];
|
||||
console.log(`Processing batch ${batchIndex + 1}/${batches.length} with ${batch.length} rows`);
|
||||
|
||||
// Track updated rows in this batch
|
||||
const batchUpdatedRows: number[] = [];
|
||||
|
||||
// Process all rows in current batch in parallel
|
||||
await Promise.all(
|
||||
rowsToValidate.map(async ({ row, index }) => {
|
||||
batch.map(async ({ row, index }) => {
|
||||
try {
|
||||
const rowAny = row as Record<string, any>;
|
||||
const supplierId = rowAny.supplier.toString();
|
||||
const upcValue = (rowAny.upc || rowAny.barcode).toString();
|
||||
|
||||
// Validate the UPC
|
||||
await validateUpc(index, supplierId, upcValue);
|
||||
console.log(`Validating UPC in initial batch: row=${index}, supplier=${supplierId}, upc=${upcValue}`);
|
||||
|
||||
// Remove this row from the validating set (handled in validateUpc)
|
||||
// Mark the item_number cell as validating
|
||||
startValidatingCell(index, 'item_number');
|
||||
|
||||
// Validate the UPC directly (don't use validateUpc to avoid duplicate UI updates)
|
||||
const cacheKey = `${supplierId}-${upcValue}`;
|
||||
|
||||
// Check cache first
|
||||
if (processedUpcMapRef.current.has(cacheKey)) {
|
||||
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
|
||||
if (cachedItemNumber) {
|
||||
console.log(`Using cached item number for row ${index}: ${cachedItemNumber}`);
|
||||
updateItemNumber(index, cachedItemNumber);
|
||||
batchUpdatedRows.push(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Make API call
|
||||
const result = await fetchProductByUpc(supplierId, upcValue);
|
||||
|
||||
if (!result.error && result.data?.itemNumber) {
|
||||
const itemNumber = result.data.itemNumber;
|
||||
console.log(`Got item number from API for row ${index}: ${itemNumber}`);
|
||||
|
||||
// Cache the result
|
||||
processedUpcMapRef.current.set(cacheKey, itemNumber);
|
||||
|
||||
// Update item number
|
||||
updateItemNumber(index, itemNumber);
|
||||
batchUpdatedRows.push(index);
|
||||
} else {
|
||||
console.warn(`No item number found for row ${index} UPC ${upcValue}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing row ${index}:`, error);
|
||||
console.error(`Error validating row ${index}:`, error);
|
||||
} finally {
|
||||
// Clear validation state
|
||||
stopValidatingCell(index, 'item_number');
|
||||
stopValidatingRow(index);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Apply updates for this batch
|
||||
if (validationStateRef.current.itemNumbers.size > 0) {
|
||||
console.log(`Applying item numbers after batch ${batchIndex + 1}`);
|
||||
applyItemNumbersToData(updatedRowIds => {
|
||||
console.log(`Processed initial UPC validation batch ${batchIndex + 1} for rows: ${updatedRowIds.join(', ')}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Small delay between batches to update UI
|
||||
if (batchIndex < batches.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in batch validation:', error);
|
||||
} finally {
|
||||
// Reset validation state
|
||||
setIsValidatingUpc(false);
|
||||
// Make sure all validation states are cleared
|
||||
validationStateRef.current.validatingRows.clear();
|
||||
setValidatingRows(new Set());
|
||||
console.log('Completed UPC validation');
|
||||
setIsValidatingUpc(false);
|
||||
|
||||
// Apply item numbers to data
|
||||
applyItemNumbersToData();
|
||||
console.log('Completed initial UPC validation');
|
||||
}
|
||||
}, [data, validateUpc, stopValidatingRow, applyItemNumbersToData]);
|
||||
}, [data, fetchProductByUpc, updateItemNumber, startValidatingCell, stopValidatingCell, stopValidatingRow, applyItemNumbersToData]);
|
||||
|
||||
// Run initial UPC validation when data changes
|
||||
useEffect(() => {
|
||||
@@ -260,42 +491,27 @@ export const useUpcValidation = (
|
||||
validateAllUPCs();
|
||||
}, [data, validateAllUPCs]);
|
||||
|
||||
// Apply item numbers when they change
|
||||
useEffect(() => {
|
||||
// Apply item numbers if there are any
|
||||
if (validationStateRef.current.itemNumbers.size > 0) {
|
||||
applyItemNumbersToData();
|
||||
}
|
||||
}, [itemNumberUpdates, applyItemNumbersToData]);
|
||||
|
||||
// Reset validation state when hook is unmounted
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
initialUpcValidationDoneRef.current = false;
|
||||
processedUpcMapRef.current.clear();
|
||||
validationStateRef.current.validatingCells.clear();
|
||||
validationStateRef.current.itemNumbers.clear();
|
||||
validationStateRef.current.validatingRows.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
// Validation methods
|
||||
validateUpc,
|
||||
validateAllUPCs,
|
||||
|
||||
// Cell state
|
||||
isValidatingCell,
|
||||
isRowValidatingUpc,
|
||||
isValidatingUpc,
|
||||
|
||||
// Row state
|
||||
validatingRows: validatingRows, // Expose as a Set to components
|
||||
|
||||
// Item number management
|
||||
getItemNumber,
|
||||
applyItemNumbersToData,
|
||||
itemNumbers: itemNumberUpdates,
|
||||
validatingCells: validatingCellKeys,
|
||||
validatingRows,
|
||||
resetInitialValidation: () => {
|
||||
initialUpcValidationDoneRef.current = false;
|
||||
},
|
||||
// Export the ref for direct access
|
||||
get initialValidationDone() {
|
||||
return initialUpcValidationDoneRef.current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Results
|
||||
upcValidationResults,
|
||||
|
||||
// Initialization state
|
||||
initialValidationDone: initialUpcValidationDoneRef.current
|
||||
};
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { RowData } from './useValidationState'
|
||||
|
||||
// Define InfoWithSource to match the expected structure
|
||||
// Make sure source is required (not optional)
|
||||
interface InfoWithSource {
|
||||
export interface InfoWithSource {
|
||||
message: string;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
source: ErrorSources;
|
||||
@@ -21,6 +21,40 @@ const isEmpty = (value: any): boolean =>
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
|
||||
|
||||
// Cache validation results to avoid running expensive validations repeatedly
|
||||
const validationResultCache = new Map<string, ValidationError[]>();
|
||||
|
||||
// Add debounce to prevent rapid successive validations
|
||||
let validateDataTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Add a function to clear cache for a specific field value
|
||||
export const clearValidationCacheForField = (fieldKey: string, value: any) => {
|
||||
// Create a pattern to match cache keys for this field
|
||||
const pattern = new RegExp(`^${fieldKey}-`);
|
||||
|
||||
// Find and clear matching cache entries
|
||||
validationResultCache.forEach((_, key) => {
|
||||
if (pattern.test(key)) {
|
||||
validationResultCache.delete(key);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Add a special function to clear all uniqueness validation caches
|
||||
export const clearAllUniquenessCaches = () => {
|
||||
// Clear cache for common unique fields
|
||||
['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'].forEach(fieldKey => {
|
||||
clearValidationCacheForField(fieldKey, null);
|
||||
});
|
||||
|
||||
// Also clear any cache entries that might involve uniqueness validation
|
||||
validationResultCache.forEach((_, key) => {
|
||||
if (key.includes('unique')) {
|
||||
validationResultCache.delete(key);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useValidation = <T extends string>(
|
||||
fields: Fields<T>,
|
||||
rowHook?: RowHook<T>,
|
||||
@@ -35,6 +69,14 @@ export const useValidation = <T extends string>(
|
||||
|
||||
if (!field.validations) return errors
|
||||
|
||||
// Create a cache key using field key, value, and validation rules
|
||||
const cacheKey = `${field.key}-${String(value)}-${JSON.stringify(field.validations)}`;
|
||||
|
||||
// Check cache first to avoid redundant validation
|
||||
if (validationResultCache.has(cacheKey)) {
|
||||
return validationResultCache.get(cacheKey) || [];
|
||||
}
|
||||
|
||||
field.validations.forEach(validation => {
|
||||
switch (validation.rule) {
|
||||
case 'required':
|
||||
@@ -71,6 +113,9 @@ export const useValidation = <T extends string>(
|
||||
}
|
||||
})
|
||||
|
||||
// Store results in cache to speed up future validations
|
||||
validationResultCache.set(cacheKey, errors);
|
||||
|
||||
return errors
|
||||
}, [])
|
||||
|
||||
@@ -223,83 +268,256 @@ export const useValidation = <T extends string>(
|
||||
return uniqueErrors;
|
||||
}, [fields]);
|
||||
|
||||
// Additional function to explicitly validate uniqueness for specified fields
|
||||
const validateUniqueField = useCallback((data: RowData<T>[], fieldKey: string) => {
|
||||
// Field keys that need special handling for uniqueness
|
||||
const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
||||
|
||||
// If the field doesn't need uniqueness validation, return empty errors
|
||||
if (!uniquenessFields.includes(fieldKey)) {
|
||||
const field = fields.find(f => String(f.key) === fieldKey);
|
||||
if (!field || !field.validations?.some(v => v.rule === 'unique')) {
|
||||
return new Map<number, Record<string, InfoWithSource>>();
|
||||
}
|
||||
}
|
||||
|
||||
// Create map to track errors
|
||||
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
|
||||
|
||||
// Find the field definition
|
||||
const field = fields.find(f => String(f.key) === fieldKey);
|
||||
if (!field) return uniqueErrors;
|
||||
|
||||
// Get validation properties
|
||||
const validation = field.validations?.find(v => v.rule === 'unique');
|
||||
const allowEmpty = validation?.allowEmpty ?? false;
|
||||
const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
|
||||
const level = validation?.level || 'error';
|
||||
|
||||
// Track values for uniqueness check
|
||||
const valueMap = new Map<string, number[]>();
|
||||
|
||||
// Build value map
|
||||
data.forEach((row, rowIndex) => {
|
||||
const value = String(row[fieldKey as keyof typeof row] || '');
|
||||
|
||||
// Skip empty values if allowed
|
||||
if (allowEmpty && isEmpty(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!valueMap.has(value)) {
|
||||
valueMap.set(value, [rowIndex]);
|
||||
} else {
|
||||
valueMap.get(value)?.push(rowIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Add errors for duplicate values
|
||||
valueMap.forEach((rowIndexes, value) => {
|
||||
if (rowIndexes.length > 1) {
|
||||
// Skip empty values
|
||||
if (!value || value.trim() === '') return;
|
||||
|
||||
// Add error to all duplicate rows
|
||||
rowIndexes.forEach(rowIndex => {
|
||||
// Create errors object if needed
|
||||
if (!uniqueErrors.has(rowIndex)) {
|
||||
uniqueErrors.set(rowIndex, {});
|
||||
}
|
||||
|
||||
// Add error for this field
|
||||
uniqueErrors.get(rowIndex)![fieldKey] = {
|
||||
message: errorMessage,
|
||||
level: level as 'info' | 'warning' | 'error',
|
||||
source: ErrorSources.Table,
|
||||
type: ErrorType.Unique
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return uniqueErrors;
|
||||
}, [fields]);
|
||||
|
||||
// Run complete validation
|
||||
const validateData = useCallback(async (data: RowData<T>[]) => {
|
||||
// Step 1: Run field and row validation for each row
|
||||
|
||||
// Step 2: Run unique validations
|
||||
const uniqueValidations = validateUnique(data);
|
||||
|
||||
// Step 3: Run table hook
|
||||
|
||||
// Create a map to store all validation errors
|
||||
const validateData = useCallback(async (data: RowData<T>[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => {
|
||||
const validationErrors = new Map<number, Record<string, InfoWithSource>>();
|
||||
|
||||
// Merge all validation results
|
||||
data.forEach((row, index) => {
|
||||
// Collect errors from all validation sources
|
||||
const rowErrors: Record<string, InfoWithSource> = {};
|
||||
// If we're updating a specific field, only validate that field for that row
|
||||
if (fieldToUpdate) {
|
||||
const { rowIndex, fieldKey } = fieldToUpdate;
|
||||
|
||||
// Add field-level errors (we need to extract these from the validation process)
|
||||
fields.forEach(field => {
|
||||
const value = row[String(field.key) as keyof typeof row];
|
||||
// Special handling for fields that often update item_number
|
||||
const triggersItemNumberValidation = fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier';
|
||||
|
||||
// If updating a uniqueness field or field that affects item_number, clear ALL related validation caches
|
||||
const isUniqueField = fieldKey === 'upc' || fieldKey === 'item_number' ||
|
||||
fieldKey === 'supplier_no' || fieldKey === 'notions_no' ||
|
||||
fieldKey === 'name' || triggersItemNumberValidation;
|
||||
|
||||
// Force cache clearing for uniqueness-validated fields to ensure fresh validation
|
||||
if (isUniqueField) {
|
||||
console.log(`Clearing validation cache for uniqueness field: ${fieldKey}`);
|
||||
clearValidationCacheForField(fieldKey, null);
|
||||
|
||||
// If a field that might affect item_number, also clear item_number cache
|
||||
if (triggersItemNumberValidation) {
|
||||
console.log('Also clearing item_number validation cache');
|
||||
clearValidationCacheForField('item_number', null);
|
||||
}
|
||||
}
|
||||
|
||||
if (rowIndex >= 0 && rowIndex < data.length) {
|
||||
const row = data[rowIndex];
|
||||
|
||||
// Find the field definition
|
||||
const field = fields.find(f => String(f.key) === fieldKey);
|
||||
|
||||
if (field) {
|
||||
// Validate just this field for this row
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
const errors = validateField(value, field as Field<T>);
|
||||
|
||||
if (errors.length > 0) {
|
||||
rowErrors[String(field.key)] = {
|
||||
// Store the validation error
|
||||
validationErrors.set(rowIndex, {
|
||||
[fieldKey]: {
|
||||
message: errors[0].message,
|
||||
level: errors[0].level,
|
||||
level: errors[0].level as 'info' | 'warning' | 'error',
|
||||
source: ErrorSources.Row,
|
||||
type: errors[0].type
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the field requires uniqueness validation or if it's item_number after UPC/Supplier change
|
||||
const needsUniquenessCheck = isUniqueField ||
|
||||
field.validations?.some(v => v.rule === 'unique');
|
||||
|
||||
if (needsUniquenessCheck) {
|
||||
console.log(`Running immediate uniqueness validation for field ${fieldKey}`);
|
||||
|
||||
// For item_number updated via UPC validation, or direct UPC update, check both fields
|
||||
if (fieldKey === 'item_number' || fieldKey === 'upc' || fieldKey === 'barcode') {
|
||||
// Validate both item_number and UPC/barcode fields for uniqueness
|
||||
const itemNumberUniqueErrors = validateUniqueField(data, 'item_number');
|
||||
const upcUniqueErrors = validateUniqueField(data, fieldKey === 'item_number' ? 'upc' : fieldKey);
|
||||
|
||||
// Combine the errors
|
||||
itemNumberUniqueErrors.forEach((errors, rowIdx) => {
|
||||
if (!validationErrors.has(rowIdx)) {
|
||||
validationErrors.set(rowIdx, {});
|
||||
}
|
||||
Object.assign(validationErrors.get(rowIdx)!, errors);
|
||||
});
|
||||
|
||||
upcUniqueErrors.forEach((errors, rowIdx) => {
|
||||
if (!validationErrors.has(rowIdx)) {
|
||||
validationErrors.set(rowIdx, {});
|
||||
}
|
||||
Object.assign(validationErrors.get(rowIdx)!, errors);
|
||||
});
|
||||
} else {
|
||||
// Normal uniqueness validation for other fields
|
||||
const uniqueErrors = validateUniqueField(data, fieldKey);
|
||||
|
||||
// Add unique errors to validation errors
|
||||
uniqueErrors.forEach((errors, rowIdx) => {
|
||||
if (!validationErrors.has(rowIdx)) {
|
||||
validationErrors.set(rowIdx, {});
|
||||
}
|
||||
Object.assign(validationErrors.get(rowIdx)!, errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Full validation - all fields for all rows
|
||||
console.log('Running full validation for all fields and rows');
|
||||
|
||||
// Clear validation cache for full validation
|
||||
validationResultCache.clear();
|
||||
|
||||
// Process each row for field-level validations
|
||||
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
|
||||
const row = data[rowIndex];
|
||||
let rowErrors: Record<string, InfoWithSource> = {};
|
||||
|
||||
// Validate all fields for this row
|
||||
fields.forEach(field => {
|
||||
const fieldKey = String(field.key);
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
const errors = validateField(value, field as Field<T>);
|
||||
|
||||
if (errors.length > 0) {
|
||||
rowErrors[fieldKey] = {
|
||||
message: errors[0].message,
|
||||
level: errors[0].level as 'info' | 'warning' | 'error',
|
||||
source: ErrorSources.Row,
|
||||
type: errors[0].type
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Add unique validation errors
|
||||
if (uniqueValidations.has(index)) {
|
||||
Object.entries(uniqueValidations.get(index) || {}).forEach(([key, error]) => {
|
||||
rowErrors[key] = error;
|
||||
});
|
||||
// Add row to validationErrors if it has any errors
|
||||
if (Object.keys(rowErrors).length > 0) {
|
||||
validationErrors.set(rowIndex, rowErrors);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out "required" errors for fields that have values
|
||||
const filteredErrors: Record<string, InfoWithSource> = {};
|
||||
// Get fields requiring uniqueness validation
|
||||
const uniqueFields = fields.filter(field =>
|
||||
field.validations?.some(v => v.rule === 'unique')
|
||||
);
|
||||
|
||||
Object.entries(rowErrors).forEach(([key, error]) => {
|
||||
const fieldValue = row[key as keyof typeof row];
|
||||
// Also add standard unique fields that might not be explicitly marked as unique
|
||||
const standardUniqueFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
||||
|
||||
// If the field has a value and the error is of type Required, skip it
|
||||
if (!isEmpty(fieldValue) &&
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'type' in error &&
|
||||
error.type === ErrorType.Required) {
|
||||
return;
|
||||
// Combine all fields that need uniqueness validation
|
||||
const allUniqueFieldKeys = new Set([
|
||||
...uniqueFields.map(field => String(field.key)),
|
||||
...standardUniqueFields
|
||||
]);
|
||||
|
||||
// Log uniqueness validation fields
|
||||
console.log('Validating unique fields:', Array.from(allUniqueFieldKeys));
|
||||
|
||||
// Run uniqueness validation for each unique field
|
||||
allUniqueFieldKeys.forEach(fieldKey => {
|
||||
// Check if this field exists in the data
|
||||
const hasField = data.some(row => fieldKey in row);
|
||||
if (!hasField) return;
|
||||
|
||||
const uniqueErrors = validateUniqueField(data, fieldKey);
|
||||
|
||||
// Add unique errors to validation errors
|
||||
uniqueErrors.forEach((errors, rowIdx) => {
|
||||
if (!validationErrors.has(rowIdx)) {
|
||||
validationErrors.set(rowIdx, {});
|
||||
}
|
||||
|
||||
filteredErrors[key] = error;
|
||||
Object.assign(validationErrors.get(rowIdx)!, errors);
|
||||
});
|
||||
});
|
||||
|
||||
// Only add to the map if there are errors
|
||||
if (Object.keys(filteredErrors).length > 0) {
|
||||
validationErrors.set(index, filteredErrors);
|
||||
console.log('Uniqueness validation complete');
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map((row) => {
|
||||
// Return the original data without __errors
|
||||
return { ...row };
|
||||
}),
|
||||
data,
|
||||
validationErrors
|
||||
};
|
||||
}, [validateRow, validateUnique, validateTable, fields, validateField]);
|
||||
}, [fields, validateField, validateUniqueField]);
|
||||
|
||||
return {
|
||||
validateData,
|
||||
validateField,
|
||||
validateRow,
|
||||
validateTable,
|
||||
validateUnique
|
||||
validateUnique,
|
||||
validateUniqueField,
|
||||
clearValidationCacheForField,
|
||||
clearAllUniquenessCaches
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user