From 35d2f0df7c6a9b2845f26f60113d7ef129ab7e95 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 21 Mar 2025 00:33:06 -0400 Subject: [PATCH] Refactor validation hooks into smaller files --- .../hooks/useAiValidation.tsx | 2 +- .../hooks/useFieldValidation.tsx | 162 +++ .../hooks/useFilterManagement.tsx | 110 ++ .../hooks/useRowOperations.tsx | 366 ++++++ .../hooks/useTemplateManagement.tsx | 354 ++++++ .../hooks/useUniqueItemNumbersValidation.tsx | 151 +++ .../hooks/useUniqueValidation.tsx | 132 ++ .../ValidationStepNew/hooks/useValidation.tsx | 296 +---- .../hooks/useValidationState.tsx | 1082 ++--------------- .../hooks/validationTypes.ts | 101 ++ .../ValidationStepNew/utils/errorUtils.ts | 42 - .../ValidationStepNew/utils/upcValidation.ts | 74 -- .../utils/validation-helper.js | 44 - .../utils/validationUtils.ts | 184 --- 14 files changed, 1473 insertions(+), 1627 deletions(-) create mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFieldValidation.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFilterManagement.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/hooks/useRowOperations.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/hooks/useTemplateManagement.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueItemNumbersValidation.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueValidation.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/hooks/validationTypes.ts delete mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/utils/errorUtils.ts delete mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/utils/upcValidation.ts delete mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/utils/validation-helper.js delete mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/utils/validationUtils.ts diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useAiValidation.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useAiValidation.tsx index 82c1fc9..d63349e 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useAiValidation.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useAiValidation.tsx @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import { toast } from 'sonner'; -import { getApiUrl, RowData } from './useValidationState'; +import { getApiUrl, RowData } from './validationTypes'; import { Fields } from '../../../types'; import { Meta } from '../types'; import { addErrorsAndRunHooks } from '../utils/dataMutations'; diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFieldValidation.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFieldValidation.tsx new file mode 100644 index 0000000..ca4e463 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFieldValidation.tsx @@ -0,0 +1,162 @@ +import { useCallback } from 'react'; +import type { Field, Fields, RowHook, TableHook } from '../../../types'; +import type { Meta } from '../types'; +import { ErrorSources, ErrorType, ValidationError } from '../../../types'; +import { RowData, InfoWithSource, isEmpty } from './validationTypes'; + +// Create a cache for validation results to avoid repeated validation of the same data +const validationResultCache = new Map(); + +// Add a function to clear cache for a specific field value +export const clearValidationCacheForField = (fieldKey: string) => { + // Look for entries that match this field key + validationResultCache.forEach((_, key) => { + if (key.startsWith(`${fieldKey}-`)) { + 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); + }); + + // Also clear any cache entries that might involve uniqueness validation + validationResultCache.forEach((_, key) => { + if (key.includes('unique')) { + validationResultCache.delete(key); + } + }); +}; + +export const useFieldValidation = ( + fields: Fields, + rowHook?: RowHook, + tableHook?: TableHook +) => { + // Validate a single field + const validateField = useCallback(( + value: any, + field: Field + ): ValidationError[] => { + const errors: ValidationError[] = []; + + 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': + // Use the shared isEmpty function + if (isEmpty(value)) { + errors.push({ + message: validation.errorMessage || 'This field is required', + level: validation.level || 'error', + type: ErrorType.Required + }); + } + break; + + case 'unique': + // Unique validation happens at table level, not here + break; + + case 'regex': + if (value !== undefined && value !== null && value !== '') { + try { + const regex = new RegExp(validation.value, validation.flags); + if (!regex.test(String(value))) { + errors.push({ + message: validation.errorMessage, + level: validation.level || 'error', + type: ErrorType.Regex + }); + } + } catch (error) { + console.error('Invalid regex in validation:', error); + } + } + break; + } + }); + + // Store results in cache to speed up future validations + validationResultCache.set(cacheKey, errors); + + return errors; + }, []); + + // Validate a single row + const validateRow = useCallback(async ( + row: RowData, + rowIndex: number, + allRows: RowData[] + ): Promise => { + // Run field-level validations + const fieldErrors: Record = {}; + + fields.forEach(field => { + const value = row[String(field.key) as keyof typeof row]; + const errors = validateField(value, field as Field); + + if (errors.length > 0) { + fieldErrors[String(field.key)] = errors; + } + }); + + // Special validation for supplier and company fields - only apply if the field exists in fields + if (fields.some(field => String(field.key) === 'supplier') && isEmpty(row.supplier)) { + fieldErrors['supplier'] = [{ + message: 'Supplier is required', + level: 'error', + type: ErrorType.Required + }]; + } + + if (fields.some(field => String(field.key) === 'company') && isEmpty(row.company)) { + fieldErrors['company'] = [{ + message: 'Company is required', + level: 'error', + type: ErrorType.Required + }]; + } + + // Run row hook if provided + let rowHookResult: Meta = { + __index: row.__index || String(rowIndex) + }; + if (rowHook) { + try { + // Call the row hook and extract only the __index property + const result = await rowHook(row, rowIndex, allRows); + rowHookResult.__index = result.__index || rowHookResult.__index; + } catch (error) { + console.error('Error in row hook:', error); + } + } + + // We no longer need to merge errors since we're not storing them in the row data + // The calling code should handle storing errors in the validationErrors Map + + return { + __index: row.__index || String(rowIndex) + }; + }, [fields, validateField, rowHook]); + + return { + validateField, + validateRow, + clearValidationCacheForField, + clearAllUniquenessCaches + }; +}; \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFilterManagement.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFilterManagement.tsx new file mode 100644 index 0000000..606f492 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useFilterManagement.tsx @@ -0,0 +1,110 @@ +import { useState, useCallback, useMemo } from 'react'; +import { FilterState, RowData } from './validationTypes'; +import type { Fields } from '../../../types'; +import { ValidationError } from '../../../types'; + +export const useFilterManagement = ( + data: RowData[], + fields: Fields, + validationErrors: Map> +) => { + // Filter state + const [filters, setFilters] = useState({ + searchText: "", + showErrorsOnly: false, + filterField: null, + filterValue: null, + }); + + // Filter data based on current filter state + const filteredData = useMemo(() => { + return data.filter((row, index) => { + // Filter by search text + if (filters.searchText) { + const searchLower = filters.searchText.toLowerCase(); + const matchesSearch = fields.some((field) => { + const value = row[field.key as keyof typeof row]; + if (value === undefined || value === null) return false; + return String(value).toLowerCase().includes(searchLower); + }); + if (!matchesSearch) return false; + } + + // Filter by errors + if (filters.showErrorsOnly) { + const hasErrors = + validationErrors.has(index) && + Object.keys(validationErrors.get(index) || {}).length > 0; + if (!hasErrors) return false; + } + + // Filter by field value + if (filters.filterField && filters.filterValue) { + const fieldValue = row[filters.filterField as keyof typeof row]; + if (fieldValue === undefined) return false; + + const valueStr = String(fieldValue).toLowerCase(); + const filterStr = filters.filterValue.toLowerCase(); + + if (!valueStr.includes(filterStr)) return false; + } + + return true; + }); + }, [data, fields, filters, validationErrors]); + + // Get filter fields + const filterFields = useMemo(() => { + return fields.map((field) => ({ + key: String(field.key), + label: field.label, + })); + }, [fields]); + + // Get filter values for the selected field + const filterValues = useMemo(() => { + if (!filters.filterField) return []; + + // Get unique values for the selected field + const uniqueValues = new Set(); + + data.forEach((row) => { + const value = row[filters.filterField as keyof typeof row]; + if (value !== undefined && value !== null) { + uniqueValues.add(String(value)); + } + }); + + return Array.from(uniqueValues).map((value) => ({ + value, + label: value, + })); + }, [data, filters.filterField]); + + // Update filters + const updateFilters = useCallback((newFilters: Partial) => { + setFilters((prev) => ({ + ...prev, + ...newFilters, + })); + }, []); + + // Reset filters + const resetFilters = useCallback(() => { + setFilters({ + searchText: "", + showErrorsOnly: false, + filterField: null, + filterValue: null, + }); + }, []); + + return { + filters, + filteredData, + filterFields, + filterValues, + updateFilters, + resetFilters + }; +}; \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useRowOperations.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useRowOperations.tsx new file mode 100644 index 0000000..3fa4363 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useRowOperations.tsx @@ -0,0 +1,366 @@ +import { useCallback } from 'react'; +import { RowData } from './validationTypes'; +import type { Field, Fields } from '../../../types'; +import { ErrorType, ValidationError } from '../../../types'; + +export const useRowOperations = ( + data: RowData[], + fields: Fields, + setData: React.Dispatch[]>>, + setValidationErrors: React.Dispatch>>>, + validateFieldFromHook: (value: any, field: Field) => ValidationError[] +) => { + // 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; + + // Get the row data + const row = data[rowIndex]; + + // 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) || {}) }; + + // 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 = validateFieldFromHook(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 = validateFieldFromHook(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, validateFieldFromHook, setValidationErrors] + ); + + // Use validateRow as an alias for fieldValidationHelper for compatibility + const validateRow = fieldValidationHelper; + + // 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 = validateFieldFromHook( + 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; + }); + } + + // 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 newData; + }); + } + }, 50); + }, + [data, fields, validateFieldFromHook, 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); + + // 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) || {}) }; + + // 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]; + + // Run validation for this field + const errors = validateFieldFromHook(value, field as unknown as Field); + + // Update errors for this field + if (errors.length > 0) { + existingRowErrors[fieldKey] = errors; + } else { + delete existingRowErrors[fieldKey]; + } + } + + // 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 = validateFieldFromHook(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); + } + } + } + + return newErrors; + }); + }, + [data, fields, validateFieldFromHook] + ); + + // 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 + const sourceValue = data[rowIndex][key]; + + // Update all rows below with the same value using the existing updateRow function + // This ensures all validation logic runs consistently + for (let i = rowIndex + 1; i < data.length; i++) { + // Just use updateRow which will handle validation with proper timing + updateRow(i, key, sourceValue); + } + }, + [data, updateRow] + ); + + return { + validateRow, + updateRow, + revalidateRows, + copyDown + }; +}; \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useTemplateManagement.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useTemplateManagement.tsx new file mode 100644 index 0000000..3d1d80d --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useTemplateManagement.tsx @@ -0,0 +1,354 @@ +import { useState, useCallback } from 'react'; +import { toast } from 'sonner'; +import { Template, RowData, TemplateState, getApiUrl } from './validationTypes'; +import { RowSelectionState } from '@tanstack/react-table'; +import { ValidationError } from '../../../types'; + +export const useTemplateManagement = ( + data: RowData[], + setData: React.Dispatch[]>>, + rowSelection: RowSelectionState, + setValidationErrors: React.Dispatch>>>, + setRowValidationStatus: React.Dispatch>>, + validateRow: (rowIndex: number, specificField?: string) => void, + isApplyingTemplateRef: React.MutableRefObject +) => { + // Template state + const [templates, setTemplates] = useState([]); + const [isLoadingTemplates, setIsLoadingTemplates] = useState(true); + const [templateState, setTemplateState] = useState({ + selectedTemplateId: null, + showSaveTemplateDialog: false, + newTemplateName: "", + newTemplateType: "", + }); + + // 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); + } + }, []); + + // Refresh templates + const refreshTemplates = useCallback(() => { + loadTemplates(); + }, [loadTemplates]); + + // Save a new template + const saveTemplate = useCallback( + async (name: string, type: string) => { + try { + // Get selected rows + const selectedRowIndex = Number(Object.keys(rowSelection)[0]); + const selectedRow = data[selectedRowIndex]; + + if (!selectedRow) { + toast.error("Please select a row to create a template"); + return; + } + + // Extract data for template, removing metadata fields + const { + __index, + __template, + __original, + __corrected, + __changes, + ...templateData + } = selectedRow as any; + + // Clean numeric values (remove $ from price fields) + const cleanedData: Record = {}; + + // Process each key-value pair + Object.entries(templateData).forEach(([key, value]) => { + // Handle numeric values with dollar signs + if (typeof value === "string" && value.includes("$")) { + cleanedData[key] = value.replace(/[$,\s]/g, "").trim(); + } + // Handle array values (like categories or ship_restrictions) + else if (Array.isArray(value)) { + cleanedData[key] = value; + } + // Handle other values + else { + cleanedData[key] = value; + } + }); + + // Send the template to the API + const response = await fetch(`${getApiUrl()}/templates`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...cleanedData, + company: name, + product_type: type, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.error || errorData.details || "Failed to save template" + ); + } + + // Get the new template from the response + const newTemplate = await response.json(); + + // Update the templates list with the new template + setTemplates((prev) => [...prev, newTemplate]); + + // Update the row to show it's using this template + setData((prev) => { + const newData = [...prev]; + if (newData[selectedRowIndex]) { + newData[selectedRowIndex] = { + ...newData[selectedRowIndex], + __template: newTemplate.id.toString(), + }; + } + return newData; + }); + + toast.success(`Template "${name}" saved successfully`); + + // Reset dialog state + setTemplateState((prev) => ({ + ...prev, + showSaveTemplateDialog: false, + newTemplateName: "", + newTemplateType: "", + })); + } catch (error) { + console.error("Error saving template:", error); + toast.error( + error instanceof Error ? error.message : "Failed to save template" + ); + } + }, + [data, rowSelection, setData] + ); + + // Apply template to rows - optimized version + const applyTemplate = useCallback( + (templateId: string, rowIndexes: number[]) => { + const template = templates.find((t) => t.id.toString() === templateId); + + if (!template) { + toast.error("Template not found"); + return; + } + + console.log(`Applying template ${templateId} to rows:`, rowIndexes); + + // Validate row indexes + const validRowIndexes = rowIndexes.filter( + (index) => index >= 0 && index < data.length && Number.isInteger(index) + ); + + if (validRowIndexes.length === 0) { + toast.error("No valid rows to update"); + console.error("Invalid row indexes:", rowIndexes); + return; + } + + // Set the template application flag + isApplyingTemplateRef.current = true; + + // Save scroll position + const scrollPosition = { + left: window.scrollX, + top: window.scrollY, + }; + + // Create a copy of data and process all rows at once to minimize state updates + const newData = [...data]; + const batchErrors = new Map>(); + const batchStatuses = new Map< + number, + "pending" | "validating" | "validated" | "error" + >(); + + // Extract template fields once outside the loop + const templateFields = Object.entries(template).filter( + ([key]) => + ![ + "id", + "__meta", + "__template", + "__original", + "__corrected", + "__changes", + ].includes(key) + ); + + // Apply template to each valid row + validRowIndexes.forEach((index) => { + // Create a new row with template values + const originalRow = newData[index]; + const updatedRow = { ...originalRow } as Record; + + // Apply template fields (excluding metadata fields) + for (const [key, value] of templateFields) { + updatedRow[key] = value; + } + + // Mark the row as using this template + updatedRow.__template = templateId; + + // Update the row in the data array + newData[index] = updatedRow as RowData; + + // Clear validation errors and mark as validated + batchErrors.set(index, {}); + batchStatuses.set(index, "validated"); + }); + + // Perform a single update for all rows + setData(newData); + + // Update all validation errors and statuses at once + setValidationErrors((prev) => { + const newErrors = new Map(prev); + for (const [rowIndex, errors] of batchErrors.entries()) { + newErrors.set(rowIndex, errors); + } + return newErrors; + }); + + setRowValidationStatus((prev) => { + const newStatus = new Map(prev); + for (const [rowIndex, status] of batchStatuses.entries()) { + newStatus.set(rowIndex, status); + } + return newStatus; + }); + + // Restore scroll position + requestAnimationFrame(() => { + window.scrollTo(scrollPosition.left, scrollPosition.top); + }); + + // Show success toast + if (validRowIndexes.length === 1) { + toast.success("Template applied"); + } else if (validRowIndexes.length > 1) { + toast.success(`Template applied to ${validRowIndexes.length} rows`); + } + + // Check which rows need UPC validation + const upcValidationRows = validRowIndexes.filter((rowIndex) => { + const row = newData[rowIndex]; + return row && row.upc && row.supplier; + }); + + // Validate UPCs for rows that have both UPC and supplier + if (upcValidationRows.length > 0) { + console.log( + `Validating UPCs for ${upcValidationRows.length} rows after template application` + ); + + // Schedule UPC validation for the next tick to allow UI to update first + setTimeout(() => { + upcValidationRows.forEach((rowIndex) => { + const row = newData[rowIndex]; + if (row && row.upc && row.supplier) { + validateRow(rowIndex); + } + }); + }, 100); + } + + // Reset the template application flag + isApplyingTemplateRef.current = false; + }, + [ + data, + templates, + setData, + setValidationErrors, + setRowValidationStatus, + validateRow, + ] + ); + + // Apply template to selected rows + const applyTemplateToSelected = useCallback( + (templateId: string) => { + if (!templateId) return; + + // Update the selected template ID + setTemplateState((prev) => ({ + ...prev, + selectedTemplateId: templateId, + })); + + // Get selected row keys (which may be UUIDs) + const selectedKeys = Object.entries(rowSelection) + .filter(([_, selected]) => selected === true) + .map(([key, _]) => key); + + console.log("Selected row keys:", selectedKeys); + + if (selectedKeys.length === 0) { + toast.error("No rows selected"); + return; + } + + // Map UUID keys to array indices + const selectedIndexes = selectedKeys + .map((key) => { + // Find the matching row index in the data array + const index = data.findIndex( + (row) => + (row.__index && row.__index === key) || // Match by __index + String(data.indexOf(row)) === key // Or by numeric index + ); + return index; + }) + .filter((index) => index !== -1); // Filter out any not found + + console.log("Mapped row indices:", selectedIndexes); + + if (selectedIndexes.length === 0) { + toast.error("Could not find selected rows"); + return; + } + + // Apply template to selected rows + applyTemplate(templateId, selectedIndexes); + }, + [rowSelection, applyTemplate, setTemplateState, data] + ); + + return { + templates, + isLoadingTemplates, + templateState, + setTemplateState, + loadTemplates, + refreshTemplates, + saveTemplate, + applyTemplate, + applyTemplateToSelected + }; +}; \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueItemNumbersValidation.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueItemNumbersValidation.tsx new file mode 100644 index 0000000..cdf0750 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueItemNumbersValidation.tsx @@ -0,0 +1,151 @@ +import { useCallback } from 'react'; +import { RowData } from './validationTypes'; +import type { Fields } from '../../../types'; +import { ErrorSources, ErrorType, ValidationError } from '../../../types'; + +export const useUniqueItemNumbersValidation = ( + data: RowData[], + fields: Fields, + setValidationErrors: React.Dispatch>>> +) => { + // Update validateUniqueItemNumbers to also check for uniqueness of UPC/barcode + const validateUniqueItemNumbers = useCallback(async () => { + console.log("Validating unique fields"); + + // Skip if no data + if (!data.length) return; + + // 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 unique values + data.forEach((row, index) => { + uniqueFields.forEach((fieldKey) => { + const value = row[fieldKey as keyof typeof row]; + + // 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); + } + }); + }); + + // 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) => { + const existingErrors = newMap.get(rowIndex) || {}; + 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); + } + }); + + // Only return a new map if we have changes + return hasChanges ? newMap : prev; + }); + } + + console.log("Uniqueness validation complete"); + }, [data, fields, setValidationErrors]); + + return { + validateUniqueItemNumbers + }; +}; \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueValidation.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueValidation.tsx new file mode 100644 index 0000000..df703c9 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUniqueValidation.tsx @@ -0,0 +1,132 @@ +import { useCallback } from 'react'; +import type { Fields } from '../../../types'; +import { ErrorSources, ErrorType } from '../../../types'; +import { RowData, InfoWithSource, isEmpty } from './validationTypes'; +import { clearValidationCacheForField } from './useFieldValidation'; + +export const useUniqueValidation = ( + fields: Fields +) => { + // 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']; + + // 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>(); + } + } + + // Create map to track errors + const uniqueErrors = new Map>(); + + // Find the field definition + const field = fields.find(f => String(f.key) === fieldKey); + if (!field) return uniqueErrors; + + // Get validation properties + const validation = field.validations?.find(v => v.rule === 'unique'); + const allowEmpty = validation?.allowEmpty ?? false; + const errorMessage = validation?.errorMessage || `${field.label} must be unique`; + const level = validation?.level || 'error'; + + // Track values for uniqueness check + const valueMap = new Map(); + + // Build value map + data.forEach((row, rowIndex) => { + const value = String(row[fieldKey as keyof typeof row] || ''); + + // Skip empty values if allowed + if (allowEmpty && isEmpty(value)) { + return; + } + + if (!valueMap.has(value)) { + valueMap.set(value, [rowIndex]); + } else { + valueMap.get(value)?.push(rowIndex); + } + }); + + // Add errors for duplicate values + valueMap.forEach((rowIndexes, value) => { + if (rowIndexes.length > 1) { + // Skip empty values + if (!value || value.trim() === '') return; + + // Add error to all duplicate rows + rowIndexes.forEach(rowIndex => { + // Create errors object if needed + if (!uniqueErrors.has(rowIndex)) { + uniqueErrors.set(rowIndex, {}); + } + + // Add error for this field + uniqueErrors.get(rowIndex)![fieldKey] = { + message: errorMessage, + level: level as 'info' | 'warning' | 'error', + source: ErrorSources.Table, + type: ErrorType.Unique + }; + }); + } + }); + + return uniqueErrors; + }, [fields]); + + // Validate uniqueness for multiple fields + const validateUniqueFields = useCallback((data: RowData[], fieldKeys: string[]) => { + // Process each field and merge results + const allErrors = new Map>(); + + fieldKeys.forEach(fieldKey => { + const fieldErrors = validateUniqueField(data, fieldKey); + + // Merge errors + fieldErrors.forEach((errors, rowIdx) => { + if (!allErrors.has(rowIdx)) { + allErrors.set(rowIdx, {}); + } + Object.assign(allErrors.get(rowIdx)!, errors); + }); + }); + + return allErrors; + }, [validateUniqueField]); + + // Run complete validation for uniqueness + const validateAllUniqueFields = useCallback((data: RowData[]) => { + // Get fields requiring uniqueness validation + const uniqueFields = fields + .filter(field => field.validations?.some(v => v.rule === 'unique')) + .map(field => String(field.key)); + + // 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, + ...standardUniqueFields + ])]; + + // Filter to only fields that exist in the data + const existingFields = allUniqueFieldKeys.filter(fieldKey => + data.some(row => fieldKey in row) + ); + + // Validate all fields at once + return validateUniqueFields(data, existingFields); + }, [fields, validateUniqueFields]); + + return { + validateUniqueField, + validateUniqueFields, + validateAllUniqueFields + }; +}; \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidation.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidation.tsx index 6cc336f..fca7398 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidation.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidation.tsx @@ -1,248 +1,24 @@ import { useCallback } from 'react' import type { Field, Fields, RowHook, TableHook } from '../../../types' -import type { Meta } from '../types' -import { ErrorSources, ErrorType, ValidationError } from '../../../types' -import { RowData } from './useValidationState' - -// Define InfoWithSource to match the expected structure -// Make sure source is required (not optional) -export interface InfoWithSource { - message: string; - level: 'info' | 'warning' | 'error'; - source: ErrorSources; - type: ErrorType; -} - -// Shared utility function for checking empty values - defined once to avoid duplication -const isEmpty = (value: any): boolean => - value === undefined || - value === null || - value === '' || - (Array.isArray(value) && value.length === 0) || - (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0); - -// Create a cache for validation results to avoid repeated validation of the same data -const validationResultCache = new Map(); - -// Add a function to clear cache for a specific field value -export const clearValidationCacheForField = (fieldKey: string) => { - // Look for entries that match this field key - validationResultCache.forEach((_, key) => { - if (key.startsWith(`${fieldKey}-`)) { - 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); - }); - - // Also clear any cache entries that might involve uniqueness validation - validationResultCache.forEach((_, key) => { - if (key.includes('unique')) { - validationResultCache.delete(key); - } - }); -}; +import { ErrorSources } from '../../../types' +import { RowData, InfoWithSource } from './validationTypes' +import { useFieldValidation, clearValidationCacheForField, clearAllUniquenessCaches } from './useFieldValidation' +import { useUniqueValidation } from './useUniqueValidation' +// Main validation hook that brings together field and uniqueness validation export const useValidation = ( fields: Fields, rowHook?: RowHook, tableHook?: TableHook ) => { - // Validate a single field - const validateField = useCallback(( - value: any, - field: Field - ): ValidationError[] => { - const errors: ValidationError[] = [] - - 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': - // Use the shared isEmpty function - if (isEmpty(value)) { - errors.push({ - message: validation.errorMessage || 'This field is required', - level: validation.level || 'error', - type: ErrorType.Required - }) - } - break - - case 'unique': - // Unique validation happens at table level, not here - break - - case 'regex': - if (value !== undefined && value !== null && value !== '') { - try { - const regex = new RegExp(validation.value, validation.flags) - if (!regex.test(String(value))) { - errors.push({ - message: validation.errorMessage, - level: validation.level || 'error', - type: ErrorType.Regex - }) - } - } catch (error) { - console.error('Invalid regex in validation:', error) - } - } - break - } - }) - - // Store results in cache to speed up future validations - validationResultCache.set(cacheKey, errors); - - return errors - }, []) - - // Validate a single row - const validateRow = useCallback(async ( - row: RowData, - rowIndex: number, - allRows: RowData[] - ): Promise => { - // Run field-level validations - const fieldErrors: Record = {} - - // Use the shared isEmpty function - - fields.forEach(field => { - const value = row[String(field.key) as keyof typeof row] - const errors = validateField(value, field as Field) - - if (errors.length > 0) { - fieldErrors[String(field.key)] = errors - } - }) - - // Special validation for supplier and company fields - only apply if the field exists in fields - if (fields.some(field => String(field.key) === 'supplier') && isEmpty(row.supplier)) { - fieldErrors['supplier'] = [{ - message: 'Supplier is required', - level: 'error', - type: ErrorType.Required - }] - } - - if (fields.some(field => String(field.key) === 'company') && isEmpty(row.company)) { - fieldErrors['company'] = [{ - message: 'Company is required', - level: 'error', - type: ErrorType.Required - }] - } - - // Run row hook if provided - let rowHookResult: Meta = { - __index: row.__index || String(rowIndex) - } - if (rowHook) { - try { - // Call the row hook and extract only the __index property - const result = await rowHook(row, rowIndex, allRows); - rowHookResult.__index = result.__index || rowHookResult.__index; - } catch (error) { - console.error('Error in row hook:', error) - } - } - - // We no longer need to merge errors since we're not storing them in the row data - // The calling code should handle storing errors in the validationErrors Map - - return { - __index: row.__index || String(rowIndex) - } - }, [fields, validateField, rowHook]) - - // 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']; - - // 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>(); - } - } - - // Create map to track errors - const uniqueErrors = new Map>(); - - // Find the field definition - const field = fields.find(f => String(f.key) === fieldKey); - if (!field) return uniqueErrors; - - // Get validation properties - const validation = field.validations?.find(v => v.rule === 'unique'); - const allowEmpty = validation?.allowEmpty ?? false; - const errorMessage = validation?.errorMessage || `${field.label} must be unique`; - const level = validation?.level || 'error'; - - // Track values for uniqueness check - const valueMap = new Map(); - - // Build value map - data.forEach((row, rowIndex) => { - const value = String(row[fieldKey as keyof typeof row] || ''); - - // Skip empty values if allowed - if (allowEmpty && isEmpty(value)) { - return; - } - - if (!valueMap.has(value)) { - valueMap.set(value, [rowIndex]); - } else { - valueMap.get(value)?.push(rowIndex); - } - }); - - // Add errors for duplicate values - valueMap.forEach((rowIndexes, value) => { - if (rowIndexes.length > 1) { - // Skip empty values - if (!value || value.trim() === '') return; - - // Add error to all duplicate rows - rowIndexes.forEach(rowIndex => { - // Create errors object if needed - if (!uniqueErrors.has(rowIndex)) { - uniqueErrors.set(rowIndex, {}); - } - - // Add error for this field - uniqueErrors.get(rowIndex)![fieldKey] = { - message: errorMessage, - level: level as 'info' | 'warning' | 'error', - source: ErrorSources.Table, - type: ErrorType.Unique - }; - }); - } - }); - - return uniqueErrors; - }, [fields]); + // Use the field validation hook + const { validateField, validateRow } = useFieldValidation(fields, rowHook, tableHook); + + // Use the uniqueness validation hook + const { + validateUniqueField, + validateAllUniqueFields + } = useUniqueValidation(fields); // Run complete validation const validateData = useCallback(async (data: RowData[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => { @@ -341,9 +117,6 @@ export const useValidation = ( // 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]; @@ -371,38 +144,15 @@ export const useValidation = ( } } - // Get fields requiring uniqueness validation - const uniqueFields = fields.filter(field => - field.validations?.some(v => v.rule === 'unique') - ); + // Validate all unique fields + const uniqueErrors = validateAllUniqueFields(data); - // 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); - }); + // Merge in unique errors + uniqueErrors.forEach((errors, rowIdx) => { + if (!validationErrors.has(rowIdx)) { + validationErrors.set(rowIdx, {}); + } + Object.assign(validationErrors.get(rowIdx)!, errors); }); console.log('Uniqueness validation complete'); @@ -412,7 +162,7 @@ export const useValidation = ( data, validationErrors }; - }, [fields, validateField, validateUniqueField]); + }, [fields, validateField, validateUniqueField, validateAllUniqueFields]); return { validateData, @@ -421,5 +171,5 @@ export const useValidation = ( validateUniqueField, clearValidationCacheForField, clearAllUniquenessCaches - } + }; } \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx index 5e78396..b7b54d6 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx @@ -1,79 +1,17 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useRsi } from "../../../hooks/useRsi"; -import type { Data, Field } from "../../../types"; -import { ErrorSources, ErrorType, ValidationError } from "../../../types"; +import { ErrorType } from "../../../types"; import { RowSelectionState } from "@tanstack/react-table"; import { toast } from "sonner"; import { useQuery } from "@tanstack/react-query"; import config from "@/config"; import { useValidation } from "./useValidation"; +import { useRowOperations } from "./useRowOperations"; +import { useTemplateManagement } from "./useTemplateManagement"; +import { useFilterManagement } from "./useFilterManagement"; +import { useUniqueItemNumbersValidation } from "./useUniqueItemNumbersValidation"; +import { Props, RowData } from "./validationTypes"; -// Define the Props interface for ValidationStepNew -export interface Props { - initialData: RowData[]; - file?: File; - onBack?: () => void; - onNext?: (data: RowData[]) => void; - isFromScratch?: boolean; -} - -// Extended Data type with meta information -export type RowData = Data & { - __index?: string; - __template?: string; - __original?: Record; - __corrected?: Record; - __changes?: Record; - upc?: string; - barcode?: string; - supplier?: string; - company?: string; - item_number?: string; - [key: string]: any; // Allow any string key for dynamic fields -}; - -// Template interface -export interface Template { - id: number; - company: string; - product_type: string; - [key: string]: string | number | boolean | undefined; -} - -// Props for the useValidationState hook -export interface ValidationStateProps extends Props {} - -// Interface for validation results -export interface ValidationResult { - error?: boolean; - message?: string; - data?: Record; - type?: ErrorType; - source?: ErrorSources; -} - -// Filter state interface -export interface FilterState { - searchText: string; - showErrorsOnly: boolean; - filterField: string | null; - filterValue: string | null; -} - -// Add config at the top of the file -// Import the config or access it through window -declare global { - interface Window { - config?: { - apiUrl: string; - }; - } -} - -// Use a helper to get API URL consistently -export const getApiUrl = () => config.apiUrl; - -// Main validation state hook export const useValidationState = ({ initialData, onBack, @@ -81,7 +19,7 @@ export const useValidationState = ({ }: Props) => { const { fields, rowHook, tableHook } = useRsi(); - // Import validateData from useValidation at the beginning + // Import validateField from useValidation const { validateField: validateFieldFromHook } = useValidation( fields, rowHook, @@ -143,503 +81,45 @@ export const useValidationState = ({ // Validation state const [isValidating] = useState(false); const [validationErrors, setValidationErrors] = useState< - Map> + Map> >(new Map()); const [rowValidationStatus, setRowValidationStatus] = useState< Map >(new Map()); - // Template state - const [templates, setTemplates] = useState([]); - const [isLoadingTemplates, setIsLoadingTemplates] = useState(true); - const [templateState, setTemplateState] = useState({ - selectedTemplateId: null as string | null, - showSaveTemplateDialog: false, - newTemplateName: "", - newTemplateType: "", - }); - - // Filter state - const [filters, setFilters] = useState({ - searchText: "", - showErrorsOnly: false, - filterField: null, - filterValue: null, - }); - const initialValidationDoneRef = useRef(false); - - // 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; - - // Get the row data - const row = data[rowIndex]; - - // 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) || {}) }; - - // 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 = validateFieldFromHook(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 = validateFieldFromHook(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, validateFieldFromHook, setValidationErrors] - ); - - // Use validateRow as an alias for fieldValidationHelper for compatibility - const validateRow = fieldValidationHelper; - - // 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 = validateFieldFromHook( - 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; - }); - } - - // 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 newData; - }); - } - }, 50); - }, - [data, fields, validateFieldFromHook, 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); - - // 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) || {}) }; - - // 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]; - - // Run validation for this field - const errors = validateFieldFromHook(value, field as unknown as Field); - - // Update errors for this field - if (errors.length > 0) { - existingRowErrors[fieldKey] = errors; - } else { - delete existingRowErrors[fieldKey]; - } - } - - // 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 = validateFieldFromHook(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); - } - } - } - - return newErrors; - }); - }, - [data, fields, validateFieldFromHook] - ); - - // Update validateUniqueItemNumbers to also check for uniqueness of UPC/barcode - const validateUniqueItemNumbers = useCallback(async () => { - console.log("Validating unique fields"); - - // Skip if no data - if (!data.length) return; - - // 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 unique values - data.forEach((row, index) => { - uniqueFields.forEach((fieldKey) => { - const value = row[fieldKey as keyof typeof row]; - - // 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); - } - }); - }); - - // 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) => { - const existingErrors = newMap.get(rowIndex) || {}; - 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); - } - }); - - // Only return a new map if we have changes - return hasChanges ? newMap : prev; - }); - } - - console.log("Uniqueness validation complete"); - }, [data, fields]); - - // Add ref to prevent recursive validation const isValidatingRef = useRef(false); + // Use row operations hook + const { validateRow, updateRow, revalidateRows, copyDown } = useRowOperations( + data, + fields, + setData, + setValidationErrors, + validateFieldFromHook + ); + + // Use unique item numbers validation hook + const { validateUniqueItemNumbers } = useUniqueItemNumbersValidation( + data, + fields, + setValidationErrors + ); + + // Use template management hook + const templateManagement = useTemplateManagement( + data, + setData, + rowSelection, + setValidationErrors, + setRowValidationStatus, + validateRow, + isApplyingTemplateRef + ); + + // Use filter management hook + const filterManagement = useFilterManagement(data, fields, validationErrors); + // Run validation when data changes - FIXED to prevent recursive validation useEffect(() => { // Skip initial load - we have a separate initialization process @@ -665,12 +145,12 @@ export const useValidationState = ({ // Create a map to collect validation errors const regexErrors = new Map< number, - Record + Record >(); // Check each row for regex errors data.forEach((row, rowIndex) => { - const rowErrors: Record = {}; + const rowErrors: Record = {}; let hasErrors = false; // Check each regex field @@ -700,8 +180,8 @@ export const useValidationState = ({ { message: regexValidation.errorMessage, level: regexValidation.level || "error", - source: ErrorSources.Row, - type: ErrorType.Regex, + source: "row", + type: "regex", }, ]; hasErrors = true; @@ -746,396 +226,6 @@ export const useValidationState = ({ validateFields(); }, [data, fields, validateUniqueItemNumbers]); - // Filter data based on current filter state - const filteredData = useMemo(() => { - return data.filter((row, index) => { - // Filter by search text - if (filters.searchText) { - const searchLower = filters.searchText.toLowerCase(); - const matchesSearch = fields.some((field) => { - const value = row[field.key as keyof typeof row]; - if (value === undefined || value === null) return false; - return String(value).toLowerCase().includes(searchLower); - }); - if (!matchesSearch) return false; - } - - // Filter by errors - if (filters.showErrorsOnly) { - const hasErrors = - validationErrors.has(index) && - Object.keys(validationErrors.get(index) || {}).length > 0; - if (!hasErrors) return false; - } - - // Filter by field value - if (filters.filterField && filters.filterValue) { - const fieldValue = row[filters.filterField as keyof typeof row]; - if (fieldValue === undefined) return false; - - const valueStr = String(fieldValue).toLowerCase(); - const filterStr = filters.filterValue.toLowerCase(); - - if (!valueStr.includes(filterStr)) return false; - } - - return true; - }); - }, [data, fields, filters, validationErrors]); - - // Get filter fields - const filterFields = useMemo(() => { - return fields.map((field) => ({ - key: String(field.key), - label: field.label, - })); - }, [fields]); - - // Get filter values for the selected field - const filterValues = useMemo(() => { - if (!filters.filterField) return []; - - // Get unique values for the selected field - const uniqueValues = new Set(); - - data.forEach((row) => { - const value = row[filters.filterField as keyof typeof row]; - if (value !== undefined && value !== null) { - uniqueValues.add(String(value)); - } - }); - - return Array.from(uniqueValues).map((value) => ({ - value, - label: value, - })); - }, [data, filters.filterField]); - - // Update filters - const updateFilters = useCallback((newFilters: Partial) => { - setFilters((prev) => ({ - ...prev, - ...newFilters, - })); - }, []); - - // Reset filters - const resetFilters = useCallback(() => { - setFilters({ - searchText: "", - showErrorsOnly: false, - filterField: null, - filterValue: null, - }); - }, []); - - // 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 - const sourceValue = data[rowIndex][key]; - - // Update all rows below with the same value using the existing updateRow function - // This ensures all validation logic runs consistently - for (let i = rowIndex + 1; i < data.length; i++) { - // Just use updateRow which will handle validation with proper timing - updateRow(i, key, sourceValue); - } - }, - [data, updateRow] - ); - - // Save a new template - const saveTemplate = useCallback( - async (name: string, type: string) => { - try { - // Get selected rows - const selectedRowIndex = Number(Object.keys(rowSelection)[0]); - const selectedRow = data[selectedRowIndex]; - - if (!selectedRow) { - toast.error("Please select a row to create a template"); - return; - } - - // Extract data for template, removing metadata fields - const { - __index, - __template, - __original, - __corrected, - __changes, - ...templateData - } = selectedRow as any; - - // Clean numeric values (remove $ from price fields) - const cleanedData: Record = {}; - - // Process each key-value pair - Object.entries(templateData).forEach(([key, value]) => { - // Handle numeric values with dollar signs - if (typeof value === "string" && value.includes("$")) { - cleanedData[key] = value.replace(/[$,\s]/g, "").trim(); - } - // Handle array values (like categories or ship_restrictions) - else if (Array.isArray(value)) { - cleanedData[key] = value; - } - // Handle other values - else { - cleanedData[key] = value; - } - }); - - // Send the template to the API - const response = await fetch(`${getApiUrl()}/templates`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...cleanedData, - company: name, - product_type: type, - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.error || errorData.details || "Failed to save template" - ); - } - - // Get the new template from the response - const newTemplate = await response.json(); - - // Update the templates list with the new template - setTemplates((prev) => [...prev, newTemplate]); - - // Update the row to show it's using this template - setData((prev) => { - const newData = [...prev]; - if (newData[selectedRowIndex]) { - newData[selectedRowIndex] = { - ...newData[selectedRowIndex], - __template: newTemplate.id.toString(), - }; - } - return newData; - }); - - toast.success(`Template "${name}" saved successfully`); - - // Reset dialog state - setTemplateState((prev) => ({ - ...prev, - showSaveTemplateDialog: false, - newTemplateName: "", - newTemplateType: "", - })); - } catch (error) { - console.error("Error saving template:", error); - toast.error( - error instanceof Error ? error.message : "Failed to save template" - ); - } - }, - [data, rowSelection, setData] - ); - - // Apply template to rows - optimized version - const applyTemplate = useCallback( - (templateId: string, rowIndexes: number[]) => { - const template = templates.find((t) => t.id.toString() === templateId); - - if (!template) { - toast.error("Template not found"); - return; - } - - console.log(`Applying template ${templateId} to rows:`, rowIndexes); - - // Validate row indexes - const validRowIndexes = rowIndexes.filter( - (index) => index >= 0 && index < data.length && Number.isInteger(index) - ); - - if (validRowIndexes.length === 0) { - toast.error("No valid rows to update"); - console.error("Invalid row indexes:", rowIndexes); - return; - } - - // Set the template application flag - isApplyingTemplateRef.current = true; - - // Save scroll position - const scrollPosition = { - left: window.scrollX, - top: window.scrollY, - }; - - // Create a copy of data and process all rows at once to minimize state updates - const newData = [...data]; - const batchErrors = new Map>(); - const batchStatuses = new Map< - number, - "pending" | "validating" | "validated" | "error" - >(); - - // Extract template fields once outside the loop - const templateFields = Object.entries(template).filter( - ([key]) => - ![ - "id", - "__meta", - "__template", - "__original", - "__corrected", - "__changes", - ].includes(key) - ); - - // Apply template to each valid row - validRowIndexes.forEach((index) => { - // Create a new row with template values - const originalRow = newData[index]; - const updatedRow = { ...originalRow } as Record; - - // Apply template fields (excluding metadata fields) - for (const [key, value] of templateFields) { - updatedRow[key] = value; - } - - // Mark the row as using this template - updatedRow.__template = templateId; - - // Update the row in the data array - newData[index] = updatedRow as RowData; - - // Clear validation errors and mark as validated - batchErrors.set(index, {}); - batchStatuses.set(index, "validated"); - }); - - // Perform a single update for all rows - setData(newData); - - // Update all validation errors and statuses at once - setValidationErrors((prev) => { - const newErrors = new Map(prev); - for (const [rowIndex, errors] of batchErrors.entries()) { - newErrors.set(rowIndex, errors); - } - return newErrors; - }); - - setRowValidationStatus((prev) => { - const newStatus = new Map(prev); - for (const [rowIndex, status] of batchStatuses.entries()) { - newStatus.set(rowIndex, status); - } - return newStatus; - }); - - // Restore scroll position - requestAnimationFrame(() => { - window.scrollTo(scrollPosition.left, scrollPosition.top); - }); - - // Show success toast - if (validRowIndexes.length === 1) { - toast.success("Template applied"); - } else if (validRowIndexes.length > 1) { - toast.success(`Template applied to ${validRowIndexes.length} rows`); - } - - // Check which rows need UPC validation - const upcValidationRows = validRowIndexes.filter((rowIndex) => { - const row = newData[rowIndex]; - return row && row.upc && row.supplier; - }); - - // Validate UPCs for rows that have both UPC and supplier - if (upcValidationRows.length > 0) { - console.log( - `Validating UPCs for ${upcValidationRows.length} rows after template application` - ); - - // Schedule UPC validation for the next tick to allow UI to update first - setTimeout(() => { - upcValidationRows.forEach((rowIndex) => { - const row = newData[rowIndex]; - if (row && row.upc && row.supplier) { - validateRow(rowIndex); - } - }); - }, 100); - } - - // Reset the template application flag - isApplyingTemplateRef.current = false; - }, - [ - data, - templates, - setData, - setValidationErrors, - setRowValidationStatus, - validateRow, - ] - ); - - // Apply template to selected rows - const applyTemplateToSelected = useCallback( - (templateId: string) => { - if (!templateId) return; - - // Update the selected template ID - setTemplateState((prev) => ({ - ...prev, - selectedTemplateId: templateId, - })); - - // Get selected row keys (which may be UUIDs) - const selectedKeys = Object.entries(rowSelection) - .filter(([_, selected]) => selected === true) - .map(([key, _]) => key); - - console.log("Selected row keys:", selectedKeys); - - if (selectedKeys.length === 0) { - toast.error("No rows selected"); - return; - } - - // Map UUID keys to array indices - const selectedIndexes = selectedKeys - .map((key) => { - // Find the matching row index in the data array - const index = data.findIndex( - (row) => - (row.__index && row.__index === key) || // Match by __index - String(data.indexOf(row)) === key // Or by numeric index - ); - return index; - }) - .filter((index) => index !== -1); // Filter out any not found - - console.log("Mapped row indices:", selectedIndexes); - - if (selectedIndexes.length === 0) { - toast.error("Could not find selected rows"); - return; - } - - // Apply template to selected rows - applyTemplate(templateId, selectedIndexes); - }, - [rowSelection, applyTemplate, setTemplateState, data] - ); - // Add field options query const { data: fieldOptionsData } = useQuery({ queryKey: ["import-field-options"], @@ -1155,7 +245,7 @@ export const useValidationState = ({ (templateId: string | null) => { if (!templateId) return "Select a template"; - const template = templates.find((t) => t.id.toString() === templateId); + const template = templateManagement.templates.find((t) => t.id.toString() === templateId); if (!template) return "Unknown template"; try { @@ -1178,7 +268,7 @@ export const useValidationState = ({ return "Error displaying template"; } }, - [templates, fieldOptionsData] + [templateManagement.templates, fieldOptionsData] ); // Check if there are any errors @@ -1189,32 +279,6 @@ 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(); - }, [loadTemplates]); - // Create a function to handle button clicks (continue or back) const handleButtonClick = useCallback( async (direction: "next" | "back") => { @@ -1262,7 +326,7 @@ export const useValidationState = ({ __changes, ...cleanRow } = row; - return cleanRow as Data; + return cleanRow as any; }); onNext(cleanedData); @@ -1317,7 +381,7 @@ export const useValidationState = ({ // Create a temporary Map to collect all validation errors const validationErrorsTemp = new Map< number, - Record + Record >(); // Variables for batching @@ -1349,7 +413,7 @@ export const useValidationState = ({ } // Store field errors for this row - const fieldErrors: Record = {}; + const fieldErrors: Record = {}; let hasErrors = false; // Check if price fields need formatting @@ -1421,8 +485,8 @@ export const useValidationState = ({ field.validations?.find((v) => v.rule === "required") ?.errorMessage || "This field is required", level: "error", - source: ErrorSources.Row, - type: ErrorType.Required, + source: "row", + type: "required", }, ]; hasErrors = true; @@ -1456,8 +520,8 @@ export const useValidationState = ({ { message: regexValidation.errorMessage, level: regexValidation.level || "error", - source: ErrorSources.Row, - type: ErrorType.Regex, + source: "row", + type: "regex", }, ]; hasErrors = true; @@ -1519,7 +583,7 @@ export const useValidationState = ({ // Run the complete validation runCompleteValidation(); - }, [data, fields, setData, setValidationErrors]); + }, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers]); // Update fields with latest options const fieldsWithOptions = useMemo(() => { @@ -1588,14 +652,14 @@ export const useValidationState = ({ // Load templates on mount useEffect(() => { - loadTemplates(); - }, [loadTemplates]); + templateManagement.loadTemplates(); + }, [templateManagement.loadTemplates]); return { // Data data, setData, - filteredData, + filteredData: filterManagement.filteredData, // Validation isValidating, @@ -1613,27 +677,27 @@ export const useValidationState = ({ copyDown, // Templates - templates, - isLoadingTemplates, - selectedTemplateId: templateState.selectedTemplateId, - showSaveTemplateDialog: templateState.showSaveTemplateDialog, - newTemplateName: templateState.newTemplateName, - newTemplateType: templateState.newTemplateType, - setTemplateState, - templateState, - loadTemplates, - saveTemplate, - applyTemplate, - applyTemplateToSelected, + templates: templateManagement.templates, + isLoadingTemplates: templateManagement.isLoadingTemplates, + selectedTemplateId: templateManagement.templateState.selectedTemplateId, + showSaveTemplateDialog: templateManagement.templateState.showSaveTemplateDialog, + newTemplateName: templateManagement.templateState.newTemplateName, + newTemplateType: templateManagement.templateState.newTemplateType, + setTemplateState: templateManagement.setTemplateState, + templateState: templateManagement.templateState, + loadTemplates: templateManagement.loadTemplates, + saveTemplate: templateManagement.saveTemplate, + applyTemplate: templateManagement.applyTemplate, + applyTemplateToSelected: templateManagement.applyTemplateToSelected, getTemplateDisplayText, - refreshTemplates, + refreshTemplates: templateManagement.refreshTemplates, // Filters - filters, - filterFields, - filterValues, - updateFilters, - resetFilters, + filters: filterManagement.filters, + filterFields: filterManagement.filterFields, + filterValues: filterManagement.filterValues, + updateFilters: filterManagement.updateFilters, + resetFilters: filterManagement.resetFilters, // Fields reference fields: fieldsWithOptions, // Return updated fields with options diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/validationTypes.ts b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/validationTypes.ts new file mode 100644 index 0000000..2b1eebf --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/validationTypes.ts @@ -0,0 +1,101 @@ +import type { Data, Field } from "../../../types"; +import { ErrorSources, ErrorType, ValidationError } from "../../../types"; +import config from "@/config"; +import { RowSelectionState } from "@tanstack/react-table"; + +// Define the Props interface for ValidationStepNew +export interface Props { + initialData: RowData[]; + file?: File; + onBack?: () => void; + onNext?: (data: RowData[]) => void; + isFromScratch?: boolean; +} + +// Extended Data type with meta information +export type RowData = Data & { + __index?: string; + __template?: string; + __original?: Record; + __corrected?: Record; + __changes?: Record; + upc?: string; + barcode?: string; + supplier?: string; + company?: string; + item_number?: string; + [key: string]: any; // Allow any string key for dynamic fields +}; + +// Template interface +export interface Template { + id: number; + company: string; + product_type: string; + [key: string]: string | number | boolean | undefined; +} + +// Props for the useValidationState hook +export interface ValidationStateProps extends Props {} + +// Interface for validation results +export interface ValidationResult { + error?: boolean; + message?: string; + data?: Record; + type?: ErrorType; + source?: ErrorSources; +} + +// Filter state interface +export interface FilterState { + searchText: string; + showErrorsOnly: boolean; + filterField: string | null; + filterValue: string | null; +} + +// UI validation state interface for useUpcValidation +export 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 +} + +// InfoWithSource interface for validation errors +export interface InfoWithSource { + message: string; + level: 'info' | 'warning' | 'error'; + source: ErrorSources; + type: ErrorType; +} + +// Template state interface +export interface TemplateState { + selectedTemplateId: string | null; + showSaveTemplateDialog: boolean; + newTemplateName: string; + newTemplateType: string; +} + +// Add config at the top of the file +// Import the config or access it through window +declare global { + interface Window { + config?: { + apiUrl: string; + }; + } +} + +// Use a helper to get API URL consistently +export const getApiUrl = () => config.apiUrl; + +// Shared utility function for checking empty values +export const isEmpty = (value: any): boolean => + value === undefined || + value === null || + value === '' || + (Array.isArray(value) && value.length === 0) || + (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0); \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/utils/errorUtils.ts b/inventory/src/components/product-import/steps/ValidationStepNew/utils/errorUtils.ts deleted file mode 100644 index 1cbcbcb..0000000 --- a/inventory/src/components/product-import/steps/ValidationStepNew/utils/errorUtils.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ErrorType } from '../types/index' - -/** - * Converts an InfoWithSource or similar error object to our Error type - * @param error The error object to convert - * @returns Our standardized Error object - */ -export const convertToError = (error: any): ErrorType => { - return { - message: typeof error.message === 'string' ? error.message : String(error.message || ''), - level: error.level || 'error', - source: error.source || 'row', - type: error.type || 'custom' - } -} - -/** - * Safely convert an error or array of errors to our Error[] format - * @param errors The error or array of errors to convert - * @returns Array of our Error objects - */ -export const convertToErrorArray = (errors: any): ErrorType[] => { - if (Array.isArray(errors)) { - return errors.map(convertToError) - } - return [convertToError(errors)] -} - -/** - * Converts a record of errors to our standardized format - * @param errorRecord Record with string keys and error values - * @returns Standardized error record - */ -export const convertErrorRecord = (errorRecord: Record): Record => { - const result: Record = {} - - Object.entries(errorRecord).forEach(([key, errors]) => { - result[key] = convertToErrorArray(errors) - }) - - return result -} \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/utils/upcValidation.ts b/inventory/src/components/product-import/steps/ValidationStepNew/utils/upcValidation.ts deleted file mode 100644 index c1b9041..0000000 --- a/inventory/src/components/product-import/steps/ValidationStepNew/utils/upcValidation.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { toast } from 'sonner' - -/** - * Placeholder for validating UPC codes - * @param upcValue UPC value to validate - * @returns Validation result - */ -export const validateUpc = async (upcValue: string): Promise => { - // Basic validation - UPC should be 12-14 digits - if (!/^\d{12,14}$/.test(upcValue)) { - toast.error('Invalid UPC format. UPC should be 12-14 digits.') - return { - error: true, - message: 'Invalid UPC format' - } - } - - // In a real implementation, call an API to validate the UPC - // For now, just return a successful result - return { - error: false, - data: { - // Mock data that would be returned from the API - item_number: `ITEM-${upcValue.substring(0, 6)}`, - sku: `SKU-${upcValue.substring(0, 4)}`, - description: `Sample Product ${upcValue.substring(0, 4)}` - } - } -} - -/** - * Generates an item number for a UPC - * @param upcValue UPC value - * @returns Generated item number - */ -export const generateItemNumber = (upcValue: string): string => { - // Simple item number generation logic - return `ITEM-${upcValue.substring(0, 6)}` -} - -/** - * Placeholder for handling UPC validation process - * @param upcValue UPC value to validate - * @param rowIndex Row index being validated - * @param updateRow Function to update row data - */ -export const handleUpcValidation = async ( - upcValue: string, - rowIndex: number, - updateRow: (rowIndex: number, key: string, value: any) => void -): Promise => { - try { - // Validate the UPC - const result = await validateUpc(upcValue) - - if (result.error) { - toast.error(result.message || 'UPC validation failed') - return - } - - // Update row with the validation result data - if (result.data) { - // Update each field returned from the API - Object.entries(result.data).forEach(([key, value]) => { - updateRow(rowIndex, key, value) - }) - - toast.success('UPC validated successfully') - } - } catch (error) { - console.error('Error validating UPC:', error) - toast.error('Failed to validate UPC') - } -} \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/utils/validation-helper.js b/inventory/src/components/product-import/steps/ValidationStepNew/utils/validation-helper.js deleted file mode 100644 index a026863..0000000 --- a/inventory/src/components/product-import/steps/ValidationStepNew/utils/validation-helper.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Helper functions for validation that ensure proper error objects - */ - -// Create a standard error object -export const createError = (message, level = 'error', source = 'row') => { - return { message, level, source }; -}; - -// Convert any error to standard format -export const convertError = (error) => { - if (!error) return createError('Unknown error'); - - if (typeof error === 'string') { - return createError(error); - } - - return { - message: error.message || 'Unknown error', - level: error.level || 'error', - source: error.source || 'row' - }; -}; - -// Convert array of errors or single error to array -export const convertToErrorArray = (errors) => { - if (Array.isArray(errors)) { - return errors.map(convertError); - } - return [convertError(errors)]; -}; - -// Convert a record of errors to standard format -export const convertErrorRecord = (errorRecord) => { - const result = {}; - - if (!errorRecord) return result; - - Object.entries(errorRecord).forEach(([key, errors]) => { - result[key] = convertToErrorArray(errors); - }); - - return result; -}; \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/utils/validationUtils.ts b/inventory/src/components/product-import/steps/ValidationStepNew/utils/validationUtils.ts deleted file mode 100644 index fed0e67..0000000 --- a/inventory/src/components/product-import/steps/ValidationStepNew/utils/validationUtils.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { Field, Data, ErrorSources, ErrorType as ValidationErrorType } from '../../../types' -import { ErrorType } from '../types/index' - -/** - * Formats a price value to a consistent format - * @param value The price value to format - * @returns The formatted price string - */ -export const formatPrice = (value: string | number): string => { - if (!value) return '' - - // Convert to string and clean - const numericValue = String(value).replace(/[^\d.]/g, '') - - // Parse the number - const number = parseFloat(numericValue) - if (isNaN(number)) return '' - - // Format as currency - return number.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }) -} - -/** - * Checks if a field is a price field - * @param field The field to check - * @returns True if the field is a price field - */ -export const isPriceField = (field: Field): boolean => { - const fieldType = field.fieldType; - return (fieldType.type === 'input' || fieldType.type === 'multi-input') && - 'price' in fieldType && - !!fieldType.price; -} - -/** - * Determines if a field is a multi-input type - * @param fieldType The field type to check - * @returns True if the field is a multi-input type - */ -export const isMultiInputType = (fieldType: any): boolean => { - return fieldType.type === 'multi-input' -} - -/** - * Gets the separator for multi-input fields - * @param fieldType The field type - * @returns The separator string - */ -export const getMultiInputSeparator = (fieldType: any): string => { - if (isMultiInputType(fieldType) && fieldType.separator) { - return fieldType.separator - } - return ',' -} - -/** - * Performs regex validation on a value - * @param value The value to validate - * @param regex The regex pattern - * @param flags Regex flags - * @returns True if validation passes - */ -export const validateRegex = (value: any, regex: string, flags?: string): boolean => { - if (value === undefined || value === null || value === '') return true - - try { - const regexObj = new RegExp(regex, flags) - return regexObj.test(String(value)) - } catch (error) { - console.error('Invalid regex in validation:', error) - return false - } -} - -/** - * Creates a validation error object - * @param message Error message - * @param level Error level - * @param source Error source - * @param type Error type - * @returns Error object - */ -export const createError = ( - message: string, - level: 'info' | 'warning' | 'error' = 'error', - source: ErrorSources = ErrorSources.Row, - type: ValidationErrorType = ValidationErrorType.Custom -): ErrorType => { - return { - message, - level, - source, - type - } as ErrorType -} - -/** - * Formats a display value based on field type - * @param value The value to format - * @param field The field definition - * @returns Formatted display value - */ -export const getDisplayValue = (value: any, field: Field): string => { - if (value === undefined || value === null) return '' - - // Handle price fields - if (isPriceField(field)) { - return formatPrice(value) - } - - // Handle multi-input fields - if (isMultiInputType(field.fieldType)) { - if (Array.isArray(value)) { - return value.join(`${getMultiInputSeparator(field.fieldType)} `) - } - } - - // Handle boolean values - if (typeof value === 'boolean') { - return value ? 'Yes' : 'No' - } - - return String(value) -} - -/** - * Validates supplier and company fields - * @param row The data row - * @returns Object with errors for invalid fields - */ -export const validateSpecialFields = (row: Data): Record => { - const errors: Record = {} - - // Validate supplier field - if (!row.supplier) { - errors['supplier'] = [{ - message: 'Supplier is required', - level: 'error', - source: ErrorSources.Row, - type: ValidationErrorType.Required - }] - } - - // Validate company field - if (!row.company) { - errors['company'] = [{ - message: 'Company is required', - level: 'error', - source: ErrorSources.Row, - type: ValidationErrorType.Required - }] - } - - return errors -} - -/** - * Merges multiple error objects - * @param errors Array of error objects to merge - * @returns Merged error object - */ -export const mergeErrors = (...errors: Record[]): Record => { - const merged: Record = {} - - errors.forEach(errorObj => { - if (!errorObj) return - - Object.entries(errorObj).forEach(([key, errs]) => { - if (!merged[key]) { - merged[key] = [] - } - - merged[key] = [ - ...merged[key], - ...(Array.isArray(errs) ? errs : [errs as ErrorType]) - ] - }) - }) - - return merged -} \ No newline at end of file