5 Commits

25 changed files with 2346 additions and 2656 deletions

View File

@@ -0,0 +1,131 @@
# Refactoring Plan for Validation Code
## Current Structure Analysis
- **useValidationState.tsx**: ~1650 lines - Core validation state management
- **useValidation.tsx**: ~425 lines - Field/data validation utility
- **useUpcValidation.tsx**: ~410 lines - UPC-specific validation
## Proposed New Structure
### 1. Core Types & Utilities (150-200 lines)
**File: `validation/types.ts`**
- All interfaces and types (RowData, ValidationError, FilterState, Template, etc.)
- Shared utility functions (isEmpty, getCellKey, etc.)
**File: `validation/utils.ts`**
- Generic validation utility functions
- Caching mechanism and cache clearing helpers
- API URL helpers
### 2. Field Validation (300-350 lines)
**File: `validation/hooks/useFieldValidation.ts`**
- `validateField` function
- Field-level validation logic
- Required, regex, and other field validations
### 3. Uniqueness Validation (250-300 lines)
**File: `validation/hooks/useUniquenessValidation.ts`**
- `validateUniqueField` function
- `validateUniqueItemNumbers` function
- All uniqueness checking logic
### 4. UPC Validation (300-350 lines)
**File: `validation/hooks/useUpcValidation.ts`**
- `fetchProductByUpc` function
- `validateUpc` function
- `applyItemNumbersToData` function
- UPC validation state management
### 5. Validation Status Management (300-350 lines)
**File: `validation/hooks/useValidationStatus.ts`**
- Error state management
- Row validation status tracking
- Validation indicators and refs
- Batch validation processing
### 6. Data Management (300-350 lines)
**File: `validation/hooks/useValidationData.ts`**
- Data state management
- Row updates
- Data filtering
- Initial data processing
### 7. Template Management (250-300 lines)
**File: `validation/hooks/useTemplateManagement.ts`**
- Template saving
- Template application
- Template loading
- Template display helpers
### 8. Main Validation Hook (300-350 lines)
**File: `validation/hooks/useValidation.ts`**
- Main hook that composes all other hooks
- Public API export
- Initialization logic
- Core validation flow
## Function Distribution
### Core Types & Utilities
- All interfaces (InfoWithSource, ValidationState, etc.)
- `isEmpty` utility
- `getApiUrl` helper
### Field Validation
- `validateField`
- `validateRow`
- `validateData` (partial)
- All validation result caching
### Uniqueness Validation
- `validateUniqueField`
- `validateUniqueItemNumbers`
- Uniqueness caching mechanisms
### UPC Validation
- `fetchProductByUpc`
- `validateUpc`
- `validateAllUPCs`
- `applyItemNumbersToData`
- UPC validation state tracking (cells, rows)
### Validation Status Management
- `startValidatingCell`/`stopValidatingCell`
- `startValidatingRow`/`stopValidatingRow`
- `isValidatingCell`/`isRowValidatingUpc`
- Error state management
- `revalidateRows`
### Data Management
- Initial data cleaning/processing
- `updateRow`
- `copyDown`
- Search/filter functionality
- `filteredData` calculation
### Template Management
- `saveTemplate`
- `applyTemplate`
- `applyTemplateToSelected`
- `getTemplateDisplayText`
- `loadTemplates`/`refreshTemplates`
### Main Validation Hook
- Composition of all other hooks
- Initialization logic
- Button/navigation handling
- Field options management
## Implementation Approach
1. **Start with Types**: Create the types file first, as all other files will depend on it
2. **Create Utility Functions**: Move shared utilities next
3. **Build Core Validation**: Extract the field validation and uniqueness validation
4. **Separate UPC Logic**: Move all UPC-specific code to its own module
5. **Extract State Management**: Move data and status management to separate files
6. **Move Template Logic**: Extract template functionality
7. **Create Composition Hook**: Build the main hook that uses all other hooks
This approach will give you more maintainable code with clearer separation of concerns, making it easier to understand, test, and modify each component independently.

View File

@@ -223,7 +223,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
onBack();
}
}}
onNext={(validatedData) => {
onNext={(validatedData: any[]) => {
// Go to image upload step with the validated data
onNext({
type: StepType.imageUpload,

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Template } from '../hooks/useValidationState'
import { Template } from '../hooks/validationTypes'
import { Button } from '@/components/ui/button'
import {
Command,
@@ -50,7 +50,6 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
const [searchTerm, setSearchTerm] = useState("");
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [] = useState<string | null>(null);
// Set default brand when component mounts or defaultBrand changes
useEffect(() => {

View File

@@ -2,7 +2,7 @@ import React, { useMemo } from 'react'
import ValidationTable from './ValidationTable'
import { RowSelectionState } from '@tanstack/react-table'
import { Fields } from '../../../types'
import { Template } from '../hooks/useValidationState'
import { Template } from '../hooks/validationTypes'
interface UpcValidationTableAdapterProps<T extends string> {
data: any[]
@@ -28,6 +28,7 @@ interface UpcValidationTableAdapterProps<T extends string> {
validatingRows: Set<number>
getItemNumber: (rowIndex: number) => string | undefined
}
itemNumbers?: Map<number, string>
}
/**
@@ -56,75 +57,79 @@ function UpcValidationTableAdapter<T extends string>({
rowSublines,
isLoadingLines,
isLoadingSublines,
upcValidation
upcValidation,
itemNumbers
}: UpcValidationTableAdapterProps<T>) {
// Prepare the validation table with UPC data
const AdaptedTable = useMemo(() => React.memo((props: React.ComponentProps<typeof ValidationTable>) => {
// Create validatingCells set from validating rows, but only for item_number fields
// This ensures only the item_number column shows loading state during UPC validation
const combinedValidatingCells = new Set<string>();
// Create combined validatingCells set from validating rows and external cells
const combinedValidatingCells = useMemo(() => {
const combined = new Set<string>();
// Add UPC validation cells
upcValidation.validatingRows.forEach(rowIndex => {
// Only mark the item_number cells as validating, NOT the UPC or supplier
combinedValidatingCells.add(`${rowIndex}-item_number`);
combined.add(`${rowIndex}-item_number`);
});
// Add any other validating cells from state
externalValidatingCells.forEach(cellKey => {
combinedValidatingCells.add(cellKey);
combined.add(cellKey);
});
// Convert the Map to the expected format for the ValidationTable
// Create a new Map from the item numbers to ensure proper typing
const itemNumbersMap = new Map<number, string>();
return combined;
}, [upcValidation.validatingRows, externalValidatingCells]);
// Merge the item numbers with the data for display purposes only
const enhancedData = props.data.map((row: any, index: number) => {
const itemNumber = upcValidation.getItemNumber(index);
if (itemNumber) {
// Add to our map for proper prop passing
itemNumbersMap.set(index, itemNumber);
// Create a consolidated item numbers map from all sources
const consolidatedItemNumbers = useMemo(() => {
const result = new Map<number, string>();
return {
...row,
item_number: itemNumber
};
}
return row;
});
// First add from itemNumbers directly - this is the source of truth for template applications
if (itemNumbers) {
// Log all numbers for debugging
console.log(`[ADAPTER-DEBUG] Received itemNumbers map with ${itemNumbers.size} entries`);
// Create a Map for upcValidationResults with the same structure expected by ValidationTable
const upcValidationResultsMap = new Map<number, { itemNumber: string }>();
itemNumbers.forEach((itemNumber, rowIndex) => {
console.log(`[ADAPTER-DEBUG] Adding item number for row ${rowIndex} from itemNumbers: ${itemNumber}`);
result.set(rowIndex, itemNumber);
});
}
// Populate with any item numbers we have from validation
// For each row, ensure we have the most up-to-date item number
data.forEach((_, index) => {
// Check if upcValidation has an item number for this row
const itemNumber = upcValidation.getItemNumber(index);
if (itemNumber) {
upcValidationResultsMap.set(index, { itemNumber });
console.log(`[ADAPTER-DEBUG] Adding item number for row ${index} from upcValidation: ${itemNumber}`);
result.set(index, itemNumber);
}
// Also check if it's directly in the data
const dataItemNumber = data[index].item_number;
if (dataItemNumber && !result.has(index)) {
console.log(`[ADAPTER-DEBUG] Adding item number for row ${index} from data: ${dataItemNumber}`);
result.set(index, dataItemNumber);
}
});
return (
<ValidationTable
{...props}
data={enhancedData}
validatingCells={combinedValidatingCells}
itemNumbers={itemNumbersMap}
isLoadingTemplates={isLoadingTemplates}
copyDown={copyDown}
rowProductLines={rowProductLines}
rowSublines={rowSublines}
isLoadingLines={isLoadingLines}
isLoadingSublines={isLoadingSublines}
upcValidationResults={upcValidationResultsMap}
/>
);
}), [upcValidation.validatingRows, upcValidation.getItemNumber, isLoadingTemplates, copyDown, externalValidatingCells, rowProductLines, rowSublines, isLoadingLines, isLoadingSublines]);
return result;
}, [data, itemNumbers, upcValidation]);
// Create upcValidationResults map using the consolidated item numbers
const upcValidationResults = useMemo(() => {
const results = new Map<number, { itemNumber: string }>();
// Populate with our consolidated item numbers
consolidatedItemNumbers.forEach((itemNumber, rowIndex) => {
results.set(rowIndex, { itemNumber });
});
return results;
}, [consolidatedItemNumbers]);
// Render the validation table with the provided props and UPC data
return (
<AdaptedTable
<ValidationTable
data={data}
fields={fields}
rowSelection={rowSelection}
@@ -137,11 +142,11 @@ function UpcValidationTableAdapter<T extends string>({
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
validatingCells={new Set()}
itemNumbers={new Map()}
validatingCells={combinedValidatingCells}
itemNumbers={consolidatedItemNumbers}
isLoadingTemplates={isLoadingTemplates}
copyDown={copyDown}
upcValidationResults={new Map<number, { itemNumber: string }>()}
upcValidationResults={upcValidationResults}
rowProductLines={rowProductLines}
rowSublines={rowSublines}
isLoadingLines={isLoadingLines}

View File

@@ -293,8 +293,18 @@ const ValidationCell = React.memo(({
// Use the CopyDown context
const copyDownContext = React.useContext(CopyDownContext);
// Display value prioritizes itemNumber if available (for item_number fields)
const displayValue = fieldKey === 'item_number' && itemNumber ? itemNumber : value;
// CRITICAL FIX: For item_number fields, always prioritize the itemNumber prop over the value
// This ensures that when the itemNumber changes, the display value changes
let displayValue;
if (fieldKey === 'item_number' && itemNumber) {
// Always log when an item_number field is rendered to help debug
console.log(`[VC-DEBUG] ValidationCell rendering item_number for row=${rowIndex} with itemNumber=${itemNumber}, value=${value}`);
// Prioritize itemNumber prop for item_number fields
displayValue = itemNumber;
} else {
displayValue = value;
}
// Use the optimized processErrors function to avoid redundant filtering
const {

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react'
import { useValidationState, Props } from '../hooks/useValidationState'
import { useValidationState } from '../hooks/useValidationState'
import { Props } from '../hooks/validationTypes'
import { Button } from '@/components/ui/button'
import { Loader2, X, Plus, Edit3, Sparkles, FileText } from 'lucide-react'
import { toast } from 'sonner'
@@ -57,7 +58,10 @@ const ValidationContainer = <T extends string>({
loadTemplates,
setData,
fields,
isLoadingTemplates } = validationState
isLoadingTemplates,
validatingCells,
setValidatingCells
} = validationState
// Use product lines fetching hook
const {
@@ -69,9 +73,6 @@ const ValidationContainer = <T extends string>({
fetchSublines
} = useProductLinesFetching(data);
// Add state for tracking cells in loading state
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set());
// Use UPC validation hook
const upcValidation = useUpcValidation(data, setData);
@@ -958,6 +959,7 @@ const ValidationContainer = <T extends string>({
isLoadingLines={isLoadingLines}
isLoadingSublines={isLoadingSublines}
upcValidation={upcValidation}
itemNumbers={upcValidation.itemNumbers}
/>
);
}, [

View File

@@ -7,7 +7,7 @@ import {
ColumnDef
} from '@tanstack/react-table'
import { Fields, Field } from '../../../types'
import { RowData, Template } from '../hooks/useValidationState'
import { RowData, Template } from '../hooks/validationTypes'
import ValidationCell, { CopyDownContext } from './ValidationCell'
import { useRsi } from '../../../hooks/useRsi'
import SearchableTemplateSelect from './SearchableTemplateSelect'
@@ -15,7 +15,7 @@ import { Table, TableBody, TableRow, TableCell } from '@/components/ui/table'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
// Define a simple Error type locally to avoid import issues
type ErrorType = {
@@ -67,10 +67,9 @@ const MemoizedTemplateSelect = React.memo(({
}) => {
if (isLoading) {
return (
<Button variant="outline" className="w-full justify-between overflow-hidden" disabled>
<Loader2 className="h-4 w-4 mr-2 animate-spin flex-none" />
<span className="truncate overflow-hidden">Loading...</span>
</Button>
<div className="flex items-center justify-center gap-2 border border-input rounded-md px-2 py-2">
<Skeleton className="h-4 w-full" />
</div>
);
}
@@ -139,11 +138,15 @@ const MemoizedCell = React.memo(({
/>
);
}, (prev, next) => {
// CRITICAL FIX: Never memoize item_number cells - always re-render them
if (prev.fieldKey === 'item_number') {
return false; // Never skip re-renders for item_number cells
}
// Optimize the memo comparison function for better performance
// Only re-render if these essential props change
const valueEqual = prev.value === next.value;
const isValidatingEqual = prev.isValidating === next.isValidating;
const itemNumberEqual = prev.itemNumber === next.itemNumber;
// Shallow equality check for errors array
const errorsEqual = prev.errors === next.errors || (
@@ -162,7 +165,7 @@ const MemoizedCell = React.memo(({
);
// Skip checking for props that rarely change
return valueEqual && isValidatingEqual && errorsEqual && optionsEqual && itemNumberEqual;
return valueEqual && isValidatingEqual && errorsEqual && optionsEqual;
});
MemoizedCell.displayName = 'MemoizedCell';
@@ -336,10 +339,28 @@ const ValidationTable = <T extends string>({
return isValidatingUpc?.(rowIndex) || validatingUpcRows.includes(rowIndex);
}, [isValidatingUpc, validatingUpcRows]);
// Use upcValidationResults for display
// Use upcValidationResults for display, prioritizing the most recent values
const getRowUpcResult = useCallback((rowIndex: number) => {
return upcValidationResults?.get(rowIndex)?.itemNumber;
}, [upcValidationResults]);
// ALWAYS get from the data array directly - most authoritative source
const rowData = data[rowIndex];
if (rowData && rowData.item_number) {
return rowData.item_number;
}
// Maps are only backup sources when data doesn't have a value
const itemNumberFromMap = itemNumbers.get(rowIndex);
if (itemNumberFromMap) {
return itemNumberFromMap;
}
// Last resort - upcValidationResults
const upcResult = upcValidationResults?.get(rowIndex)?.itemNumber;
if (upcResult) {
return upcResult;
}
return undefined;
}, [data, itemNumbers, upcValidationResults]);
// Memoize field columns with stable handlers
const fieldColumns = useMemo(() => fields.map((field): ColumnDef<RowData<T>, any> | null => {
@@ -412,26 +433,34 @@ const ValidationTable = <T extends string>({
disabled: false
};
// Debug logging
console.log(`Field ${fieldKey} in ValidationTable (after deep clone):`, {
originalField: field,
modifiedField: fieldWithType,
options,
hasOptions: options && options.length > 0,
disabled: fieldWithType.disabled
});
}
// Get item number from UPC validation results if available
let itemNumber = itemNumbers.get(row.index);
if (!itemNumber && fieldKey === 'item_number') {
itemNumber = getRowUpcResult(row.index);
// CRITICAL CHANGE: Get item number directly from row data first for item_number fields
let itemNumber;
if (fieldKey === 'item_number') {
// Check directly in row data first - this is the most accurate source
const directValue = row.original[fieldKey];
if (directValue) {
itemNumber = directValue;
} else {
// Fall back to centralized getter that checks all sources
itemNumber = getRowUpcResult(row.index);
}
}
// CRITICAL: For item_number fields, create a unique key that includes the itemNumber value
// This forces a complete re-render when the itemNumber changes
const cellKey = fieldKey === 'item_number'
? `cell-${row.index}-${fieldKey}-${itemNumber || 'empty'}-${Date.now()}` // Force re-render on every render cycle for item_number
: `cell-${row.index}-${fieldKey}`;
return (
<MemoizedCell
key={cellKey} // CRITICAL: Add key to force re-render when itemNumber changes
field={fieldWithType as Field<string>}
value={row.original[field.key as keyof typeof row.original]}
value={fieldKey === 'item_number' && row.original[field.key]
? row.original[field.key] // Use direct value from row data
: row.original[field.key as keyof typeof row.original]}
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
errors={cellErrors}
isValidating={isLoading}

View File

@@ -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';

View File

@@ -0,0 +1,161 @@
import { useCallback } from 'react';
import type { Field, Fields, RowHook } from '../../../types';
import type { Meta } from '../types';
import { ErrorType, ValidationError } from '../../../types';
import { RowData, 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 = <T extends string>(
fields: Fields<T>,
rowHook?: RowHook<T>
) => {
// Validate a single field
const validateField = useCallback((
value: any,
field: Field<T>
): 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<T>,
rowIndex: number,
allRows: RowData<T>[]
): Promise<Meta> => {
// Run field-level validations
const fieldErrors: Record<string, ValidationError[]> = {};
fields.forEach(field => {
const value = row[String(field.key) as keyof typeof row];
const errors = validateField(value, field as Field<T>);
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
};
};

View File

@@ -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 = <T extends string>(
data: RowData<T>[],
fields: Fields<T>,
validationErrors: Map<number, Record<string, ValidationError[]>>
) => {
// Filter state
const [filters, setFilters] = useState<FilterState>({
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<string>();
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<FilterState>) => {
setFilters((prev) => ({
...prev,
...newFilters,
}));
}, []);
// Reset filters
const resetFilters = useCallback(() => {
setFilters({
searchText: "",
showErrorsOnly: false,
filterField: null,
filterValue: null,
});
}, []);
return {
filters,
filteredData,
filterFields,
filterValues,
updateFilters,
resetFilters
};
};

View File

@@ -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 = <T extends string>(
data: RowData<T>[],
fields: Fields<T>,
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
validateFieldFromHook: (value: any, field: Field<T>) => 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<T>);
// 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<string, ValidationError[]> = {};
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<T>);
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<T>
).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<T>);
// 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<string, ValidationError[]> = {};
// 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<T>);
// 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
};
};

View File

@@ -0,0 +1,516 @@
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 = <T extends string>(
data: RowData<T>[],
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
rowSelection: RowSelectionState,
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
setRowValidationStatus: React.Dispatch<React.SetStateAction<Map<number, "pending" | "validating" | "validated" | "error">>>,
validateRow: (rowIndex: number, specificField?: string) => void,
isApplyingTemplateRef: React.MutableRefObject<boolean>,
upcValidation: {
validateUpc: (rowIndex: number, supplierId: string, upcValue: string) => Promise<{success: boolean, itemNumber?: string}>,
applyItemNumbersToData: (onApplied?: (updatedRowIds: number[]) => void) => void
},
setValidatingCells?: React.Dispatch<React.SetStateAction<Set<string>>>
) => {
// Template state
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
const [templateState, setTemplateState] = useState<TemplateState>({
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<string, any> = {};
// 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<number, Record<string, ValidationError[]>>();
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<string, any>;
// 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<T>;
// Clear validation errors and mark as validated
batchErrors.set(index, {});
batchStatuses.set(index, "validated");
});
// Check which rows need UPC validation
const upcValidationRows = validRowIndexes.filter((rowIndex) => {
const row = newData[rowIndex];
return row && row.upc && row.supplier;
});
// 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`);
}
// Reset template application flag to allow validation
isApplyingTemplateRef.current = false;
// If there are rows with both UPC and supplier, validate them
if (upcValidationRows.length > 0) {
console.log(`Validating UPCs for ${upcValidationRows.length} rows after template application`);
// Process each row sequentially - this mimics the exact manual edit behavior
const processNextValidation = (index = 0) => {
if (index >= upcValidationRows.length) {
return; // All rows processed
}
const rowIndex = upcValidationRows[index];
const row = newData[rowIndex];
if (row && row.supplier && row.upc) {
// The EXACT implementation from handleUpdateRow when supplier is edited manually:
// 1. Mark the item_number cell as being validated - THIS IS CRITICAL FOR LOADING STATE
const cellKey = `${rowIndex}-item_number`;
// Clear validation errors for this field
setValidationErrors(prev => {
const newErrors = new Map(prev);
if (newErrors.has(rowIndex)) {
const rowErrors = { ...newErrors.get(rowIndex) };
if (rowErrors.item_number) {
delete rowErrors.item_number;
}
newErrors.set(rowIndex, rowErrors);
}
return newErrors;
});
// Set loading state - using setValidatingCells from props
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.add(cellKey);
return newSet;
});
}
// Validate UPC for this row
upcValidation.validateUpc(rowIndex, row.supplier.toString(), row.upc.toString())
.then(result => {
if (result.success && result.itemNumber) {
// CRITICAL FIX: Directly update data with the item number to ensure immediate UI update
setData(prevData => {
const newData = [...prevData];
// Update this specific row with the item number
if (rowIndex >= 0 && rowIndex < newData.length) {
newData[rowIndex] = {
...newData[rowIndex],
item_number: result.itemNumber
};
}
return newData;
});
// Also trigger other relevant updates
upcValidation.applyItemNumbersToData();
// Mark for revalidation after item numbers are updated
setTimeout(() => {
// Validate the row EXACTLY like in manual edit
validateRow(rowIndex, 'item_number');
// CRITICAL FIX: Make one final check to ensure data is correct
setTimeout(() => {
// Get the current item number from the data
const currentItemNumber = (() => {
try {
const dataAtThisPointInTime = data[rowIndex];
return dataAtThisPointInTime?.item_number;
} catch (e) {
return undefined;
}
})();
// If the data is wrong at this point, fix it directly
if (currentItemNumber !== result.itemNumber) {
// Directly update the data to fix the issue
setData(dataRightNow => {
const fixedData = [...dataRightNow];
if (rowIndex >= 0 && rowIndex < fixedData.length) {
fixedData[rowIndex] = {
...fixedData[rowIndex],
item_number: result.itemNumber
};
}
return fixedData;
});
// Then do a force update after a brief delay
setTimeout(() => {
setData(currentData => {
// Critical fix: ensure the item number is correct
if (currentData[rowIndex] && currentData[rowIndex].item_number !== result.itemNumber) {
// Create a completely new array with the correct item number
const fixedData = [...currentData];
fixedData[rowIndex] = {
...fixedData[rowIndex],
item_number: result.itemNumber
};
return fixedData;
}
// Create a completely new array
return [...currentData];
});
}, 20);
} else {
// Item number is already correct, just do the force update
setData(currentData => {
// Create a completely new array
return [...currentData];
});
}
}, 50);
// Clear loading state
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}
// Continue to next row after validation is complete
setTimeout(() => processNextValidation(index + 1), 100);
}, 100);
} else {
// Clear loading state on failure
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}
// Continue to next row if validation fails
setTimeout(() => processNextValidation(index + 1), 100);
}
})
.catch(err => {
console.error(`Error validating UPC for row ${rowIndex}:`, err);
// Clear loading state on error
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}
// Continue to next row despite error
setTimeout(() => processNextValidation(index + 1), 100);
});
} else {
// Skip this row and continue to the next
processNextValidation(index + 1);
}
};
// Start processing validations
processNextValidation();
}
},
[
data,
templates,
setData,
setValidationErrors,
setRowValidationStatus,
validateRow,
upcValidation,
setValidatingCells
]
);
// 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
};
};

View File

@@ -1,263 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { RowSelectionState } from '@tanstack/react-table'
import { Template, RowData, getApiUrl } from './useValidationState'
interface TemplateState {
selectedTemplateId: string | null
showSaveTemplateDialog: boolean
newTemplateName: string
newTemplateType: string
}
export const useTemplates = <T extends string>(
data: RowData<T>[],
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
toast: any,
rowSelection: RowSelectionState
) => {
const [templates, setTemplates] = useState<Template[]>([])
const [templateState, setTemplateState] = useState<TemplateState>({
selectedTemplateId: null,
showSaveTemplateDialog: false,
newTemplateName: '',
newTemplateType: '',
})
// Load templates from API
const loadTemplates = useCallback(async () => {
try {
console.log('Fetching templates...');
const response = await fetch(`${getApiUrl()}/templates`)
console.log('Templates response status:', response.status);
if (!response.ok) throw new Error('Failed to fetch templates')
const templateData = await response.json()
console.log('Templates fetched successfully:', templateData);
// Validate template data
const validTemplates = templateData.filter((t: any) =>
t && typeof t === 'object' && t.id && t.company && t.product_type
);
if (validTemplates.length !== templateData.length) {
console.warn('Some templates were filtered out due to invalid data', {
original: templateData.length,
valid: validTemplates.length
});
}
setTemplates(validTemplates)
} catch (error) {
console.error('Error loading templates:', error)
toast({
title: 'Error',
description: 'Failed to load templates',
})
}
}, [toast])
// Save a new template based on selected rows
const saveTemplate = useCallback(async (name: string, type: string) => {
try {
// Get selected rows
const selectedRows = Object.keys(rowSelection)
.map(index => data[parseInt(index)])
.filter(Boolean)
if (selectedRows.length === 0) {
toast({
title: 'Error',
description: 'Please select at least one row to create a template',
})
return
}
// Create template based on selected rows
const template: Template = {
id: Date.now(), // Temporary ID, will be replaced by server
company: selectedRows[0].company as string || '',
product_type: type,
...selectedRows[0], // Copy all fields from the first selected row
}
// Remove metadata fields
delete (template as any).__meta
delete (template as any).__template
delete (template as any).__original
delete (template as any).__corrected
delete (template as any).__changes
// Send to API
const response = await fetch(`${getApiUrl()}/templates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
company: template.company,
product_type: type,
...Object.fromEntries(
Object.entries(template).filter(([key]) =>
!['company', 'product_type'].includes(key)
)
)
}),
})
if (!response.ok) {
throw new Error('Failed to save template')
}
// Reload templates to get the server-generated ID
await loadTemplates()
toast({
title: 'Success',
description: `Template "${name}" saved successfully`,
})
// Reset dialog state
setTemplateState(prev => ({
...prev,
showSaveTemplateDialog: false,
newTemplateName: '',
newTemplateType: '',
}))
} catch (error) {
console.error('Error saving template:', error)
toast({
title: 'Error',
description: 'Failed to save template',
})
}
}, [data, rowSelection, toast, loadTemplates])
// Apply a template to selected rows
const applyTemplate = useCallback((templateId: string, rowIndexes: number[]) => {
const template = templates.find(t => t.id.toString() === templateId)
if (!template) {
toast({
title: 'Error',
description: 'Template not found',
})
return
}
setData(prevData => {
const newData = [...prevData]
rowIndexes.forEach(index => {
if (index >= 0 && index < newData.length) {
// Create a new row with template values
const updatedRow = { ...newData[index] }
// Apply template fields (excluding metadata and ID fields)
Object.entries(template).forEach(([key, value]) => {
if (!['id', 'company', 'product_type', 'created_at', 'updated_at'].includes(key)) {
// Handle numeric values that might be stored as strings
if (typeof value === 'string' && /^\d+(\.\d+)?$/.test(value)) {
// If it's a price field, add the dollar sign
if (['msrp', 'cost_each'].includes(key)) {
updatedRow[key as keyof typeof updatedRow] = `$${value}` as any;
} else {
updatedRow[key as keyof typeof updatedRow] = value as any;
}
}
// Special handling for array fields like categories and ship_restrictions
else if (key === 'categories' || key === 'ship_restrictions') {
if (Array.isArray(value)) {
updatedRow[key as keyof typeof updatedRow] = value as any;
} else if (typeof value === 'string') {
try {
// Try to parse as JSON if it's a JSON string
if (value.startsWith('[') && value.endsWith(']')) {
const parsed = JSON.parse(value);
updatedRow[key as keyof typeof updatedRow] = parsed as any;
}
// Otherwise, it might be a PostgreSQL array format like {val1,val2}
else if (value.startsWith('{') && value.endsWith('}')) {
const parsed = value.slice(1, -1).split(',');
updatedRow[key as keyof typeof updatedRow] = parsed as any;
}
// If it's a single value, wrap it in an array
else {
updatedRow[key as keyof typeof updatedRow] = [value] as any;
}
} catch (error) {
console.error(`Error parsing ${key}:`, error);
// If parsing fails, use as-is
updatedRow[key as keyof typeof updatedRow] = value as any;
}
} else {
updatedRow[key as keyof typeof updatedRow] = value as any;
}
} else {
updatedRow[key as keyof typeof updatedRow] = value as any;
}
}
})
// Mark the row as using this template
updatedRow.__template = templateId
// Update the row in the data array
newData[index] = updatedRow
}
})
return newData
})
toast({
title: 'Success',
description: `Template applied to ${rowIndexes.length} row(s)`,
})
}, [templates, setData, toast])
// Get display text for a template
const getTemplateDisplayText = useCallback((templateId: string | null) => {
if (!templateId) return 'Select a template'
const template = templates.find(t => t.id.toString() === templateId)
return template
? `${template.company} - ${template.product_type}`
: 'Unknown template'
}, [templates])
// Load templates on component mount and set up refresh event listener
useEffect(() => {
loadTemplates()
// Add event listener for template refresh
const handleRefreshTemplates = () => {
loadTemplates()
}
window.addEventListener('refresh-templates', handleRefreshTemplates)
// Clean up event listener
return () => {
window.removeEventListener('refresh-templates', handleRefreshTemplates)
}
}, [loadTemplates])
return {
templates,
selectedTemplateId: templateState.selectedTemplateId,
showSaveTemplateDialog: templateState.showSaveTemplateDialog,
newTemplateName: templateState.newTemplateName,
newTemplateType: templateState.newTemplateType,
setTemplateState,
loadTemplates,
saveTemplate,
applyTemplate,
getTemplateDisplayText,
// Helper method to apply to selected rows
applyTemplateToSelected: (templateId: string) => {
const selectedIndexes = Object.keys(rowSelection).map(i => parseInt(i))
applyTemplate(templateId, selectedIndexes)
}
}
}

View File

@@ -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 = <T extends string>(
data: RowData<T>[],
fields: Fields<T>,
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>
) => {
// 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<string, Map<string, number[]>>();
// 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<string, number[]>());
});
// Initialize batch updates
const errors = new Map<number, Record<string, ValidationError[]>>();
// 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
};
};

View File

@@ -0,0 +1,131 @@
import { useCallback } from 'react';
import type { Fields } from '../../../types';
import { ErrorSources, ErrorType } from '../../../types';
import { RowData, InfoWithSource, isEmpty } from './validationTypes';
export const useUniqueValidation = <T extends string>(
fields: Fields<T>
) => {
// Additional function to explicitly validate uniqueness for specified fields
const validateUniqueField = useCallback((data: RowData<T>[], fieldKey: string) => {
// Field keys that need special handling for uniqueness
const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
// If the field doesn't need uniqueness validation, return empty errors
if (!uniquenessFields.includes(fieldKey)) {
const field = fields.find(f => String(f.key) === fieldKey);
if (!field || !field.validations?.some(v => v.rule === 'unique')) {
return new Map<number, Record<string, InfoWithSource>>();
}
}
// Create map to track errors
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
// Find the field definition
const field = fields.find(f => String(f.key) === fieldKey);
if (!field) return uniqueErrors;
// Get validation properties
const validation = field.validations?.find(v => v.rule === 'unique');
const allowEmpty = validation?.allowEmpty ?? false;
const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
const level = validation?.level || 'error';
// Track values for uniqueness check
const valueMap = new Map<string, number[]>();
// Build value map
data.forEach((row, rowIndex) => {
const value = String(row[fieldKey as keyof typeof row] || '');
// Skip empty values if allowed
if (allowEmpty && isEmpty(value)) {
return;
}
if (!valueMap.has(value)) {
valueMap.set(value, [rowIndex]);
} else {
valueMap.get(value)?.push(rowIndex);
}
});
// Add errors for duplicate values
valueMap.forEach((rowIndexes, value) => {
if (rowIndexes.length > 1) {
// Skip empty values
if (!value || value.trim() === '') return;
// Add error to all duplicate rows
rowIndexes.forEach(rowIndex => {
// Create errors object if needed
if (!uniqueErrors.has(rowIndex)) {
uniqueErrors.set(rowIndex, {});
}
// Add error for this field
uniqueErrors.get(rowIndex)![fieldKey] = {
message: errorMessage,
level: level as 'info' | 'warning' | 'error',
source: ErrorSources.Table,
type: ErrorType.Unique
};
});
}
});
return uniqueErrors;
}, [fields]);
// Validate uniqueness for multiple fields
const validateUniqueFields = useCallback((data: RowData<T>[], fieldKeys: string[]) => {
// Process each field and merge results
const allErrors = new Map<number, Record<string, InfoWithSource>>();
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<T>[]) => {
// 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
};
};

View File

@@ -30,13 +30,6 @@ export const useUpcValidation = (
const processedUpcMapRef = useRef(new Map<string, string>());
const initialUpcValidationDoneRef = useRef(false);
// For batch validation
const validationQueueRef = useRef<Array<{rowIndex: number, supplierId: string, upcValue: string}>>([]);
const isProcessingBatchRef = useRef(false);
// For validation results
const [upcValidationResults] = useState<Map<number, { itemNumber: string }>>(new Map());
// Helper to create cell key
const getCellKey = (rowIndex: number, fieldKey: string) => `${rowIndex}-${fieldKey}`;
@@ -56,17 +49,40 @@ export const useUpcValidation = (
// Update item number
const updateItemNumber = useCallback((rowIndex: number, itemNumber: string) => {
console.log(`Setting item number for row ${rowIndex} to ${itemNumber}`);
validationStateRef.current.itemNumbers.set(rowIndex, itemNumber);
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
}, []);
// CRITICAL: Update BOTH the data state and the ref
// First, update the data directly to ensure UI consistency
setData(prevData => {
// Create a new copy of the data
const newData = [...prevData];
// Mark a row as being validated
const startValidatingRow = useCallback((rowIndex: number) => {
validationStateRef.current.validatingRows.add(rowIndex);
setValidatingRows(new Set(validationStateRef.current.validatingRows));
setIsValidatingUpc(true);
}, []);
// Only update if the row exists
if (rowIndex >= 0 && rowIndex < newData.length) {
// First, we need a new object reference for the row to force a re-render
newData[rowIndex] = {
...newData[rowIndex],
item_number: itemNumber
};
}
return newData;
});
// Also update the itemNumbers map AFTER the data is updated
// This ensures the map reflects the current state of the data
setTimeout(() => {
// Update the ref with the same value
validationStateRef.current.itemNumbers.set(rowIndex, itemNumber);
// CRITICAL: Force a React state update to ensure all components re-render
// Created a brand new Map object to ensure React detects the change
const newItemNumbersMap = new Map(validationStateRef.current.itemNumbers);
setItemNumberUpdates(newItemNumbersMap);
// Force an immediate React render cycle by triggering state updates
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
setValidatingRows(new Set(validationStateRef.current.validatingRows));
}, 0);
}, [setData]);
// Mark a row as no longer being validated
const stopValidatingRow = useCallback((rowIndex: number) => {
@@ -139,11 +155,22 @@ export const useUpcValidation = (
);
previousKeys.forEach(key => validationStateRef.current.activeValidations.delete(key));
// Start validation - track this with the ref to avoid race conditions
startValidatingRow(rowIndex);
startValidatingCell(rowIndex, 'item_number');
// Log validation start to help debug template issues
console.log(`[UPC-DEBUG] Starting UPC validation for row ${rowIndex} with supplier ${supplierId}, upc ${upcValue}`);
console.log(`Validating UPC: rowIndex=${rowIndex}, supplierId=${supplierId}, upc=${upcValue}`);
// IMPORTANT: Set validation state using setState to FORCE UI updates
validationStateRef.current.validatingRows.add(rowIndex);
setValidatingRows(new Set(validationStateRef.current.validatingRows));
setIsValidatingUpc(true);
// Start cell validation and explicitly update UI via setState
const cellKey = getCellKey(rowIndex, 'item_number');
validationStateRef.current.validatingCells.add(cellKey);
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
console.log(`[UPC-DEBUG] Set loading state for row ${rowIndex}, cell key ${cellKey}`);
console.log(`[UPC-DEBUG] Current validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
console.log(`[UPC-DEBUG] Current validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
try {
// Create a unique key for this validation to track it
@@ -164,18 +191,43 @@ export const useUpcValidation = (
});
// Fetch the product by UPC
console.log(`[UPC-DEBUG] Fetching product data for UPC ${upcValue} with supplier ${supplierId}`);
const product = await fetchProductByUpc(supplierId, upcValue);
console.log(`[UPC-DEBUG] Fetch complete for row ${rowIndex}, success: ${!product.error}`);
// Check if this validation is still relevant (hasn't been superseded by another)
if (!validationStateRef.current.activeValidations.has(validationKey)) {
console.log(`Validation ${validationKey} was cancelled`);
console.log(`[UPC-DEBUG] Validation ${validationKey} was cancelled`);
return { success: false };
}
// Extract the item number from the API response - check for !error since API returns { error: boolean, data: any }
if (product && !product.error && product.data?.itemNumber) {
// Store this validation result
updateItemNumber(rowIndex, product.data.itemNumber);
console.log(`[UPC-DEBUG] Got item number for row ${rowIndex}: ${product.data.itemNumber}`);
// CRITICAL FIX: Directly update the data with the new item number first
setData(prevData => {
const newData = [...prevData];
if (rowIndex >= 0 && rowIndex < newData.length) {
// This should happen before updating the map
newData[rowIndex] = {
...newData[rowIndex],
item_number: product.data.itemNumber
};
}
return newData;
});
// Then, update the map to match what's now in the data
validationStateRef.current.itemNumbers.set(rowIndex, product.data.itemNumber);
// CRITICAL: Force a React state update to ensure all components re-render
// Created a brand new Map object to ensure React detects the change
const newItemNumbersMap = new Map(validationStateRef.current.itemNumbers);
setItemNumberUpdates(newItemNumbersMap);
// Force a shallow copy of the itemNumbers map to trigger useEffect dependencies
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
return {
success: true,
@@ -183,7 +235,7 @@ export const useUpcValidation = (
};
} else {
// No item number found but validation was still attempted
console.log(`No item number found for UPC ${upcValue}`);
console.log(`[UPC-DEBUG] No item number found for UPC ${upcValue}`);
// Clear any existing item number to show validation was attempted and failed
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
@@ -194,157 +246,74 @@ export const useUpcValidation = (
return { success: false };
}
} catch (error) {
console.error('Error validating UPC:', error);
console.error('[UPC-DEBUG] Error validating UPC:', error);
return { success: false };
} finally {
// End validation
stopValidatingRow(rowIndex);
stopValidatingCell(rowIndex, 'item_number');
// End validation - FORCE UI update by using setState directly
console.log(`[UPC-DEBUG] Ending validation for row ${rowIndex}`);
validationStateRef.current.validatingRows.delete(rowIndex);
setValidatingRows(new Set(validationStateRef.current.validatingRows));
if (validationStateRef.current.validatingRows.size === 0) {
setIsValidatingUpc(false);
}
validationStateRef.current.validatingCells.delete(cellKey);
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
console.log(`[UPC-DEBUG] Cleared loading state for row ${rowIndex}`);
console.log(`[UPC-DEBUG] Updated validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
console.log(`[UPC-DEBUG] Updated validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
}
}, [fetchProductByUpc, updateItemNumber, startValidatingCell, stopValidatingCell, startValidatingRow, stopValidatingRow, setData]);
}, [fetchProductByUpc, updateItemNumber, setData]);
// Apply item numbers to data
const applyItemNumbersToData = useCallback((onApplied?: (updatedRowIds: number[]) => void) => {
// Create a copy of the current item numbers map to avoid race conditions
const currentItemNumbers = new Map(validationStateRef.current.itemNumbers);
// Apply all pending item numbers to the data state
const applyItemNumbersToData = useCallback((callback?: (updatedRows: number[]) => void) => {
// Skip if we have nothing to apply
if (validationStateRef.current.itemNumbers.size === 0) {
if (callback) callback([]);
return;
}
// Only apply if we have any item numbers
if (currentItemNumbers.size === 0) return;
// Track updated row indices to pass to callback
const updatedRowIndices: number[] = [];
// Log for debugging
console.log(`Applying ${currentItemNumbers.size} item numbers to data`);
// Gather all row IDs that will be updated
const rowIds: number[] = [];
// Update the data state with all item numbers
setData(prevData => {
// Create a new copy of the data
const newData = [...prevData];
// Update each row with its item number without affecting other fields
currentItemNumbers.forEach((itemNumber, rowIndex) => {
if (rowIndex < newData.length) {
console.log(`Setting item_number for row ${rowIndex} to ${itemNumber}`);
// Apply each item number to the data
validationStateRef.current.itemNumbers.forEach((itemNumber, rowIndex) => {
// Ensure row exists and value has actually changed
if (rowIndex >= 0 && rowIndex < newData.length &&
newData[rowIndex]?.item_number !== itemNumber) {
// Only update the item_number field, leaving other fields unchanged
// Create a new row object to force re-rendering
newData[rowIndex] = {
...newData[rowIndex],
item_number: itemNumber
};
// Track which rows were updated
updatedRowIndices.push(rowIndex);
// Track which row was updated for the callback
rowIds.push(rowIndex);
}
});
return newData;
});
// Call the callback if provided, after state updates are processed
if (onApplied && updatedRowIndices.length > 0) {
// Use setTimeout to ensure this happens after the state update
setTimeout(() => {
onApplied(updatedRowIndices);
}, 100); // Use 100ms to ensure the data update is fully processed
// Force a re-render by updating React state
setTimeout(() => {
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
}, 0);
// Call the callback with the updated row IDs
if (callback) {
callback(rowIds);
}
}, [setData]);
// Process validation queue in batches - faster processing with smaller batches
const processBatchValidation = useCallback(async () => {
if (isProcessingBatchRef.current) return;
if (validationQueueRef.current.length === 0) return;
console.log(`Processing validation batch with ${validationQueueRef.current.length} items`);
isProcessingBatchRef.current = true;
// Process in smaller batches for better UI responsiveness
const BATCH_SIZE = 5;
const queue = [...validationQueueRef.current];
validationQueueRef.current = [];
// Track if any updates were made
let updatesApplied = false;
// Track updated row indices
const updatedRows: number[] = [];
try {
// Process in small batches
for (let i = 0; i < queue.length; i += BATCH_SIZE) {
const batch = queue.slice(i, i + BATCH_SIZE);
// Process batch in parallel
const results = await Promise.all(batch.map(async ({ rowIndex, supplierId, upcValue }) => {
try {
// Skip if already validated
const cacheKey = `${supplierId}-${upcValue}`;
if (processedUpcMapRef.current.has(cacheKey)) {
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
if (cachedItemNumber) {
console.log(`Using cached item number for row ${rowIndex}: ${cachedItemNumber}`);
updateItemNumber(rowIndex, cachedItemNumber);
updatesApplied = true;
updatedRows.push(rowIndex);
return true;
}
return false;
}
// Fetch from API
const result = await fetchProductByUpc(supplierId, upcValue);
if (!result.error && result.data?.itemNumber) {
const itemNumber = result.data.itemNumber;
// Store in cache
processedUpcMapRef.current.set(cacheKey, itemNumber);
// Update item number
updateItemNumber(rowIndex, itemNumber);
updatesApplied = true;
updatedRows.push(rowIndex);
console.log(`Set item number for row ${rowIndex} to ${itemNumber}`);
return true;
}
return false;
} catch (error) {
console.error(`Error processing row ${rowIndex}:`, error);
return false;
} finally {
// Clear validation state
stopValidatingRow(rowIndex);
}
}));
// If any updates were applied in this batch, update the data
if (results.some(Boolean) && updatesApplied) {
applyItemNumbersToData(updatedRowIds => {
console.log(`Processed batch UPC validation for rows: ${updatedRowIds.join(', ')}`);
});
updatesApplied = false;
updatedRows.length = 0; // Clear the array
}
// Small delay between batches to allow UI to update
if (i + BATCH_SIZE < queue.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
}
} catch (error) {
console.error('Error in batch processing:', error);
} finally {
isProcessingBatchRef.current = false;
// Process any new items
if (validationQueueRef.current.length > 0) {
setTimeout(processBatchValidation, 0);
}
}
}, [fetchProductByUpc, updateItemNumber, stopValidatingRow, applyItemNumbersToData]);
// For immediate processing
// Batch validate all UPCs in the data
const validateAllUPCs = useCallback(async () => {
// Skip if we've already done the initial validation
@@ -508,8 +477,8 @@ export const useUpcValidation = (
getItemNumber,
applyItemNumbersToData,
// Results
upcValidationResults,
// CRITICAL: Expose the itemNumbers map directly
itemNumbers: validationStateRef.current.itemNumbers,
// Initialization state
initialValidationDone: initialUpcValidationDoneRef.current

View File

@@ -1,336 +1,23 @@
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();
const validationCache: Record<string, any> = {};
// Add a function to clear cache for a specific field value
export const clearValidationCacheForField = (fieldKey: string) => {
// Clear cache
const cacheKey = `field_${fieldKey}`;
delete validationCache[cacheKey];
};
// 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 type { Field, Fields, RowHook } from '../../../types'
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 = <T extends string>(
fields: Fields<T>,
rowHook?: RowHook<T>,
tableHook?: TableHook<T>
rowHook?: RowHook<T>
) => {
// Validate a single field
const validateField = useCallback((
value: any,
field: Field<T>
): ValidationError[] => {
const errors: ValidationError[] = []
// Use the field validation hook
const { validateField, validateRow } = useFieldValidation(fields, rowHook);
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<T>,
rowIndex: number,
allRows: RowData<T>[]
): Promise<Meta> => {
// Run field-level validations
const fieldErrors: Record<string, ValidationError[]> = {}
// 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<T>)
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])
// Validate all data at the table level
const validateTable = useCallback(async (data: RowData<T>[]): Promise<Meta[]> => {
if (!tableHook) {
return data.map((row, index) => ({
__index: row.__index || String(index)
}))
}
try {
const tableResults = await tableHook(data)
// Process table validation results
return tableResults.map((result, index) => {
return {
__index: result.__index || data[index].__index || String(index)
}
})
} catch (error) {
console.error('Error in table hook:', error)
return data.map((row, index) => ({
__index: row.__index || String(index)
}))
}
}, [tableHook])
// Validate unique fields across the table
const validateUnique = useCallback((data: RowData<T>[]) => {
// Create a map to store errors for each row
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
// Find fields with unique validation
const uniqueFields = fields.filter(field =>
field.validations?.some(v => v.rule === 'unique')
);
if (uniqueFields.length === 0) {
return uniqueErrors;
}
// Check each unique field
uniqueFields.forEach(field => {
const { key } = field;
const validation = field.validations?.find(v => v.rule === 'unique');
const allowEmpty = validation?.allowEmpty ?? false;
const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
const level = validation?.level || 'error';
// Track values for uniqueness check
const valueMap = new Map<string, number[]>();
// Build value map
data.forEach((row, rowIndex) => {
const value = String(row[String(key) 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) => {
if (rowIndexes.length > 1) {
// Add error to all duplicate rows
rowIndexes.forEach(rowIndex => {
// Get existing errors for this row or create a new object
const rowErrors = uniqueErrors.get(rowIndex) || {};
rowErrors[String(key)] = {
message: errorMessage,
level,
source: ErrorSources.Table,
type: ErrorType.Unique
};
uniqueErrors.set(rowIndex, rowErrors);
});
}
});
});
return uniqueErrors;
}, [fields]);
// Additional function to explicitly validate uniqueness for specified fields
const validateUniqueField = useCallback((data: RowData<T>[], fieldKey: string) => {
// Field keys that need special handling for uniqueness
const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
// If the field doesn't need uniqueness validation, return empty errors
if (!uniquenessFields.includes(fieldKey)) {
const field = fields.find(f => String(f.key) === fieldKey);
if (!field || !field.validations?.some(v => v.rule === 'unique')) {
return new Map<number, Record<string, InfoWithSource>>();
}
}
// Create map to track errors
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
// Find the field definition
const field = fields.find(f => String(f.key) === fieldKey);
if (!field) return uniqueErrors;
// Get validation properties
const validation = field.validations?.find(v => v.rule === 'unique');
const allowEmpty = validation?.allowEmpty ?? false;
const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
const level = validation?.level || 'error';
// Track values for uniqueness check
const valueMap = new Map<string, number[]>();
// Build value map
data.forEach((row, rowIndex) => {
const value = String(row[fieldKey as keyof typeof row] || '');
// Skip empty values if allowed
if (allowEmpty && isEmpty(value)) {
return;
}
if (!valueMap.has(value)) {
valueMap.set(value, [rowIndex]);
} else {
valueMap.get(value)?.push(rowIndex);
}
});
// Add errors for duplicate values
valueMap.forEach((rowIndexes, value) => {
if (rowIndexes.length > 1) {
// Skip empty values
if (!value || value.trim() === '') return;
// Add error to all duplicate rows
rowIndexes.forEach(rowIndex => {
// Create errors object if needed
if (!uniqueErrors.has(rowIndex)) {
uniqueErrors.set(rowIndex, {});
}
// Add error for this field
uniqueErrors.get(rowIndex)![fieldKey] = {
message: errorMessage,
level: level as 'info' | 'warning' | 'error',
source: ErrorSources.Table,
type: ErrorType.Unique
};
});
}
});
return uniqueErrors;
}, [fields]);
// Use the uniqueness validation hook
const {
validateUniqueField,
validateAllUniqueFields
} = useUniqueValidation(fields);
// Run complete validation
const validateData = useCallback(async (data: RowData<T>[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => {
@@ -429,9 +116,6 @@ export const useValidation = <T extends string>(
// 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];
@@ -459,38 +143,15 @@ export const useValidation = <T extends string>(
}
}
// 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');
@@ -500,16 +161,14 @@ export const useValidation = <T extends string>(
data,
validationErrors
};
}, [fields, validateField, validateUniqueField]);
}, [fields, validateField, validateUniqueField, validateAllUniqueFields]);
return {
validateData,
validateField,
validateRow,
validateTable,
validateUnique,
validateUniqueField,
clearValidationCacheForField,
clearAllUniquenessCaches
}
};
}

View File

@@ -0,0 +1,100 @@
import type { Data } from "../../../types";
import { ErrorSources, ErrorType } from "../../../types";
import config from "@/config";
// Define the Props interface for ValidationStepNew
export interface Props<T extends string> {
initialData: RowData<T>[];
file?: File;
onBack?: () => void;
onNext?: (data: RowData<T>[]) => void;
isFromScratch?: boolean;
}
// Extended Data type with meta information
export type RowData<T extends string> = Data<T> & {
__index?: string;
__template?: string;
__original?: Record<string, any>;
__corrected?: Record<string, any>;
__changes?: Record<string, boolean>;
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<T extends string> extends Props<T> {}
// Interface for validation results
export interface ValidationResult {
error?: boolean;
message?: string;
data?: Record<string, any>;
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<string>; // Using rowIndex-fieldKey as identifier
itemNumbers: Map<number, string>; // Using rowIndex as key
validatingRows: Set<number>; // Rows currently being validated
activeValidations: Set<string>; // Active validations
}
// 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);

View File

@@ -1,5 +1,5 @@
import ValidationContainer from './components/ValidationContainer'
import { Props } from './hooks/useValidationState'
import { Props } from './hooks/validationTypes'
/**
* ValidationStepNew component - modern implementation of the validation step

View File

@@ -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<string, any>): Record<string, ErrorType[]> => {
const result: Record<string, ErrorType[]> = {}
Object.entries(errorRecord).forEach(([key, errors]) => {
result[key] = convertToErrorArray(errors)
})
return result
}

View File

@@ -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<any> => {
// 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<void> => {
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')
}
}

View File

@@ -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;
};

View File

@@ -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<any>): 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<any>): 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 = <T extends string>(row: Data<T>): Record<string, ErrorType[]> => {
const errors: Record<string, ErrorType[]> = {}
// 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<string, ErrorType[]>[]): Record<string, ErrorType[]> => {
const merged: Record<string, ErrorType[]> = {}
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
}

File diff suppressed because one or more lines are too long