diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx index 8252bf3..2250366 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx @@ -1188,7 +1188,6 @@ export const MatchColumnsStep = React.memo(({ })) as unknown as Fields const unmatched = findUnmatchedRequiredFields(typedFields, columns); - console.log("Unmatched required fields:", unmatched); return unmatched; }, [fields, columns]) @@ -1200,7 +1199,6 @@ export const MatchColumnsStep = React.memo(({ // Type assertion to handle the DeepReadonly vs string type mismatch return !unmatchedRequiredFields.includes(key as any); }); - console.log("Matched required fields:", matched); return matched; }, [requiredFields, unmatchedRequiredFields]); diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx index cddc531..90f2149 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx @@ -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 ( 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 - .filter(e => e.level === 'error' || e.level === 'warning') - .map(getErrorMessage) - .join('\n'); - } + // 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(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]); + + // 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' 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 ( setIsTargetRowHovered(true) : undefined} onMouseLeave={isInTargetRow ? () => setIsTargetRowHovered(false) : undefined} @@ -401,25 +410,20 @@ const ValidationCell = React.memo(({ )} - {isValidating ? ( + {isLoading ? (
Loading...
) : (
- {(() => { console.log(`ValidationCell: fieldKey=${fieldKey}, options=`, options); return null; })()}
)} @@ -427,32 +431,48 @@ const ValidationCell = React.memo(({
); }, (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 || - (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]; - })); + // Then check value changes + if (prevProps.value !== nextProps.value) return false; - // Skip comparison of props that rarely change - // (rowIndex, width, copyDown, totalRows) + // Item number is related to validation state + if (prevProps.itemNumber !== nextProps.itemNumber) return false; - return valueEqual && isValidatingEqual && fieldEqual && errorsEqual && - optionsEqual && itemNumberEqual; + // 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) => { + const nextOptions = nextProps.options || []; + return opt === nextOptions[idx]; + })); + + if (!optionsEqual) return false; + } + + // 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'; diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx index 1f95a6d..9e64e3d 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx @@ -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 @@ -58,7 +60,7 @@ const ValidationContainer = ({ loadTemplates, setData, fields, - isLoadingTemplates } = validationState + isLoadingTemplates } = validationState // Use product lines fetching hook const { @@ -114,6 +116,58 @@ const ValidationContainer = ({ const [templateFormInitialData, setTemplateFormInitialData] = useState(null) const [fieldOptions, setFieldOptions] = useState(null) + // Track fields that need revalidation due to value changes + const [fieldsToRevalidate, setFieldsToRevalidate] = useState>(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 = ({ } }, [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 = { ...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); + }, 100); + }); - // Call the onNext callback with the validated data - onNext?.(data) - }, [onNext, data, upcValidation.applyItemNumbersToData]); + // 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 = ({ setRowSelection(newSelection); }, [setRowSelection]); + // Add scroll container ref at the container level + const scrollContainerRef = useRef(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>({}); + const lastLineFetchTime = useRef>({}); + + // Memoize scroll handlers - simplified to avoid performance issues + const handleScroll = useCallback((event: React.UIEvent | 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 = ({ } } - // 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 + }; + } + return newData; + }); + + // Mark for revalidation after a delay to ensure data update completes first + setTimeout(() => { + markRowForRevalidation(rowIndex, key as string); + }, 0); + + // 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 }; - - newData[originalIndex] = updatedRow; - return newData; - }); - } + } + return newData; + }); - // Restore scroll position after update + // Secondary effects - using a timeout to ensure UI updates first setTimeout(() => { - window.scrollTo(scrollPosition.left, scrollPosition.top); - }, 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) { - setData(prevData => { - const newData = [...prevData]; - newData[originalIndex] = { - ...newData[originalIndex], - line: undefined, - subline: undefined - }; - 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 updating supplier field AND there's a UPC value, validate UPC - if (key === 'supplier' && value && rowData) { - const rowDataAny = rowData as Record; - if (rowDataAny.upc || rowDataAny.barcode) { - const upcValue = rowDataAny.upc || rowDataAny.barcode; + // 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`); - try { - // Mark the item_number cell as being validated - setValidatingCells(prev => { - const newSet = new Set(prev); - newSet.add(`${rowIndex}-item_number`); - 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 { - // Clear validation state for the item_number cell - setValidatingCells(prev => { - const newSet = new Set(prev); - newSet.delete(`${rowIndex}-item_number`); - return newSet; - }); - } - } - } - - // If updating line field, fetch sublines - if (key === 'line' && value) { - // Clear any existing subline value for this row - if (originalIndex !== -1) { + // Clear any existing line/subline values immediately setData(prevData => { const newData = [...prevData]; - newData[originalIndex] = { - ...newData[originalIndex], - subline: undefined - }; + 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 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 updating UPC/barcode field AND there's a supplier value, validate UPC - if ((key === 'upc' || key === 'barcode') && value && rowData) { - const rowDataAny = rowData as Record; - if (rowDataAny.supplier) { - try { - // Mark the item_number cell as being validated + + // Fetch product lines for the new company + if (rowId && value !== undefined) { + const companyId = value.toString(); + + // Force immediate fetch for better UX + console.log(`Immediately fetching product lines for company ${companyId} for row ${rowId}`); + + // 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 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 { - // Clear validation state for the item_number cell - setValidatingCells(prev => { - const newSet = new Set(prev); - newSet.delete(`${rowIndex}-item_number`); - return newSet; - }); + 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; + }); + }); } } - } - }, [data, filteredData, updateRow, fetchProductLines, fetchSublines, setData, rowProductLines, rowSublines, upcValidation]); + + // 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(cellKey); + return newSet; + }); + }); + } + } + + // Handle line change - clear subline and fetch sublines + if (key === 'line' && value) { + console.log(`Line changed to ${value} for row ${rowIndex}, updating sublines`); + + // Clear any existing subline value + setData(prevData => { + const newData = [...prevData]; + 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; + }); + + // Fetch sublines for the new line + if (rowId && value !== undefined) { + const lineId = value.toString(); + + // 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}-subline`); + return newSet; + }); + + 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(cellKey); + return newSet; + }); + }); + } + } + }, 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 = ({ // 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(); - - // 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(); + 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; + + 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 + }; + } + } + }); - // Remove loading state + 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); - newSet.delete(`${targetRowIndex}-${fieldKey}`); + updatingCells.forEach(cell => newSet.delete(cell)); 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; + }, 100); - // Run validation immediately without timeout - upcValidation.validateAllUPCs(); - - // No cleanup needed since we're not using a timer - }, [data, upcValidation]); + // 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.add(cellKey); + return newSet; + }); + }); + + // 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); + + 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); + }); + } + }); + }; + + // 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 = ({ isLoadingSublines ]); - // Add scroll container ref at the container level - const scrollContainerRef = useRef(null); - const lastScrollPosition = useRef({ left: 0, top: 0 }); - const isScrolling = useRef(false); - - // Memoize scroll handlers - const handleScroll = useCallback((event: React.UIEvent | 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 (
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx index 6164ae1..38b72d4 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationTable.tsx @@ -189,10 +189,6 @@ const ValidationTable = ({ }: ValidationTableProps) => { const { translations } = useRsi(); - // 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(null); @@ -308,10 +304,13 @@ const ValidationTable = ({ const cache = new Map(); fields.forEach((field) => { - // Don't skip disabled fields + // Get the field key + const fieldKey = String(field.key); - if (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select') { - 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 = ({ 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]) { + } + else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) { isLoading = true; - console.log(`Subline field for row ${rowId} is loading`); } + // Get validation errors for this cell + const cellErrors = validationErrors.get(row.index)?.[fieldKey] || []; + return ( } 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 = ({
)}
- {/* Custom Table Header - Always Visible */} + {/* Custom Table Header - Always Visible with GPU acceleration */}
{table.getFlatHeaders().map((header) => { @@ -521,49 +528,57 @@ const ValidationTable = ({
- {/* Table Body - Restore the original structure */} - + {/* Table Body - With optimized rendering */} +
- {table.getRowModel().rows.map((row) => ( - 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" : "" - )} - 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 - }} - onMouseEnter={() => handleRowMouseEnter(parseInt(row.id))} - > - {row.getVisibleCells().map((cell: any) => { - const width = cell.column.getSize(); - return ( - sourceRowIndex ? 'pointer' : undefined - }} - className={isInCopyDownMode && sourceRowIndex !== null && parseInt(row.id) > sourceRowIndex ? "target-row-cell" : ""} - > + {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 ( + handleRowMouseEnter(parseInt(row.id))} + > + {row.getVisibleCells().map((cell: any) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ); - })} - - ))} + + ))} + + ); + })}
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx index 3a689e4..739e472 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx @@ -107,23 +107,28 @@ const InputCell = ({ // 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) => { diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx index 0e9268e..f7e0503 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiSelectCell.tsx @@ -167,41 +167,59 @@ const MultiSelectCell = ({ const commandListRef = useRef(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 = ({ // 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 diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx index b386145..3975595 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx @@ -51,8 +51,6 @@ const SelectCell = ({ // 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 = ({ // 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 = ({ // 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,105 +139,44 @@ const SelectCell = ({ // 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 ( - { - setOpen(isOpen); - if (isOpen && onStartEdit) onStartEdit(); - }} - > - - - - - - - - No options found. - - {selectOptions.map((option) => ( - handleSelect(value)} - className="flex w-full" - > - - {option.label} - - ))} - - - - - + borderColor: hasClass('!border-blue-500') ? '#3b82f6' : + hasClass('!border-blue-200') ? '#bfdbfe' : + hasClass('!border-blue-200') && isHovered ? '#bfdbfe' : + undefined, + borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined, + cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined + }} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {displayText || ""} +
); } diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx index b4b8b96..840471e 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx @@ -39,24 +39,27 @@ export const useProductLinesFetching = (data: Record[]) => { // 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[]) => { // 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[]) => { // 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[]) => { 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[] = []; diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx index c231abe..a6866f9 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useUpcValidation.tsx @@ -1,11 +1,11 @@ import { useState, useCallback, useRef, useEffect } from 'react' import config from '@/config' - interface ValidationState { validatingCells: Set; // Using rowIndex-fieldKey as identifier itemNumbers: Map; // Using rowIndex as key validatingRows: Set; // Rows currently being validated + activeValidations: Set; // Active validations } export const useUpcValidation = ( @@ -16,19 +16,27 @@ export const useUpcValidation = ( const validationStateRef = useRef({ 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>(new Set()); - const [itemNumberUpdates, setItemNumberUpdates] = useState>(new Map()); + const [, setValidatingCellKeys] = useState>(new Set()); + const [, setItemNumberUpdates] = useState>(new Map()); const [validatingRows, setValidatingRows] = useState>(new Set()); - const [isValidatingUpc, setIsValidatingUpc] = useState(false); + const [, setIsValidatingUpc] = useState(false); // Cache for UPC validation results const processedUpcMapRef = useRef(new Map()); const initialUpcValidationDoneRef = useRef(false); + // For batch validation + const validationQueueRef = useRef>([]); + const isProcessingBatchRef = useRef(false); + + // For validation results + const [upcValidationResults] = useState>(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 @@ -83,114 +93,270 @@ export const useUpcValidation = ( const getItemNumber = useCallback((rowIndex: number): string | undefined => { return validationStateRef.current.itemNumbers.get(rowIndex); }, []); + + // 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)}`); + + // 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' }; + } + }, []); - // Apply all pending updates to the data state - const applyItemNumbersToData = useCallback(() => { - if (validationStateRef.current.itemNumbers.size === 0) return; + // 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)); - setData((prevData: any[]) => { + // 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 }; - } - - // 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 - const cacheKey = `${supplierId}-${upcValue}`; - if (processedUpcMapRef.current.has(cacheKey)) { - const cachedItemNumber = processedUpcMapRef.current.get(cacheKey); + // Process in small batches + for (let i = 0; i < queue.length; i += BATCH_SIZE) { + const batch = queue.slice(i, i + BATCH_SIZE); - if (cachedItemNumber) { - // Use cached item number - updateItemNumber(rowIndex, cachedItemNumber); - return { success: true, itemNumber: cachedItemNumber }; + // 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) { + console.log(`Using cached item number for row ${rowIndex}: ${cachedItemNumber}`); + updateItemNumber(rowIndex, cachedItemNumber); + updatesApplied = true; + updatedRows.push(rowIndex); + return true; + } + return false; + } + + // Fetch from API + const result = await fetchProductByUpc(supplierId, upcValue); + + if (!result.error && result.data?.itemNumber) { + const itemNumber = result.data.itemNumber; + + // Store in cache + processedUpcMapRef.current.set(cacheKey, itemNumber); + + // Update item number + updateItemNumber(rowIndex, itemNumber); + updatesApplied = true; + updatedRows.push(rowIndex); + + console.log(`Set item number for row ${rowIndex} to ${itemNumber}`); + return true; + } + return false; + } catch (error) { + console.error(`Error processing row ${rowIndex}:`, error); + return false; + } finally { + // Clear validation state + stopValidatingRow(rowIndex); + } + })); + + // 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 } - return { success: false }; - } - - // Make API call to validate UPC - const response = await fetch(`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`); - - // 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); - - // Update the item numbers state - updateItemNumber(rowIndex, responseData.itemNumber); - - return { success: true, itemNumber: responseData.itemNumber }; + // Small delay between batches to allow UI to update + if (i + BATCH_SIZE < queue.length) { + await new Promise(resolve => setTimeout(resolve, 10)); } } - - return { success: false }; } catch (error) { - console.error(`Error validating UPC for row ${rowIndex}:`, error); - return { success: false }; + console.error('Error in batch processing:', error); } finally { - // Clear validation state - stopValidatingCell(rowIndex, 'upc'); - stopValidatingCell(rowIndex, 'item_number'); - stopValidatingRow(rowIndex); + isProcessingBatchRef.current = false; + + // Process any new items + if (validationQueueRef.current.length > 0) { + setTimeout(processBatchValidation, 0); + } } - }, [startValidatingCell, stopValidatingCell, updateItemNumber, startValidatingRow, stopValidatingRow]); + }, [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 - await Promise.all( - rowsToValidate.map(async ({ row, index }) => { - try { - const rowAny = row as Record; - const supplierId = rowAny.supplier.toString(); - const upcValue = (rowAny.upc || rowAny.barcode).toString(); - - // Validate the UPC - await validateUpc(index, supplierId, upcValue); - - // Remove this row from the validating set (handled in validateUpc) - } catch (error) { - console.error(`Error processing row ${index}:`, error); - stopValidatingRow(index); - } - }) - ); + // 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( + batch.map(async ({ row, index }) => { + try { + const rowAny = row as Record; + const supplierId = rowAny.supplier.toString(); + const upcValue = (rowAny.upc || rowAny.barcode).toString(); + + console.log(`Validating UPC in initial batch: row=${index}, supplier=${supplierId}, upc=${upcValue}`); + + // 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 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(() => { @@ -259,43 +490,28 @@ export const useUpcValidation = ( // Run validation 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; - } - } -} \ No newline at end of file + + // Results + upcValidationResults, + + // Initialization state + initialValidationDone: initialUpcValidationDoneRef.current + }; +}; \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx index 227f500..a1bb3b8 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx @@ -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(); + +// Add debounce to prevent rapid successive validations +let validateDataTimeoutId: ReturnType | 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 = ( fields: Fields, rowHook?: RowHook, @@ -35,6 +69,14 @@ export const useValidation = ( 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 = ( } }) + // Store results in cache to speed up future validations + validationResultCache.set(cacheKey, errors); + return errors }, []) @@ -223,83 +268,256 @@ export const useValidation = ( return uniqueErrors; }, [fields]); - // Run complete validation - const validateData = useCallback(async (data: RowData[]) => { - // Step 1: Run field and row validation for each row + // Additional function to explicitly validate uniqueness for specified fields + const validateUniqueField = useCallback((data: RowData[], fieldKey: string) => { + // Field keys that need special handling for uniqueness + const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no']; - // Step 2: Run unique validations - const uniqueValidations = validateUnique(data); + // 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>(); + } + } - // Step 3: Run table hook + // Create map to track errors + const uniqueErrors = new Map>(); - // Create a map to store all validation errors - const validationErrors = new Map>(); + // Find the field definition + const field = fields.find(f => String(f.key) === fieldKey); + if (!field) return uniqueErrors; - // Merge all validation results - data.forEach((row, index) => { - // Collect errors from all validation sources - const rowErrors: Record = {}; + // 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(); + + // Build value map + data.forEach((row, rowIndex) => { + const value = String(row[fieldKey as keyof typeof row] || ''); - // 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]; - const errors = validateField(value, field as Field); - - if (errors.length > 0) { - rowErrors[String(field.key)] = { - message: errors[0].message, - level: errors[0].level, - 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; - }); + // Skip empty values if allowed + if (allowEmpty && isEmpty(value)) { + return; } - // Filter out "required" errors for fields that have values - const filteredErrors: Record = {}; - - Object.entries(rowErrors).forEach(([key, error]) => { - const fieldValue = row[key as keyof typeof row]; - - // 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; - } - - filteredErrors[key] = error; - }); - - // Only add to the map if there are errors - if (Object.keys(filteredErrors).length > 0) { - validationErrors.set(index, filteredErrors); + 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[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => { + const validationErrors = new Map>(); + + // If we're updating a specific field, only validate that field for that row + if (fieldToUpdate) { + const { rowIndex, fieldKey } = fieldToUpdate; + + // 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); + + if (errors.length > 0) { + // Store the validation error + validationErrors.set(rowIndex, { + [fieldKey]: { + message: errors[0].message, + 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 = {}; + + // 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); + + 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 row to validationErrors if it has any errors + if (Object.keys(rowErrors).length > 0) { + validationErrors.set(rowIndex, rowErrors); + } + } + + // Get fields requiring uniqueness validation + const uniqueFields = fields.filter(field => + field.validations?.some(v => v.rule === 'unique') + ); + + // Also add standard unique fields that might not be explicitly marked as unique + const standardUniqueFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no']; + + // 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, {}); + } + Object.assign(validationErrors.get(rowIdx)!, errors); + }); + }); + + 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 } } \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx index 0c7c4d5..9d2dc88 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidationState.tsx @@ -6,6 +6,7 @@ import { RowSelectionState } from '@tanstack/react-table' import { toast } from 'sonner' import { useQuery } from "@tanstack/react-query"; import config from "@/config"; +import { useValidation } from './useValidation' // Helper function to check if a value is empty @@ -19,7 +20,7 @@ import config from "@/config"; // Define the Props interface for ValidationStepNew export interface Props { initialData: RowData[] - file: File + file?: File onBack?: () => void onNext?: (data: RowData[]) => void isFromScratch?: boolean @@ -104,9 +105,12 @@ function debounce any>( export const useValidationState = ({ initialData, onBack, - onNext}: ValidationStateProps) => { + onNext}: Props) => { const { fields, rowHook, tableHook } = useRsi(); + // Import validateData from useValidation at the beginning + const { validateField: validateFieldFromHook } = useValidation(fields, rowHook, tableHook); + // Add ref to track template application state const isApplyingTemplateRef = useRef(false); @@ -157,7 +161,7 @@ export const useValidationState = ({ const [isValidating] = useState(false) const [validationErrors, setValidationErrors] = useState>>(new Map()) const [rowValidationStatus, setRowValidationStatus] = useState>(new Map()) - const [, setValidatingCells] = useState>(new Set()) + const [] = useState>(new Set()) // Template state const [templates, setTemplates] = useState([]) @@ -188,207 +192,539 @@ export const useValidationState = ({ // Add debounce timer ref for item number validation // Add batch update state - const pendingUpdatesRef = useRef<{ - errors: Map>, - statuses: Map, - data: Array>, - cells: Set - }>({ - errors: new Map(), - statuses: new Map(), - data: [], - cells: new Set() - }); // Optimized batch update function with single state update - const flushPendingUpdates = useCallback(() => { - const updates = pendingUpdatesRef.current; - const hasDataUpdates = updates.data.length > 0; - const hasErrorsUpdates = updates.errors.size > 0; - const hasStatusUpdates = updates.statuses.size > 0; - const hasCellUpdates = updates.cells.size > 0; + + // Queue a row update to be processed in batch + + // Tracking rows that need validation + + // Helper function to validate a field value - now just an alias for validateFieldFromHook + const validateField = validateFieldFromHook; + + // Helper function to validate a field value + const fieldValidationHelper = useCallback((rowIndex: number, specificField?: string) => { + // Skip validation if row doesn't exist + if (rowIndex < 0 || rowIndex >= data.length) return; - if (!hasDataUpdates && !hasErrorsUpdates && !hasStatusUpdates && !hasCellUpdates) { - return; // No updates to process - } + // Get the row data + const row = data[rowIndex]; - // Batch all state updates in a single callback - // This minimizes React re-renders - const batchedUpdates = () => { - if (hasDataUpdates) { - setData(prevData => { - if (updates.data.length === 0) return prevData; + // If validating a specific field, only check that field + if (specificField) { + const field = fields.find(f => String(f.key) === specificField); + if (field) { + const value = row[specificField as keyof typeof row]; + + // Use state setter instead of direct mutation + setValidationErrors(prev => { + const newErrors = new Map(prev); + const existingErrors = {...(newErrors.get(rowIndex) || {})}; - // Create a new array only if we need to change something - const newData = [...prevData]; - for (const rowData of updates.data) { - const index = rowData.__index ? parseInt(rowData.__index) : -1; - if (index >= 0 && index < newData.length) { - newData[index] = rowData; + // Quick check for required fields - this prevents flashing errors + const isRequired = field.validations?.some(v => v.rule === 'required'); + const isEmpty = value === undefined || value === null || value === '' || + (Array.isArray(value) && value.length === 0) || + (typeof value === 'object' && value !== null && Object.keys(value).length === 0); + + // For non-empty values, remove required errors immediately + if (isRequired && !isEmpty && existingErrors[specificField]) { + const nonRequiredErrors = existingErrors[specificField].filter(e => e.type !== ErrorType.Required); + if (nonRequiredErrors.length === 0) { + // If no other errors, remove the field entirely from errors + delete existingErrors[specificField]; + } else { + existingErrors[specificField] = nonRequiredErrors; } } + + // Run full validation for the field + const errors = validateField(value, field as unknown as Field); + + // Update validation errors for this field + if (errors.length > 0) { + existingErrors[specificField] = errors; + } else { + delete existingErrors[specificField]; + } + + // Update validation errors map + if (Object.keys(existingErrors).length > 0) { + newErrors.set(rowIndex, existingErrors); + } else { + newErrors.delete(rowIndex); + } + + return newErrors; + }); + } + } else { + // Validate all fields in the row + setValidationErrors(prev => { + const newErrors = new Map(prev); + const rowErrors: Record = {}; + + fields.forEach(field => { + const fieldKey = String(field.key); + const value = row[fieldKey as keyof typeof row]; + const errors = validateField(value, field as unknown as Field); + + if (errors.length > 0) { + rowErrors[fieldKey] = errors; + } + }); + + // Update validation errors map + if (Object.keys(rowErrors).length > 0) { + newErrors.set(rowIndex, rowErrors); + } else { + newErrors.delete(rowIndex); + } + + return newErrors; + }); + } + }, [data, fields, validateField, setValidationErrors]); + + // Use validateRow as an alias for fieldValidationHelper for compatibility + const validateRow = fieldValidationHelper; + + // Smart markRowForRevalidation function that validates instead of just clearing errors + + // Modified updateRow function that properly handles field-specific validation + const updateRow = useCallback((rowIndex: number, key: T, value: any) => { + // Process value before updating data + let processedValue = value; + + // Strip dollar signs from price fields + if ((key === 'msrp' || key === 'cost_each') && typeof value === 'string') { + processedValue = value.replace(/[$,]/g, ''); + + // Also ensure it's a valid number + const numValue = parseFloat(processedValue); + if (!isNaN(numValue)) { + processedValue = numValue.toFixed(2); + } + } + + // Find the row data first + const rowData = data[rowIndex]; + if (!rowData) { + console.error(`No row data found for index ${rowIndex}`); + return; + } + + // Create a copy of the row to avoid mutation + const updatedRow = { ...rowData, [key]: processedValue }; + + // Update the data immediately - this sets the value + setData(prevData => { + const newData = [...prevData]; + if (rowIndex >= 0 && rowIndex < newData.length) { + newData[rowIndex] = updatedRow; + } + return newData; + }); + + // Find the field definition + const field = fields.find(f => String(f.key) === key); + if (!field) return; + + // CRITICAL FIX: Combine both validation operations into a single state update + // to prevent intermediate rendering that causes error icon flashing + setValidationErrors(prev => { + const newMap = new Map(prev); + const existingErrors = newMap.get(rowIndex) || {}; + const newRowErrors = { ...existingErrors }; + + // Check for required field first + const isRequired = field.validations?.some(v => v.rule === 'required'); + const isEmpty = processedValue === undefined || processedValue === null || processedValue === '' || + (Array.isArray(processedValue) && processedValue.length === 0) || + (typeof processedValue === 'object' && processedValue !== null && Object.keys(processedValue).length === 0); + + // For required fields with values, remove required errors + if (isRequired && !isEmpty && newRowErrors[key as string]) { + const hasRequiredError = newRowErrors[key as string].some(e => e.type === ErrorType.Required); + + if (hasRequiredError) { + // Remove required errors but keep other types of errors + const nonRequiredErrors = newRowErrors[key as string].filter(e => e.type !== ErrorType.Required); + + if (nonRequiredErrors.length === 0) { + // If no other errors, delete the field's errors entirely + delete newRowErrors[key as string]; + } else { + // Otherwise keep non-required errors + newRowErrors[key as string] = nonRequiredErrors; + } + } + } + + // Now run full validation for the field (except for required which we already handled) + const errors = validateField(processedValue, field as unknown as Field) + .filter(e => e.type !== ErrorType.Required || isEmpty); + + // Update with new validation results + if (errors.length > 0) { + newRowErrors[key as string] = errors; + } else if (!newRowErrors[key as string]) { + // If no errors found and no existing errors, ensure field is removed from errors + delete newRowErrors[key as string]; + } + + // Update the map + if (Object.keys(newRowErrors).length > 0) { + newMap.set(rowIndex, newRowErrors); + } else { + newMap.delete(rowIndex); + } + + return newMap; + }); + + // Handle simple secondary effects here + setTimeout(() => { + // Use __index to find the actual row in the full data array + const rowId = rowData.__index; + + // Handle company change - clear line/subline + if (key === 'company' && processedValue) { + // Clear any existing line/subline values + setData(prevData => { + const newData = [...prevData]; + const idx = newData.findIndex(item => item.__index === rowId); + if (idx >= 0) { + newData[idx] = { + ...newData[idx], + line: undefined, + subline: undefined + }; + } return newData; }); } - if (hasErrorsUpdates) { - setValidationErrors(prev => { - if (updates.errors.size === 0) return prev; - - // Create a new map - const newErrors = new Map(prev); - for (const [rowIndex, errors] of updates.errors.entries()) { - newErrors.set(rowIndex, errors); + // Handle line change - clear subline + if (key === 'line' && processedValue) { + // Clear any existing subline value + setData(prevData => { + const newData = [...prevData]; + const idx = newData.findIndex(item => item.__index === rowId); + if (idx >= 0) { + newData[idx] = { + ...newData[idx], + subline: undefined + }; } - return newErrors; + return newData; }); } + }, 50); + }, [data, fields, validateField, setData, setValidationErrors]); + + // Improved revalidateRows function + const revalidateRows = useCallback(async (rowIndexes: number[], updatedFields?: { [rowIndex: number]: string[] }) => { + // Process all specified rows using a single state update to avoid race conditions + setValidationErrors(prev => { + const newErrors = new Map(prev); - if (hasStatusUpdates) { - setRowValidationStatus(prev => { - if (updates.statuses.size === 0) return prev; + // Process each row + for (const rowIndex of rowIndexes) { + if (rowIndex < 0 || rowIndex >= data.length) continue; + + const row = data[rowIndex]; + if (!row) continue; + + // If we have specific fields to update for this row + const fieldsToValidate = updatedFields?.[rowIndex] || []; + + if (fieldsToValidate.length > 0) { + // Get existing errors for this row + const existingRowErrors = { ...(newErrors.get(rowIndex) || {}) }; - // Create a new map - const newStatuses = new Map(prev); - for (const [rowIndex, status] of updates.statuses.entries()) { - newStatuses.set(rowIndex, status); + // Validate each specified field + for (const fieldKey of fieldsToValidate) { + const field = fields.find(f => String(f.key) === fieldKey); + if (!field) continue; + + const value = row[fieldKey as keyof typeof row]; + + // Check if this is a required field with a value + + // Run validation for this field + const errors = validateField(value, field as unknown as Field); + + // Update errors for this field + if (errors.length > 0) { + existingRowErrors[fieldKey] = errors; + } else { + delete existingRowErrors[fieldKey]; + } } - return newStatuses; - }); - } - - if (hasCellUpdates) { - setValidatingCells(prev => { - if (updates.cells.size === 0) return prev; - // Create a new set - return new Set([...prev, ...updates.cells]); - }); + // Update the row's errors + if (Object.keys(existingRowErrors).length > 0) { + newErrors.set(rowIndex, existingRowErrors); + } else { + newErrors.delete(rowIndex); + } + } else { + // No specific fields provided - validate the entire row + const rowErrors: Record = {}; + + // Validate all fields in the row + for (const field of fields) { + const fieldKey = String(field.key); + const value = row[fieldKey as keyof typeof row]; + + // Run validation for this field + const errors = validateField(value, field as unknown as Field); + + // Update errors for this field + if (errors.length > 0) { + rowErrors[fieldKey] = errors; + } + } + + // Update the row's errors + if (Object.keys(rowErrors).length > 0) { + newErrors.set(rowIndex, rowErrors); + } else { + newErrors.delete(rowIndex); + } + } } - }; - - // Use React's batching mechanism - batchedUpdates(); - - // Reset pending updates - pendingUpdatesRef.current = { - errors: new Map(), - statuses: new Map(), - data: [], - cells: new Set() - }; - }, []); - - // Queue a row update to be processed in batch - const queueRowUpdate = useCallback((rowIndex: number, key: T, value: any) => { - const updates = pendingUpdatesRef.current; - - // Find the row to update - let rowToUpdate: RowData | undefined = undefined; - for (const row of updates.data) { - if (row.__index === String(rowIndex)) { - rowToUpdate = row; - break; - } - } - - // If not found, look in the current data state - if (!rowToUpdate) { - const currentRow = data[rowIndex]; - if (!currentRow) return; - // Create a copy and add to pending updates - rowToUpdate = { ...currentRow }; - updates.data.push(rowToUpdate); - } - - // Update the value - rowToUpdate[key] = value; - - // Mark as changed - if (!rowToUpdate.__changes) { - rowToUpdate.__changes = {}; - } - rowToUpdate.__changes[key as string] = true; - - // Queue cell for validation - updates.cells.add(`${rowIndex}:${key}`); - - // Schedule a flush - if (DEBOUNCE_DELAY <= 0) { - // No delay, flush immediately - flushPendingUpdates(); - } else { - // Use setTimeout instead of debounce utility for more flexibility - setTimeout(flushPendingUpdates, DEBOUNCE_DELAY); - } - }, [data, flushPendingUpdates]); + return newErrors; + }); + }, [data, fields, validateField]); - // Replace the existing updateRow function - const updateRow = useCallback((rowIndex: number, key: T, value: any) => { - // Use the optimized queue mechanism - queueRowUpdate(rowIndex, key, value); - }, [queueRowUpdate]); - - // Update validateUniqueItemNumbers to use batch updates + // Update validateUniqueItemNumbers to also check for uniqueness of UPC/barcode const validateUniqueItemNumbers = useCallback(async () => { - console.log('Validating unique item numbers'); + console.log('Validating unique fields'); // Skip if no data if (!data.length) return; - // Use a more efficient Map to track duplicates - const itemNumberMap = new Map(); + // Track unique identifiers in maps + const uniqueFieldsMap = new Map>(); + + // Find fields that need uniqueness validation + const uniqueFields = fields.filter(field => + field.validations?.some(v => v.rule === 'unique') + ).map(field => String(field.key)); + + console.log(`Found ${uniqueFields.length} fields requiring uniqueness validation:`, uniqueFields); + + // Always check item_number uniqueness even if not explicitly defined + if (!uniqueFields.includes('item_number')) { + uniqueFields.push('item_number'); + } + + // Initialize maps for each unique field + uniqueFields.forEach(fieldKey => { + uniqueFieldsMap.set(fieldKey, new Map()); + }); // Initialize batch updates const errors = new Map>(); - // Single pass through data to identify all item numbers + // Single pass through data to identify all unique values data.forEach((row, index) => { - const itemNumber = row.item_number?.toString(); - if (itemNumber) { - // Get or initialize the array of indices for this item number - const indices = itemNumberMap.get(itemNumber) || []; - indices.push(index); - itemNumberMap.set(itemNumber, indices); - } - }); - - // Process duplicates more efficiently - itemNumberMap.forEach((indices, itemNumber) => { - // Only process if there are duplicates - if (indices.length > 1) { - const errorObj = { - message: `Duplicate item number: ${itemNumber}`, - level: 'error' as 'error', - source: ErrorSources.Table, - type: ErrorType.Unique - }; + uniqueFields.forEach(fieldKey => { + const value = row[fieldKey as keyof typeof row]; - // Add error to each row with this item number - indices.forEach(rowIndex => { - const rowErrors = errors.get(rowIndex) || {}; - rowErrors['item_number'] = [errorObj]; - errors.set(rowIndex, rowErrors); - }); - } + // Skip empty values + if (value === undefined || value === null || value === '') { + return; + } + + const valueStr = String(value); + const fieldMap = uniqueFieldsMap.get(fieldKey); + + if (fieldMap) { + // Get or initialize the array of indices for this value + const indices = fieldMap.get(valueStr) || []; + indices.push(index); + fieldMap.set(valueStr, indices); + } + }); }); - // Apply batch updates + // Process duplicates + uniqueFields.forEach(fieldKey => { + const fieldMap = uniqueFieldsMap.get(fieldKey); + if (!fieldMap) return; + + fieldMap.forEach((indices, value) => { + // Only process if there are duplicates + if (indices.length > 1) { + // Get the validation rule for this field + const field = fields.find(f => String(f.key) === fieldKey); + const validationRule = field?.validations?.find(v => v.rule === 'unique'); + + const errorObj = { + message: validationRule?.errorMessage || `Duplicate ${fieldKey}: ${value}`, + level: validationRule?.level || 'error' as 'error', + source: ErrorSources.Table, + type: ErrorType.Unique + }; + + // Add error to each row with this value + indices.forEach(rowIndex => { + const rowErrors = errors.get(rowIndex) || {}; + rowErrors[fieldKey] = [errorObj]; + errors.set(rowIndex, rowErrors); + }); + } + }); + }); + + // Apply batch updates only if we have errors to report if (errors.size > 0) { + // OPTIMIZATION: Check if we actually have new errors before updating state + let hasChanges = false; + + // We'll update errors with a single batch operation setValidationErrors(prev => { const newMap = new Map(prev); + + // Check each row for changes errors.forEach((rowErrors, rowIndex) => { - // Preserve existing errors for other fields const existingErrors = newMap.get(rowIndex) || {}; - newMap.set(rowIndex, { ...existingErrors, ...rowErrors }); + const updatedErrors = { ...existingErrors }; + let rowHasChanges = false; + + // Check each field for changes + Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => { + // Compare with existing errors + const existingFieldErrors = existingErrors[fieldKey]; + + if (!existingFieldErrors || + existingFieldErrors.length !== fieldErrors.length || + !existingFieldErrors.every((err, idx) => + err.message === fieldErrors[idx].message && + err.type === fieldErrors[idx].type + )) { + // We have a change + updatedErrors[fieldKey] = fieldErrors; + rowHasChanges = true; + hasChanges = true; + } + }); + + // Only update if we have changes + if (rowHasChanges) { + newMap.set(rowIndex, updatedErrors); + } }); - return newMap; + + // Only return a new map if we have changes + return hasChanges ? newMap : prev; }); } - console.log('Unique item number validation complete'); - }, [data]); + console.log('Uniqueness validation complete'); + }, [data, fields]); + + // Add ref to prevent recursive validation + const isValidatingRef = useRef(false); + + // Run validation when data changes - FIXED to prevent recursive validation + useEffect(() => { + // Skip initial load - we have a separate initialization process + if (!initialValidationDoneRef.current) return; + + // Don't run validation during template application + if (isApplyingTemplateRef.current) return; + + // CRITICAL FIX: Skip validation if we're already validating to prevent infinite loops + if (isValidatingRef.current) return; + + console.log('Running validation on data change'); + isValidatingRef.current = true; + + // For faster validation, run synchronously instead of in an async function + const validateFields = () => { + try { + // Run regex validations on all rows + const regexFields = fields.filter(field => field.validations?.some(v => v.rule === 'regex')); + if (regexFields.length > 0) { + // Create a map to collect validation errors + const regexErrors = new Map>(); + + // Check each row for regex errors + data.forEach((row, rowIndex) => { + const rowErrors: Record = {}; + let hasErrors = false; + + // Check each regex field + regexFields.forEach(field => { + const key = String(field.key); + const value = row[key as keyof typeof row]; + + // Skip empty values + if (value === undefined || value === null || value === '') { + return; + } + + // Find regex validation + const regexValidation = field.validations?.find(v => v.rule === 'regex'); + if (regexValidation) { + try { + // Check if value matches regex + const regex = new RegExp(regexValidation.value, regexValidation.flags); + if (!regex.test(String(value))) { + // Add regex validation error + rowErrors[key] = [{ + message: regexValidation.errorMessage, + level: regexValidation.level || 'error', + source: ErrorSources.Row, + type: ErrorType.Regex + }]; + hasErrors = true; + } + } catch (error) { + console.error('Invalid regex in validation:', error); + } + } + }); + + // Add errors if any found + if (hasErrors) { + regexErrors.set(rowIndex, rowErrors); + } + }); + + // Update validation errors + if (regexErrors.size > 0) { + setValidationErrors(prev => { + const newErrors = new Map(prev); + // Merge in regex errors + for (const [rowIndex, errors] of regexErrors.entries()) { + const existingErrors = newErrors.get(rowIndex) || {}; + newErrors.set(rowIndex, { ...existingErrors, ...errors }); + } + return newErrors; + }); + } + } + + // Run uniqueness validations immediately + validateUniqueItemNumbers(); + } finally { + // Always ensure the ref is reset, even if an error occurs + setTimeout(() => { + isValidatingRef.current = false; + }, 100); + } + }; + + // Run validation immediately + validateFields(); + + }, [data, fields, validateUniqueItemNumbers]); // Fetch product by UPC from API - optimized with proper error handling and types const fetchProductByUpc = useCallback(async (supplier: string, upc: string): Promise => { @@ -652,143 +988,6 @@ export const useValidationState = ({ }) }, []) - // Helper function to validate a field value - const validateField = useCallback((value: any, field: Field): ValidationError[] => { - const errors: ValidationError[] = []; - - // Required field validation - improved to better handle various value types - if (field.validations?.some(v => v.rule === 'required')) { - if (value === undefined || value === null || value === '' || - (Array.isArray(value) && value.length === 0) || - (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0)) { - errors.push({ - message: field.validations.find(v => v.rule === 'required')?.errorMessage || `${field.label} is required`, - level: 'error', - source: ErrorSources.Row, - type: ErrorType.Required - }); - } - } - - // Regex validation - const regexValidation = field.validations?.find(v => v.rule === 'regex'); - if (regexValidation && value !== undefined && value !== null && value !== '') { - try { - const regex = new RegExp(regexValidation.value, regexValidation.flags); - if (!regex.test(String(value))) { - errors.push({ - message: regexValidation.errorMessage, - level: regexValidation.level || 'error', - source: ErrorSources.Row, - type: ErrorType.Regex - }); - } - } catch (error) { - console.error('Invalid regex in validation:', error); - } - } - - return errors; - }, []); - - // Validate a single row - const validateRow = useCallback((rowIndex: number) => { - // Skip if row doesn't exist - if (!data[rowIndex]) return; - - // Mark row as validating - setRowValidationStatus(prev => { - const updated = new Map(prev); - updated.set(rowIndex, 'validating'); - return updated; - }); - - // Get the row data - const row = data[rowIndex]; - const fieldErrors: Record = {}; - let hasErrors = false; - - // Track if row has changes to original values - - // Use a more efficient approach - only validate fields that need validation - fields.forEach(field => { - // Skip disabled fields - if (field.disabled) return; - - const fieldKey = String(field.key); - const value = row[fieldKey as keyof typeof row]; - - // Validate the field - const errors = validateField(value, field as Field); - - // Store errors if any - if (errors.length > 0) { - fieldErrors[fieldKey] = errors; - hasErrors = true; - } - }); - - // Run row hook if provided - if (rowHook) { - try { - // Call the row hook - const hookResult = rowHook(row, rowIndex, data); - - // Handle both synchronous and asynchronous results - Promise.resolve(hookResult).then(result => { - // Extract errors from the hook result - const hookErrors: Record = {}; - let hasHookErrors = false; - - // Process hook errors if they exist - if (result) { - // The hook might return custom errors through a different mechanism - // We need to adapt to the new approach where errors are not stored in __errors - - // Update validation errors for this row - setValidationErrors(prev => { - const updated = new Map(prev); - if (Object.keys(fieldErrors).length > 0 || hasHookErrors) { - // Merge field errors with hook errors - const mergedErrors = { ...fieldErrors }; - - if (hasHookErrors) { - Object.entries(hookErrors).forEach(([key, errors]) => { - if (mergedErrors[key]) { - // Append to existing errors - mergedErrors[key] = [...mergedErrors[key], ...errors]; - } else { - // Add new errors - mergedErrors[key] = errors; - } - }); - } - - updated.set(rowIndex, mergedErrors); - } else { - updated.delete(rowIndex); - } - return updated; - }); - } - }); - } catch (error) { - console.error('Error in row hook:', error); - } - } else { - // No row hook, just update with field errors - setValidationErrors(prev => { - const updated = new Map(prev); - if (Object.keys(fieldErrors).length > 0) { - updated.set(rowIndex, fieldErrors); - } else { - updated.delete(rowIndex); - } - return updated; - }); - } - }, [data, fields, validateField, rowHook]); - // Copy a cell value to all cells below it in the same column const copyDown = useCallback((rowIndex: number, key: T) => { // Get the source value to copy @@ -1185,74 +1384,80 @@ export const useValidationState = ({ // Initialize validation on mount useEffect(() => { if (initialValidationDoneRef.current) return; - - // Function to perform basic field validations - const runBasicValidation = () => { - console.log('Starting basic field validation'); - - if (!data.length) { - console.log('No data to validate'); - return; - } - - // Create a copy for data modifications + + console.log('Running initial validation'); + + const runCompleteValidation = async () => { + if (!data || data.length === 0) return; + + console.log('Running complete validation...'); + + // Get required fields + const requiredFields = fields.filter(field => field.validations?.some(v => v.rule === 'required')); + console.log(`Found ${requiredFields.length} required fields`); + + // Get fields that have regex validation + const regexFields = fields.filter(field => field.validations?.some(v => v.rule === 'regex')); + console.log(`Found ${regexFields.length} fields with regex validation`); + + // Get fields that need uniqueness validation + const uniqueFields = fields.filter(field => field.validations?.some(v => v.rule === 'unique')); + console.log(`Found ${uniqueFields.length} fields requiring uniqueness validation`); + + // Limit batch size to avoid UI freezing + const BATCH_SIZE = 100; + const totalRows = data.length; + + // Initialize new data for any modifications const newData = [...data]; - // Use Maps for better performance with large datasets - const batchErrors = new Map>(); - const batchStatuses = new Map(); - - console.log(`Validating ${data.length} rows`); - - // Process in larger batches to improve performance - const BATCH_SIZE = Math.min(500, Math.max(100, Math.floor(data.length / 5))); // Larger adaptive batch size - const totalBatches = Math.ceil(data.length / BATCH_SIZE); + + // Create a temporary Map to collect all validation errors + const validationErrorsTemp = new Map>(); + + // Variables for batching let currentBatch = 0; + const totalBatches = Math.ceil(totalRows / BATCH_SIZE); - // Pre-cache field validations - const requiredFields = fields.filter(f => f.validations?.some(v => v.rule === 'required')); - - // Pre-process the supplier and company fields checks - const hasSupplierField = fields.some(field => String(field.key) === 'supplier'); - const hasCompanyField = fields.some(field => String(field.key) === 'company'); - - const processBatch = () => { + const processBatch = async () => { + // Calculate batch range const startIdx = currentBatch * BATCH_SIZE; - const endIdx = Math.min(startIdx + BATCH_SIZE, data.length); + const endIdx = Math.min(startIdx + BATCH_SIZE, totalRows); + console.log(`Processing batch ${currentBatch + 1}/${totalBatches} (rows ${startIdx} to ${endIdx - 1})`); - // Start validation time measurement for this batch - const batchStartTime = performance.now(); + // Process rows in this batch + const batchPromises: Promise[] = []; - // Create validation promises for all rows in the batch - const batchPromises = []; - - // Prepare a single batch processor for all rows for (let rowIndex = startIdx; rowIndex < endIdx; rowIndex++) { batchPromises.push( new Promise(resolve => { const row = data[rowIndex]; + + // Skip if row is empty or undefined + if (!row) { + resolve(); + return; + } + + // Store field errors for this row const fieldErrors: Record = {}; let hasErrors = false; - - // Set default values for tax_cat and ship_restrictions if not already set - if (row.tax_cat === undefined || row.tax_cat === null || row.tax_cat === '') { - newData[rowIndex] = { - ...newData[rowIndex], - tax_cat: '0' - } as RowData; - } - if (row.ship_restrictions === undefined || row.ship_restrictions === null || row.ship_restrictions === '') { - newData[rowIndex] = { - ...newData[rowIndex], - ship_restrictions: '0' - } as RowData; - } - - // Process price fields efficiently - use a single check for both fields + // Check if price fields need formatting const rowAsRecord = row as Record; - const mSrpNeedsProcessing = typeof rowAsRecord.msrp === 'string' && rowAsRecord.msrp.includes('$'); - const costEachNeedsProcessing = typeof rowAsRecord.cost_each === 'string' && rowAsRecord.cost_each.includes('$'); + let mSrpNeedsProcessing = false; + let costEachNeedsProcessing = false; + if (rowAsRecord.msrp && typeof rowAsRecord.msrp === 'string' && + (rowAsRecord.msrp.includes('$') || rowAsRecord.msrp.includes(','))) { + mSrpNeedsProcessing = true; + } + + if (rowAsRecord.cost_each && typeof rowAsRecord.cost_each === 'string' && + (rowAsRecord.cost_each.includes('$') || rowAsRecord.cost_each.includes(','))) { + costEachNeedsProcessing = true; + } + + // Process price fields if needed if (mSrpNeedsProcessing || costEachNeedsProcessing) { // Create a clean copy only if needed const cleanedRow = {...row} as Record; @@ -1272,7 +1477,7 @@ export const useValidationState = ({ newData[rowIndex] = cleanedRow as RowData; } - // Only validate required fields for efficiency + // Validate required fields for (const field of requiredFields) { const key = String(field.key); const value = row[key as keyof typeof row]; @@ -1293,99 +1498,90 @@ export const useValidationState = ({ } } - // Special validation for supplier and company - if (hasSupplierField && !row.supplier) { - fieldErrors['supplier'] = [{ - message: 'Supplier is required', - level: 'error', - source: ErrorSources.Row, - type: ErrorType.Required - }]; - hasErrors = true; + // Validate regex fields - even if they have data + for (const field of regexFields) { + const key = String(field.key); + const value = row[key as keyof typeof row]; + + // Skip empty values as they're handled by required validation + if (value === undefined || value === null || value === '') { + continue; + } + + // Find regex validation + const regexValidation = field.validations?.find(v => v.rule === 'regex'); + if (regexValidation) { + try { + // Check if value matches regex + const regex = new RegExp(regexValidation.value, regexValidation.flags); + if (!regex.test(String(value))) { + // Add regex validation error + fieldErrors[key] = [{ + message: regexValidation.errorMessage, + level: regexValidation.level || 'error', + source: ErrorSources.Row, + type: ErrorType.Regex + }]; + hasErrors = true; + } + } catch (error) { + console.error('Invalid regex in validation:', error); + } + } } - if (hasCompanyField && !row.company) { - fieldErrors['company'] = [{ - message: 'Company is required', - level: 'error', - source: ErrorSources.Row, - type: ErrorType.Required - }]; - hasErrors = true; + // Update validation errors for this row + if (hasErrors) { + validationErrorsTemp.set(rowIndex, fieldErrors); } - - // Only add errors if there are any - if (Object.keys(fieldErrors).length > 0) { - batchErrors.set(rowIndex, fieldErrors); - } - - // Update row validation status - batchStatuses.set(rowIndex, hasErrors ? 'error' : 'validated'); resolve(); }) ); } - // Process all promises in the batch - Promise.all(batchPromises).then(() => { - // Measure batch completion time - const batchEndTime = performance.now(); - const processingTime = batchEndTime - batchStartTime; - - // Update UI state for this batch more efficiently - if (batchErrors.size > 0) { - setValidationErrors(prev => { - const newMap = new Map(prev); - for (const [rowIndex, errors] of batchErrors.entries()) { - newMap.set(rowIndex, errors); - } - return newMap; - }); - } - - if (batchStatuses.size > 0) { - setRowValidationStatus(prev => { - const newMap = new Map(prev); - for (const [rowIndex, status] of batchStatuses.entries()) { - newMap.set(rowIndex, status); - } - return newMap; - }); - } - - // Move to the next batch or finish - currentBatch++; - - // Log progress - console.log(`Batch ${currentBatch}/${totalBatches} completed in ${processingTime.toFixed(2)}ms`); - - if (currentBatch < totalBatches) { - // Process next batch immediately without delay - processBatch(); - } else { - // All batches processed, update the data once - setData(newData); - console.log('Basic validation complete'); - initialValidationDoneRef.current = true; - - // Run item number uniqueness validation after basic validation - validateUniqueItemNumbers(); - } - }); + // Wait for all row validations to complete + await Promise.all(batchPromises); }; - // Start processing the first batch - processBatch(); + const processAllBatches = async () => { + for (let batch = 0; batch < totalBatches; batch++) { + currentBatch = batch; + await processBatch(); + + // Yield to UI thread periodically + if (batch % 2 === 1) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + + // All batches complete + console.log('All initial validation batches complete'); + + // Apply collected validation errors all at once + setValidationErrors(validationErrorsTemp); + + // Apply any data changes (like price formatting) + if (JSON.stringify(data) !== JSON.stringify(newData)) { + setData(newData); + } + + // Run uniqueness validation after the basic validation + validateUniqueItemNumbers(); + + // Mark that initial validation is done + initialValidationDoneRef.current = true; + + console.log('Initial validation complete'); + }; + + // Start the validation process + processAllBatches(); }; - - // Function to perform UPC validations asynchronously - - // Run basic validations immediately to update UI - runBasicValidation(); - - initialValidationDoneRef.current = true; - }, [data, fields, validateField, fetchProductByUpc]); + + // Run the complete validation + runCompleteValidation(); + }, [data, fields, setData, setValidationErrors]); // Update fields with latest options const fieldsWithOptions = useMemo(() => { @@ -1453,7 +1649,60 @@ export const useValidationState = ({ useEffect(() => { loadTemplates(); }, [loadTemplates]); - + + // Watch for UPC/barcode and supplier changes to trigger validation + useEffect(() => { + // Skip during initial load + if (!initialValidationDoneRef.current) return; + + // Skip template application + if (isApplyingTemplateRef.current) return; + + // Skip if we're already processing + if (isProcessingBatchRef.current) return; + + // Look for rows with both UPC/barcode and supplier that need validation + const rowsToValidate = data.map((row, index) => { + const upc = row.upc || row.barcode; + const supplier = row.supplier; + + if (upc && supplier) { + // Check if this combination is already in the cache + const cacheKey = `${supplier}-${upc}`; + if (!processedUpcMapRef.current.has(cacheKey)) { + return { index, upc, supplier }; + } + } + return null; + }).filter(Boolean); + + // If we have rows to validate, queue them up + if (rowsToValidate.length > 0) { + console.log(`Found ${rowsToValidate.length} rows needing UPC validation`); + + // Add each row to the validation queue + for (const row of rowsToValidate) { + if (row) { + const { index, upc, supplier } = row; + validationQueueRef.current.push({ + rowIndex: index, + supplierId: String(supplier), + upcValue: String(upc) + }); + + // Mark as validating + setValidatingUpcRows(prev => { + if (prev.includes(index)) return prev; + return [...prev, index]; + }); + } + } + + // Process the queue + debouncedProcessBatch(); + } + }, [data, debouncedProcessBatch]); + return { // Data data, @@ -1513,5 +1762,6 @@ export const useValidationState = ({ // Button handling handleButtonClick, upcValidationResults, + revalidateRows, } } \ No newline at end of file