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 5ffaa20..a9496d3 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 @@ -141,13 +141,26 @@ const ItemNumberCell = React.memo(({ field: Field, onChange: (value: any) => void }) => { + // Determine if the field has an error const hasError = errors.some(error => error.level === 'error' || error.level === 'warning'); - const isRequiredButEmpty = errors.some(error => error.level === 'required' && (!value || value.trim() === '')); - const nonRequiredErrors = errors.filter(error => error.level !== 'required'); + + // Determine if the field is required but empty + const isRequiredButEmpty = errors.some(error => + error.message?.toLowerCase().includes('required') && + (!value || (typeof value === 'string' && value.trim() === '')) + ); + + // Only show error icons for non-empty fields + const shouldShowErrorIcon = hasError && value && (typeof value !== 'string' || value.trim() !== ''); + + // Get error messages for the tooltip + const errorMessages = shouldShowErrorIcon + ? errors.filter(e => e.level === 'error' || e.level === 'warning').map(e => e.message).join('\n') + : ''; return ( -
+
{isValidating ? (
@@ -164,10 +177,10 @@ const ItemNumberCell = React.memo(({ />
)} - {nonRequiredErrors.length > 0 && ( + {shouldShowErrorIcon && (
e.message).join('\n'), + message: errorMessages, level: 'error' }} />
@@ -209,21 +222,27 @@ const ValidationCell = ({ ); } - // Error states + // Determine if the field has an error const hasError = errors.some(error => error.level === 'error' || error.level === 'warning'); - const isRequiredButEmpty = errors.some(error => { - if (error.level !== 'required') return false; - - // Handle different value types - if (Array.isArray(value)) { - return value.length === 0; - } - if (typeof value === 'string') { - return !value || value.trim() === ''; - } - return value === undefined || value === null; - }); - const nonRequiredErrors = errors.filter(error => error.level !== 'required'); + + // Determine if the field is required but empty + const isRequiredButEmpty = errors.some(error => + error.message?.toLowerCase().includes('required') && + (value === undefined || value === null || + (typeof value === 'string' && value.trim() === '') || + (Array.isArray(value) && value.length === 0)) + ); + + // Only show error icons for non-empty fields + const shouldShowErrorIcon = hasError && + !(value === undefined || value === null || + (typeof value === 'string' && value.trim() === '') || + (Array.isArray(value) && value.length === 0)); + + // Get error messages for the tooltip + const errorMessages = shouldShowErrorIcon + ? errors.filter(e => e.level === 'error' || e.level === 'warning').map(e => e.message).join('\n') + : ''; // Check if this is a multiline field const isMultiline = typeof field.fieldType === 'object' && @@ -235,7 +254,7 @@ const ValidationCell = ({ return ( -
+
@@ -248,10 +267,10 @@ const ValidationCell = ({ />
- {nonRequiredErrors.length > 0 && ( + {shouldShowErrorIcon && (
e.message).join('\n'), + message: errorMessages, level: 'error' }} />
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 c81d35a..1f578fb 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 @@ -562,17 +562,51 @@ const ValidationContainer = ({ onNext?.(data) }, [onNext, data, applyItemNumbersToData]); - // Delete selected rows - memoized const deleteSelectedRows = useCallback(() => { + // Get selected row indices const selectedRowIndexes = Object.keys(rowSelection).map(Number); - const newData = data.filter((_, index) => !selectedRowIndexes.includes(index)); + + if (selectedRowIndexes.length === 0) { + toast.error("No rows selected"); + return; + } + + // Sort indices in descending order to avoid index shifting during removal + const sortedIndices = [...selectedRowIndexes].sort((a, b) => b - a); + + // Create a new array without the selected rows + const newData = [...data]; + + // Remove rows from bottom up to avoid index issues + sortedIndices.forEach(index => { + if (index >= 0 && index < newData.length) { + newData.splice(index, 1); + } + }); + + // Update the data with rows removed setData(newData); + + // Clear row selection setRowSelection({}); + + // Show success message toast.success( selectedRowIndexes.length === 1 ? "Row deleted" : `${selectedRowIndexes.length} rows deleted` ); + + // Reindex the data in the next render cycle + requestAnimationFrame(() => { + // Update indices to maintain consistency + setData(current => + current.map((row, newIndex) => ({ + ...row, + __index: String(newIndex) + })) + ); + }); }, [data, rowSelection, setData, setRowSelection]); // Enhanced ValidationTable component that's aware of item numbers 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 2105054..c853bf4 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 @@ -85,6 +85,9 @@ export const useValidationState = ({ onNext}: ValidationStateProps) => { const { fields, rowHook, tableHook } = useRsi(); + // Add ref to track template application state + const isApplyingTemplateRef = useRef(false); + // Core data state const [data, setData] = useState[]>(() => { // Clean price fields in initial data before setting state @@ -182,7 +185,7 @@ export const useValidationState = ({ }) // UPC validation state - const [validatingUpcRows] = useState([]) + const [validatingUpcRows, setValidatingUpcRows] = useState([]) const [upcValidationResults, setUpcValidationResults] = useState>(new Map()) // Create a UPC cache to prevent duplicate API calls @@ -190,7 +193,6 @@ export const useValidationState = ({ const initialValidationDoneRef = useRef(false); // Add debounce timer ref for item number validation - const itemNumberValidationTimerRef = useRef(null); // Function to validate uniqueness of item numbers across the entire table const validateItemNumberUniqueness = useCallback(() => { @@ -290,65 +292,64 @@ export const useValidationState = ({ } }, [data, validationErrors]); - // Effect to trigger validation when UPC results change - useEffect(() => { - if (upcValidationResults.size === 0) return; - - // Create a single batch update for all changes - const updatedData = [...data]; - const updatedStatus = new Map(rowValidationStatus); - const updatedErrors = new Map(validationErrors); - let hasChanges = false; - - upcValidationResults.forEach((result, rowIndex) => { - if (result.itemNumber && updatedData[rowIndex]) { - // Only update if the item number has actually changed - if (updatedData[rowIndex].item_number !== result.itemNumber) { - hasChanges = true; - updatedData[rowIndex] = { - ...updatedData[rowIndex], - item_number: result.itemNumber - }; - - updatedStatus.set(rowIndex, 'pending'); - - const rowErrors = updatedErrors.get(rowIndex) || {}; - delete rowErrors['item_number']; + // Effect to update data when UPC validation results change +useEffect(() => { + if (upcValidationResults.size === 0) return; + + // Save scroll position + const scrollPosition = { + left: window.scrollX, + top: window.scrollY + }; + + // Process all updates in a single batch + const updatedData = [...data]; + const updatedErrors = new Map(validationErrors); + const updatedStatus = new Map(rowValidationStatus); + let hasChanges = false; + + upcValidationResults.forEach((result, rowIndex) => { + if (result.itemNumber && updatedData[rowIndex]) { + // Only update if the item number has actually changed + if (updatedData[rowIndex].item_number !== result.itemNumber) { + hasChanges = true; + + // Update item number + updatedData[rowIndex] = { + ...updatedData[rowIndex], + item_number: result.itemNumber + }; + + // Clear item_number errors + const rowErrors = {...(updatedErrors.get(rowIndex) || {})}; + delete rowErrors.item_number; + + if (Object.keys(rowErrors).length > 0) { updatedErrors.set(rowIndex, rowErrors); + } else { + updatedStatus.set(rowIndex, 'validated'); } } - }); - - // Only update state if there were actual changes - if (hasChanges) { - // Clean price fields before updating - const cleanedData = cleanPriceFields(updatedData); - - // Save current scroll position before updating - const scrollPosition = { - left: window.scrollX, - top: window.scrollY - }; - - setData(cleanedData); - setRowValidationStatus(updatedStatus); - setValidationErrors(updatedErrors); - - // Validate uniqueness after a short delay to allow UI to update - // Use requestAnimationFrame for better performance - if (itemNumberValidationTimerRef.current !== null) { - cancelAnimationFrame(itemNumberValidationTimerRef.current); - } - - itemNumberValidationTimerRef.current = requestAnimationFrame(() => { - // Restore scroll position - window.scrollTo(scrollPosition.left, scrollPosition.top); - - validateItemNumberUniqueness(); - itemNumberValidationTimerRef.current = null; - }); } - }, [upcValidationResults, validateItemNumberUniqueness, data, rowValidationStatus, validationErrors, cleanPriceFields]); + }); + + // Only update state if there were changes + if (hasChanges) { + // Apply all updates + setData(updatedData); + setValidationErrors(updatedErrors); + setRowValidationStatus(updatedStatus); + + // Validate uniqueness after state updates + requestAnimationFrame(() => { + // Restore scroll position + window.scrollTo(scrollPosition.left, scrollPosition.top); + + // Check for duplicate item numbers + validateItemNumberUniqueness(); + }); + } +}, [upcValidationResults, data, validationErrors, rowValidationStatus, validateItemNumberUniqueness]); // Fetch product by UPC from API - optimized with proper error handling and types const fetchProductByUpc = useCallback(async (supplier: string, upc: string): Promise => { @@ -439,7 +440,6 @@ export const useValidationState = ({ } }, []); - // Validate a UPC code - optimized for minimal rendering impact const validateUpc = useCallback(async (rowIndex: number, supplierId: string, upcValue: string): Promise<{ success: boolean, itemNumber?: string }> => { try { // Skip if either value is missing @@ -447,77 +447,139 @@ export const useValidationState = ({ return { success: false }; } - // Check if we've already validated this UPC/supplier combination + // Mark this row as being validated + setValidatingUpcRows((prev: number[]) => { + return [...prev, rowIndex]; + }); + + // Check cache first const cacheKey = `${supplierId}-${upcValue}`; if (processedUpcMapRef.current.has(cacheKey)) { const cachedItemNumber = processedUpcMapRef.current.get(cacheKey); if (cachedItemNumber) { - // Update data directly with the cached item number + // Update with cached item number setUpcValidationResults(prev => { const newResults = new Map(prev); newResults.set(rowIndex, { itemNumber: cachedItemNumber }); return newResults; }); + // Remove from validating state + setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex)); + return { success: true, itemNumber: cachedItemNumber }; } + // Remove from validating state + setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex)); return { success: false }; } // Make API call to validate UPC - const result = await fetchProductByUpc(supplierId, upcValue); + const apiResult = await fetchProductByUpc(supplierId, upcValue); - if (result.error) { + // Remove from validating state now that call is complete + setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex)); + + if (apiResult.error) { // Handle error case - if (result.message && result.message.includes('already exists') && result.data?.itemNumber) { + if (apiResult.message && apiResult.message.includes('already exists') && apiResult.data?.itemNumber) { // UPC already exists - update with existing item number + processedUpcMapRef.current.set(cacheKey, apiResult.data.itemNumber); + setUpcValidationResults(prev => { const newResults = new Map(prev); - newResults.set(rowIndex, { itemNumber: result.data!.itemNumber }); + if (apiResult.data?.itemNumber) { + newResults.set(rowIndex, { itemNumber: apiResult.data.itemNumber }); + } return newResults; }); - return { success: true, itemNumber: result.data.itemNumber }; + return { success: true, itemNumber: apiResult.data.itemNumber }; } else { // Other error - show validation error setValidationErrors(prev => { const newErrors = new Map(prev); - const rowErrors = newErrors.get(rowIndex) || {}; - const fieldKey = 'upc'; + const rowErrors = {...(newErrors.get(rowIndex) || {})}; - newErrors.set(rowIndex, { - ...rowErrors, - [fieldKey]: [{ - message: result.message || 'Invalid UPC', + rowErrors.upc = [{ + message: apiResult.message || 'Invalid UPC', + level: 'error', + source: 'validation' + }]; + + newErrors.set(rowIndex, rowErrors); + return newErrors; + }); + + // Update data errors too + setData(prevData => { + const newData = [...prevData]; + + if (newData[rowIndex]) { + const rowErrors = {...(newData[rowIndex].__errors || {})}; + + rowErrors.upc = [{ + message: apiResult.message || 'Invalid UPC', level: 'error', source: 'validation' - }] - }); + }]; + + newData[rowIndex] = { + ...newData[rowIndex], + __errors: rowErrors + }; + } - return newErrors; + return newData; }); return { success: false }; } - } else if (result.data && result.data.itemNumber) { + } else if (apiResult.data && apiResult.data.itemNumber) { // Success case - update with new item number + processedUpcMapRef.current.set(cacheKey, apiResult.data.itemNumber); + setUpcValidationResults(prev => { const newResults = new Map(prev); - newResults.set(rowIndex, { itemNumber: result.data!.itemNumber }); + if (apiResult.data?.itemNumber) { + newResults.set(rowIndex, { itemNumber: apiResult.data.itemNumber }); + } return newResults; }); - return { success: true, itemNumber: result.data.itemNumber }; + // Clear UPC errors + setValidationErrors(prev => { + const newErrors = new Map(prev); + const rowErrors = {...(newErrors.get(rowIndex) || {})}; + + if ('upc' in rowErrors) { + delete rowErrors.upc; + } + + if (Object.keys(rowErrors).length > 0) { + newErrors.set(rowIndex, rowErrors); + } else { + newErrors.delete(rowIndex); + } + + return newErrors; + }); + + return { success: true, itemNumber: apiResult.data.itemNumber }; } return { success: false }; } catch (error) { console.error(`Error validating UPC for row ${rowIndex}:`, error); + + // Remove from validating state on error + setValidatingUpcRows((prev: number[]) => prev.filter((idx: number) => idx !== rowIndex)); + return { success: false }; } - }, [fetchProductByUpc]); + }, [fetchProductByUpc, setValidatingUpcRows, setUpcValidationResults, setValidationErrors, setData]); // Track which cells are currently being validated - allows targeted re-rendering const isValidatingUpc = useCallback((rowIndex: number) => { @@ -607,63 +669,156 @@ export const useValidationState = ({ }) }, []) - // Validate a single field against its validation rules + // Helper function to validate a field value const validateField = useCallback((value: any, field: Field): ErrorType[] => { const errors: ErrorType[] = []; - // Skip validation for disabled fields - if (field.disabled) return errors; - - // Process value for price fields before validation - let processedValue = value; - const isPrice = typeof field.fieldType === 'object' && - (field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') && - field.fieldType.price === true; - - if (isPrice && typeof value === 'string') { - processedValue = value.replace(/[$,]/g, ''); + // Required field validation - improved to better handle various value types + if (field.validations?.some(v => v.type === 'required')) { + // Handle different value types more carefully + const isEmpty = + value === undefined || + value === null || + (typeof value === 'string' && value.trim() === '') || + (Array.isArray(value) && value.length === 0); + + if (isEmpty) { + errors.push({ + message: 'Required field', + level: 'error', + source: 'required' + }); + // Return early since other validations may not be relevant for empty values + return errors; + } } - // Check each validation rule - field.validations?.forEach(validation => { - // Skip if already has an error for this rule - if (errors.some(e => e.message === validation.errorMessage)) return; - - const rule = validation.rule; - - // Required validation - if (rule === 'required') { - if (processedValue === undefined || processedValue === null || processedValue === '') { - errors.push({ - message: validation.errorMessage || 'This field is required', - level: 'required', // Use 'required' level to distinguish from other errors - source: 'required' - }); + // Continue with other validations if the required check passed + if (field.validations) { + for (const validation of field.validations) { + // Skip required validation as we've already handled it + if (validation.type === 'required') continue; + + if (validation.type === 'regex' && typeof value === 'string') { + // Implement regex validation + const regex = new RegExp(validation.pattern!); + if (!regex.test(value)) { + errors.push({ + message: validation.message || 'Invalid format', + level: validation.level || 'error', + source: 'validation' + }); + } + } else if (validation.type === 'min' && typeof value === 'number') { + // Implement min validation + if (value < validation.value) { + errors.push({ + message: validation.message || `Value must be at least ${validation.value}`, + level: validation.level || 'error', + source: 'validation' + }); + } + } else if (validation.type === 'max' && typeof value === 'number') { + // Implement max validation + if (value > validation.value) { + errors.push({ + message: validation.message || `Value must be at most ${validation.value}`, + level: validation.level || 'error', + source: 'validation' + }); + } } + // Add other validation types as needed } - - // Skip other validations if value is empty and not required - if (processedValue === undefined || processedValue === null || processedValue === '') return; - - // Regex validation - if (rule === 'regex' && validation.value) { - const regex = new RegExp(validation.value); - if (!regex.test(String(processedValue))) { - errors.push({ - message: validation.errorMessage || 'Invalid format', - level: validation.level || 'error', - source: 'validation' - }); - } - } - - // Unique validation is handled separately in batch processing - }); + } return errors; }, []); - // Now, let's update the updateRow function to trigger validation after updating data + // Validate a single row + const validateRow = useCallback((rowIndex: number) => { + // Skip if row doesn't exist or if we're in the middle of applying a template + if (rowIndex < 0 || rowIndex >= data.length) return; + + // Skip validation if we're applying a template + if (isApplyingTemplateRef.current) { + return true; + } + + const row = data[rowIndex]; + const fieldErrors: Record = {}; + let hasErrors = false; + + // Ensure values are trimmed for proper validation + const cleanedRow = { ...row }; + Object.entries(cleanedRow).forEach(([key, value]) => { + if (typeof value === 'string') { + (cleanedRow as any)[key] = value.trim(); + } + }); + + // Validate each field + fields.forEach(field => { + if (field.disabled) return; + const key = String(field.key); + const value = cleanedRow[key as keyof typeof cleanedRow]; + const errors = validateField(value, field as Field); + if (errors.length > 0) { + fieldErrors[key] = errors; + hasErrors = true; + } + }); + + // Special validation for supplier and company - check existence and non-emptiness + if (!cleanedRow.supplier || (typeof cleanedRow.supplier === 'string' && cleanedRow.supplier.trim() === '')) { + fieldErrors['supplier'] = [{ + message: 'Supplier is required', + level: 'error', + source: 'required' + }]; + hasErrors = true; + } + + if (!cleanedRow.company || (typeof cleanedRow.company === 'string' && cleanedRow.company.trim() === '')) { + fieldErrors['company'] = [{ + message: 'Company is required', + level: 'error', + source: 'required' + }]; + hasErrors = true; + } + + // Update validation state + setValidationErrors(prev => { + const newErrors = new Map(prev); + if (hasErrors) { + newErrors.set(rowIndex, fieldErrors); + } else { + newErrors.delete(rowIndex); + } + return newErrors; + }); + + setRowValidationStatus(prev => { + const newStatus = new Map(prev); + newStatus.set(rowIndex, hasErrors ? 'error' : 'validated'); + return newStatus; + }); + + // Update the row data with errors + setData(prev => { + const newData = [...prev]; + newData[rowIndex] = { + ...newData[rowIndex], + __errors: hasErrors ? fieldErrors : undefined + }; + return newData; + }); + + return !hasErrors; + }, [data, fields, validateField, setValidationErrors, setRowValidationStatus, setData]); + + // Update a single row's field value const updateRow = useCallback((rowIndex: number, key: T, value: any) => { // Process value before updating data let processedValue = value; @@ -685,55 +840,28 @@ export const useValidationState = ({ top: window.scrollY }; - // Update the data immediately for responsive UI + // Update the data immediately for UI responsiveness setData(prevData => { const newData = [...prevData]; - // Create a deep copy of the row to avoid reference issues - const row = JSON.parse(JSON.stringify(newData[rowIndex])); + // Create a copy of the row to avoid reference issues + const updatedRow = { ...newData[rowIndex] }; // Update the field value - row[key] = processedValue; - - // Mark row as needing validation - setRowValidationStatus(prev => { - const updated = new Map(prev); - updated.set(rowIndex, 'pending'); - return updated; - }); + updatedRow[key] = processedValue; // Update the row in the data array - newData[rowIndex] = row as RowData; + newData[rowIndex] = updatedRow as RowData; // Clean all price fields to ensure consistency - return newData.map(dataRow => { - if (dataRow === row) return row as RowData; - - const updatedRow = { ...dataRow } as Record; - let needsUpdate = false; - - // Clean MSRP - if (typeof updatedRow.msrp === 'string' && updatedRow.msrp.includes('$')) { - updatedRow.msrp = updatedRow.msrp.replace(/[$,]/g, ''); - const numValue = parseFloat(updatedRow.msrp); - if (!isNaN(numValue)) { - updatedRow.msrp = numValue.toFixed(2); - } - needsUpdate = true; - } - - // Clean cost_each - if (typeof updatedRow.cost_each === 'string' && updatedRow.cost_each.includes('$')) { - updatedRow.cost_each = updatedRow.cost_each.replace(/[$,]/g, ''); - const numValue = parseFloat(updatedRow.cost_each); - if (!isNaN(numValue)) { - updatedRow.cost_each = numValue.toFixed(2); - } - needsUpdate = true; - } - - return needsUpdate ? (updatedRow as RowData) : dataRow; - }); + return cleanPriceFields(newData); + }); + + // Mark row as pending validation + setRowValidationStatus(prev => { + const updated = new Map(prev); + updated.set(rowIndex, 'pending'); + return updated; }); // Restore scroll position after update @@ -741,211 +869,22 @@ export const useValidationState = ({ window.scrollTo(scrollPosition.left, scrollPosition.top); }); - // Debounce validation to avoid excessive processing + // Validate the row after the update setTimeout(() => { - const field = fields.find(f => f.key === key); - if (field) { - // Get current errors for the row - const currentRowErrors = new Map(validationErrors); - const rowErrorsMap = currentRowErrors.get(rowIndex) || {}; + validateRow(rowIndex); + + // Trigger UPC validation if applicable + if ((key === 'upc' || key === 'supplier') && data[rowIndex]) { + const row = data[rowIndex]; + const upcValue = key === 'upc' ? processedValue : row.upc; + const supplierValue = key === 'supplier' ? processedValue : row.supplier; - // Validate just this field - const fieldErrors = validateField(value, field as unknown as Field); - - // Only update if errors have changed - const currentFieldErrors = rowErrorsMap[key] || []; - const errorsChanged = - fieldErrors.length !== currentFieldErrors.length || - JSON.stringify(fieldErrors) !== JSON.stringify(currentFieldErrors); - - if (errorsChanged) { - // Update the errors for this field - const updatedRowErrors = { - ...rowErrorsMap, - [key]: fieldErrors - }; - - // Update the validation errors - currentRowErrors.set(rowIndex, updatedRowErrors); - setValidationErrors(currentRowErrors); - - // Also update __errors in the data row - setData(prevData => { - const newData = [...prevData]; - const row = { ...newData[rowIndex], __errors: updatedRowErrors }; - newData[rowIndex] = row as RowData; - return newData; - }); - } - - // If this is a UPC or supplier field and both have values, validate UPC - if ((key === 'upc' || key === 'supplier') && data[rowIndex]) { - const currentRow = data[rowIndex]; - // Use type assertion for accessing potentially undefined properties - const upcValue = key === 'upc' ? value : (currentRow as Record)['upc']; - const supplierValue = key === 'supplier' ? value : (currentRow as Record)['supplier']; - - if (upcValue && supplierValue) { - validateUpc(rowIndex, supplierValue, String(upcValue)); - } - } - - // Check for duplicate item numbers with debouncing - if (key === 'item_number' && value) { - // Cancel any pending validation - if (itemNumberValidationTimerRef.current !== null) { - cancelAnimationFrame(itemNumberValidationTimerRef.current); - } - - // Schedule validation for next frame - itemNumberValidationTimerRef.current = requestAnimationFrame(() => { - validateItemNumberUniqueness(); - itemNumberValidationTimerRef.current = null; - }); + if (upcValue && supplierValue) { + validateUpc(rowIndex, String(supplierValue), String(upcValue)); } } - }, 100); // Small delay to batch updates - }, [data, fields, validateField, validationErrors, validateUpc, validateItemNumberUniqueness]); - - // Validate a single row - optimized version - const validateRow = useCallback(async (rowIndex: number) => { - if (!data[rowIndex]) return; - - // Update row status to validating - setRowValidationStatus(prev => { - const updated = new Map(prev); - updated.set(rowIndex, 'validating'); - return updated; - }); - - // Use a microtask to prevent UI blocking - await new Promise(resolve => setTimeout(resolve, 0)); - - try { - // Collect field errors - const fieldErrors: Record = {}; - - // Basic field validations - for (let j = 0; j < fields.length; j++) { - const field = fields[j]; - const key = String(field.key); - const value = data[rowIndex][key as keyof typeof data[typeof rowIndex]]; - - // Skip validation for disabled fields - if (field.disabled) { - continue; - } - - // Use type assertion to address readonly fields issue - const errors = validateField(value, field as Field); - if (errors.length > 0) { - fieldErrors[key] = errors; - } - } - - // Special validation for supplier and company - if (!data[rowIndex].supplier) { - fieldErrors['supplier'] = [{ - message: 'Supplier is required', - level: 'error', - source: 'required' - }]; - } - - if (!data[rowIndex].company) { - fieldErrors['company'] = [{ - message: 'Company is required', - level: 'error', - source: 'required' - }]; - } - - // Batch update all state at once to minimize re-renders - const newStatus = new Map(rowValidationStatus); - newStatus.set(rowIndex, Object.keys(fieldErrors).length > 0 ? 'error' : 'validated'); - - const newErrors = new Map(validationErrors); - newErrors.set(rowIndex, fieldErrors); - - // Use functional updates to ensure we have the latest state - setData(prevData => { - const newData = [...prevData]; - newData[rowIndex] = { - ...newData[rowIndex], - __errors: fieldErrors - }; - return newData; - }); - - setValidationErrors(newErrors); - setRowValidationStatus(newStatus); - - } catch (error) { - console.error('Error validating row:', error); - - // Update row status to error - setRowValidationStatus(prev => { - const updated = new Map(prev); - updated.set(rowIndex, 'error'); - return updated; - }); - } - }, [data, fields, validateField, rowValidationStatus, validationErrors]); - - // Validate all rows - optimized version with batching for better performance - - // Load templates - const loadTemplates = useCallback(async () => { - try { - setIsLoadingTemplates(true); - console.log('Fetching templates from:', `${getApiUrl()}/templates`); - // Fetch templates from the API - const response = await fetch(`${getApiUrl()}/templates`) - console.log('Templates response:', { - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()) - }); - - if (!response.ok) throw new Error('Failed to fetch templates') - - const templateData = await response.json() - console.log('Templates response data:', templateData); - - // Validate template data - const validTemplates = templateData.filter((t: any) => - t && typeof t === 'object' && t.id && t.company && t.product_type - ); - - console.log('Valid templates:', { - total: templateData.length, - valid: validTemplates.length, - templates: validTemplates - }); - - if (validTemplates.length !== templateData.length) { - console.warn('Some templates were filtered out due to invalid data', { - original: templateData.length, - valid: validTemplates.length, - filtered: templateData.filter((t: any) => - !(t && typeof t === 'object' && t.id && t.company && t.product_type) - ) - }); - } - - setTemplates(validTemplates) - } catch (error) { - console.error('Error fetching templates:', error) - toast.error('Failed to load templates') - } finally { - setIsLoadingTemplates(false); - } - }, []) - - // Load templates on mount - useEffect(() => { - loadTemplates(); - }, [loadTemplates]); + }, 50); + }, [data, validateRow, validateUpc, setData, setRowValidationStatus, cleanPriceFields]); // Save a new template const saveTemplate = useCallback(async (name: string, type: string) => { @@ -1031,98 +970,124 @@ export const useValidationState = ({ toast.error(error instanceof Error ? error.message : 'Failed to save template') } }, [data, rowSelection, setData]); - - // Apply a template to selected rows + + // Apply template to rows const applyTemplate = useCallback((templateId: string, rowIndexes: number[]) => { - const template = templates.find(t => t.id.toString() === templateId) + const template = templates.find(t => t.id.toString() === templateId); if (!template) { - toast.error('Template not found') - return + toast.error('Template not found'); + return; } + // Set the template application flag + isApplyingTemplateRef.current = true; + + // Save scroll position + const scrollPosition = { + left: window.scrollX, + top: window.scrollY + }; + + // Track updated rows + const updatedRows: number[] = []; + + // Apply template to data - capture the updated data to use for validation + let updatedData: RowData[] = []; + setData(prevData => { - const newData = [...prevData] + const newData = [...prevData]; rowIndexes.forEach(index => { if (index >= 0 && index < newData.length) { // Create a new row with template values - const updatedRow = { ...newData[index] } + const updatedRow = { ...newData[index] }; - // Apply template fields (excluding metadata and ID fields) + // Clear existing errors and validation status + delete updatedRow.__errors; + + // Apply template fields (excluding metadata fields) Object.entries(template).forEach(([key, value]) => { if (!['id', '__errors', '__meta', '__template', '__original', '__corrected', '__changes'].includes(key)) { - (updatedRow as any)[key] = value + (updatedRow as any)[key] = value; } - }) + }); // Mark the row as using this template - updatedRow.__template = templateId + updatedRow.__template = templateId; // Update the row in the data array - newData[index] = updatedRow + newData[index] = updatedRow; + + // Track which rows were updated + updatedRows.push(index); } - }) + }); - return newData - }) + // Store the updated data for validation + updatedData = [...newData]; + + return newData; + }); - // Only show toast for crucial operations, not validation + // Clear validation errors and status for affected rows + setValidationErrors(prev => { + const newErrors = new Map(prev); + rowIndexes.forEach(index => { + newErrors.delete(index); + }); + return newErrors; + }); + + setRowValidationStatus(prev => { + const newStatus = new Map(prev); + rowIndexes.forEach(index => { + newStatus.set(index, 'validated'); // Mark as validated immediately + }); + return newStatus; + }); + + // Restore scroll position + requestAnimationFrame(() => { + window.scrollTo(scrollPosition.left, scrollPosition.top); + }); + + // Show success toast if (rowIndexes.length === 1) { toast.success('Template applied'); } else if (rowIndexes.length > 1) { toast.success(`Template applied to ${rowIndexes.length} rows`); } - // Process validation in batches to prevent UI freezing - // For a small number of rows, validate immediately - if (rowIndexes.length <= 5) { - rowIndexes.forEach(index => { - validateRow(index); - - // Also check for UPC validation - use type assertion - const currentRow = data[index]; - if (currentRow) { - const upcValue = (currentRow as Record)['upc']; - const supplierValue = (currentRow as Record)['supplier']; - if (upcValue && supplierValue) { - validateUpc(index, supplierValue, String(upcValue)); - } - } - }); - } else { - // For many rows, use a batched approach to keep UI responsive - const BATCH_SIZE = 5; - const processBatch = (startIndex: number) => { - const endIndex = Math.min(startIndex + BATCH_SIZE, rowIndexes.length); - - for (let i = startIndex; i < endIndex; i++) { - const rowIndex = rowIndexes[i]; - validateRow(rowIndex); + // Schedule UPC validation with a delay + setTimeout(() => { + // Process rows in sequence to ensure validation state is consistent + const processRows = async () => { + for (const rowIndex of updatedRows) { + // Get the current row data after template application + const currentRow = updatedData[rowIndex]; - // Check for UPC validation - const currentRow = data[rowIndex]; - if (currentRow) { - const upcValue = (currentRow as Record)['upc']; - const supplierValue = (currentRow as Record)['supplier']; - if (upcValue && supplierValue) { - validateUpc(rowIndex, supplierValue, String(upcValue)); - } + // Check if UPC validation is needed + if (currentRow && currentRow.upc && currentRow.supplier) { + await validateUpc(rowIndex, String(currentRow.supplier), String(currentRow.upc)); + } + + // Small delay between rows to prevent overwhelming the UI + if (updatedRows.length > 1) { + await new Promise(resolve => setTimeout(resolve, 50)); } } - // Process next batch if more rows remain - if (endIndex < rowIndexes.length) { - setTimeout(() => processBatch(endIndex), 50); - } + // Reset the template application flag after all processing is done + isApplyingTemplateRef.current = false; }; - // Start processing batches - processBatch(0); - } - }, [templates, validateRow, validateUpc, data]); - - // Apply a template to selected rows + // Start processing rows + processRows(); + }, 500); + }, [templates, validateUpc, setData, setValidationErrors, setRowValidationStatus]); + + // Apply template to selected rows const applyTemplateToSelected = useCallback((templateId: string) => { if (!templateId) return; @@ -1135,10 +1100,15 @@ export const useValidationState = ({ // Get selected row indexes const selectedIndexes = Object.keys(rowSelection).map(i => parseInt(i)); + if (selectedIndexes.length === 0) { + toast.error('No rows selected'); + return; + } + // Apply template to selected rows applyTemplate(templateId, selectedIndexes); - }, [rowSelection, applyTemplate]); - + }, [rowSelection, applyTemplate, setTemplateState]); + // Add field options query const { data: fieldOptionsData } = useQuery({ queryKey: ["import-field-options"], @@ -1184,6 +1154,26 @@ export const useValidationState = ({ return false }, [rowValidationStatus]); + // Load templates + const loadTemplates = useCallback(async () => { + try { + setIsLoadingTemplates(true); + console.log('Fetching templates from:', `${getApiUrl()}/templates`); + const response = await fetch(`${getApiUrl()}/templates`); + if (!response.ok) throw new Error('Failed to fetch templates'); + const templateData = await response.json(); + const validTemplates = templateData.filter((t: any) => + t && typeof t === 'object' && t.id && t.company && t.product_type + ); + setTemplates(validTemplates); + } catch (error) { + console.error('Error fetching templates:', error); + toast.error('Failed to load templates'); + } finally { + setIsLoadingTemplates(false); + } + }, []); + // Add a refreshTemplates function const refreshTemplates = useCallback(() => { loadTemplates(); @@ -1362,7 +1352,7 @@ export const useValidationState = ({ // Function to perform UPC validations asynchronously const runUPCValidation = async () => { console.log('Starting UPC validation'); - + // Collect rows that need UPC validation const rowsWithUpc = []; for (let rowIndex = 0; rowIndex < data.length; rowIndex++) { @@ -1371,10 +1361,10 @@ export const useValidationState = ({ rowsWithUpc.push({ rowIndex, upc: row.upc, supplier: row.supplier }); } } - + console.log(`Found ${rowsWithUpc.length} rows with UPC and supplier`); const BATCH_SIZE = 3; - + for (let i = 0; i < rowsWithUpc.length; i += BATCH_SIZE) { const batch = rowsWithUpc.slice(i, i + BATCH_SIZE); await Promise.all(batch.map(async ({ rowIndex, upc, supplier }) => { @@ -1391,28 +1381,25 @@ export const useValidationState = ({ } return; } - + console.log(`Validating UPC: ${upc} for supplier: ${supplier}`); - const result = await fetchProductByUpc(supplier, upc); - if (result && result.data && result.data.itemNumber) { - const itemNumber = result.data.itemNumber; + const apiResult = await fetchProductByUpc(supplier, upc); + if (apiResult && !apiResult.error && apiResult.data?.itemNumber) { + const itemNumber = apiResult.data.itemNumber; processedUpcMapRef.current.set(cacheKey, itemNumber); setUpcValidationResults(prev => { - if (prev.get(rowIndex)?.itemNumber === itemNumber) { - return prev; - } const newResults = new Map(prev); newResults.set(rowIndex, { itemNumber }); return newResults; }); - } else if (result.error && result.message !== 'UPC not found') { + } else if (apiResult.error && apiResult.message !== 'UPC not found') { setValidationErrors(prev => { const newErrors = new Map(prev); const rowErrors = newErrors.get(rowIndex) || {}; newErrors.set(rowIndex, { ...rowErrors, upc: [{ - message: result.message || 'Invalid UPC', + message: apiResult.message || 'Invalid UPC', level: 'error', source: 'validation' }] @@ -1429,12 +1416,12 @@ export const useValidationState = ({ console.error('Error validating UPC:', error); } })); - + if (i + BATCH_SIZE < rowsWithUpc.length) { await new Promise(resolve => setTimeout(resolve, 100)); } } - + console.log('UPC validation complete'); }; @@ -1506,6 +1493,11 @@ export const useValidationState = ({ }); }, [fields, fieldOptionsData]); + // Load templates on mount + useEffect(() => { + loadTemplates(); + }, [loadTemplates]); + return { // Data data, diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts index 4e5f69a..2740741 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts @@ -137,7 +137,7 @@ export const validateSpecialFields = (row: Data): Record(row: Data): Record