Fix dropdown values saving, add back checkbox column, mostly fix validation, fix some field types
This commit is contained in:
@@ -742,7 +742,7 @@ router.post("/validate", async (req, res) => {
|
|||||||
|
|
||||||
console.log("🤖 Sending request to OpenAI...");
|
console.log("🤖 Sending request to OpenAI...");
|
||||||
const completion = await openai.chat.completions.create({
|
const completion = await openai.chat.completions.create({
|
||||||
model: "gpt-4o",
|
model: "o3-mini",
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|||||||
@@ -924,16 +924,16 @@ router.get('/check-upc-and-generate-sku', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Generate item number - supplierId-last6DigitsOfUPC minus last digit
|
// Step 2: Generate item number - supplierId-last5DigitsOfUPC minus last digit
|
||||||
let itemNumber = '';
|
let itemNumber = '';
|
||||||
const upcStr = String(upc);
|
const upcStr = String(upc);
|
||||||
|
|
||||||
// Extract the last 6 digits of the UPC, removing the last digit (checksum)
|
// Extract the last 5 digits of the UPC, removing the last digit (checksum)
|
||||||
// So we get 5 digits from positions: length-7 to length-2
|
// So we get 5 digits from positions: length-6 to length-2
|
||||||
if (upcStr.length >= 7) {
|
if (upcStr.length >= 6) {
|
||||||
const lastSixMinusOne = upcStr.substring(upcStr.length - 7, upcStr.length - 1);
|
const lastFiveMinusOne = upcStr.substring(upcStr.length - 6, upcStr.length - 1);
|
||||||
itemNumber = `${supplierId}-${lastSixMinusOne}`;
|
itemNumber = `${supplierId}-${lastFiveMinusOne}`;
|
||||||
} else if (upcStr.length >= 6) {
|
} else if (upcStr.length >= 5) {
|
||||||
// If UPC is shorter, use as many digits as possible
|
// If UPC is shorter, use as many digits as possible
|
||||||
const digitsToUse = upcStr.substring(0, upcStr.length - 1);
|
const digitsToUse = upcStr.substring(0, upcStr.length - 1);
|
||||||
itemNumber = `${supplierId}-${digitsToUse}`;
|
itemNumber = `${supplierId}-${digitsToUse}`;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
import { InfoWithSource } from "../../types"
|
|
||||||
|
|
||||||
export type Meta = { __index: string; __errors?: Error | null }
|
|
||||||
export type Error = { [key: string]: InfoWithSource }
|
|
||||||
export type Errors = { [id: string]: Error }
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import type { Data, Fields, Info, RowHook, TableHook } from "../../../types"
|
|
||||||
import type { Meta, Error, Errors } from "../types"
|
|
||||||
import { v4 } from "uuid"
|
|
||||||
import { ErrorSources } from "../../../types"
|
|
||||||
|
|
||||||
export const addErrorsAndRunHooks = async <T extends string>(
|
|
||||||
data: (Data<T> & Partial<Meta>)[],
|
|
||||||
fields: Fields<T>,
|
|
||||||
rowHook?: RowHook<T>,
|
|
||||||
tableHook?: TableHook<T>,
|
|
||||||
changedRowIndexes?: number[],
|
|
||||||
): Promise<(Data<T> & Meta)[]> => {
|
|
||||||
const errors: Errors = {}
|
|
||||||
|
|
||||||
const addError = (source: ErrorSources, rowIndex: number, fieldKey: T, error: Info) => {
|
|
||||||
errors[rowIndex] = {
|
|
||||||
...errors[rowIndex],
|
|
||||||
[fieldKey]: { ...error, source },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tableHook) {
|
|
||||||
data = await tableHook(data, (...props) => addError(ErrorSources.Table, ...props))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rowHook) {
|
|
||||||
if (changedRowIndexes) {
|
|
||||||
for (const index of changedRowIndexes) {
|
|
||||||
data[index] = await rowHook(data[index], (...props) => addError(ErrorSources.Row, index, ...props), data)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data = await Promise.all(
|
|
||||||
data.map(async (value, index) =>
|
|
||||||
rowHook(value, (...props) => addError(ErrorSources.Row, index, ...props), data),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fields.forEach((field) => {
|
|
||||||
field.validations?.forEach((validation) => {
|
|
||||||
switch (validation.rule) {
|
|
||||||
case "unique": {
|
|
||||||
const values = data.map((entry) => entry[field.key as T])
|
|
||||||
|
|
||||||
const taken = new Set() // Set of items used at least once
|
|
||||||
const duplicates = new Set() // Set of items used multiple times
|
|
||||||
|
|
||||||
values.forEach((value) => {
|
|
||||||
if (validation.allowEmpty && !value) {
|
|
||||||
// If allowEmpty is set, we will not validate falsy fields such as undefined or empty string.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (taken.has(value)) {
|
|
||||||
duplicates.add(value)
|
|
||||||
} else {
|
|
||||||
taken.add(value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
values.forEach((value, index) => {
|
|
||||||
if (duplicates.has(value)) {
|
|
||||||
addError(ErrorSources.Table, index, field.key as T, {
|
|
||||||
level: validation.level || "error",
|
|
||||||
message: validation.errorMessage || "Field must be unique",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "required": {
|
|
||||||
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => data[index]) : data
|
|
||||||
dataToValidate.forEach((entry, index) => {
|
|
||||||
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
|
|
||||||
if (entry[field.key as T] === null || entry[field.key as T] === undefined || entry[field.key as T] === "") {
|
|
||||||
addError(ErrorSources.Row, realIndex, field.key as T, {
|
|
||||||
level: validation.level || "error",
|
|
||||||
message: validation.errorMessage || "Field is required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "regex": {
|
|
||||||
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => data[index]) : data
|
|
||||||
const regex = new RegExp(validation.value, validation.flags)
|
|
||||||
dataToValidate.forEach((entry, index) => {
|
|
||||||
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
|
|
||||||
const value = entry[field.key]?.toString() ?? ""
|
|
||||||
if (!value.match(regex)) {
|
|
||||||
addError(ErrorSources.Row, realIndex, field.key as T, {
|
|
||||||
level: validation.level || "error",
|
|
||||||
message:
|
|
||||||
validation.errorMessage || `Field did not match the regex /${validation.value}/${validation.flags} `,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return data.map((value, index) => {
|
|
||||||
// This is required only for table. Mutates to prevent needless rerenders
|
|
||||||
if (!("__index" in value)) {
|
|
||||||
value.__index = v4()
|
|
||||||
}
|
|
||||||
const newValue = value as Data<T> & Meta
|
|
||||||
|
|
||||||
// If we are validating all indexes, or we did full validation on this row - apply all errors
|
|
||||||
if (!changedRowIndexes || changedRowIndexes.includes(index)) {
|
|
||||||
if (errors[index]) {
|
|
||||||
return { ...newValue, __errors: errors[index] }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!errors[index] && value?.__errors) {
|
|
||||||
return { ...newValue, __errors: null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// if we have not validated this row, keep it's row errors but apply global error changes
|
|
||||||
else {
|
|
||||||
// at this point errors[index] contains only table source errors, previous row and table errors are in value.__errors
|
|
||||||
const hasRowErrors =
|
|
||||||
value.__errors && Object.values(value.__errors).some((error) => error.source === ErrorSources.Row)
|
|
||||||
|
|
||||||
if (!hasRowErrors) {
|
|
||||||
if (errors[index]) {
|
|
||||||
return { ...newValue, __errors: errors[index] }
|
|
||||||
}
|
|
||||||
return newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorsWithoutTableError = Object.entries(value.__errors!).reduce((acc, [key, value]) => {
|
|
||||||
if (value.source === ErrorSources.Row) {
|
|
||||||
acc[key] = value
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, {} as Error)
|
|
||||||
|
|
||||||
const newErrors = { ...errorsWithoutTableError, ...errors[index] }
|
|
||||||
|
|
||||||
return { ...newValue, __errors: newErrors }
|
|
||||||
}
|
|
||||||
|
|
||||||
return newValue
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Loader2, CheckIcon } from 'lucide-react';
|
import { Loader2, CheckIcon } from 'lucide-react';
|
||||||
import { Code } from '@/components/ui/code';
|
import { Code } from '@/components/ui/code';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { AiValidationDetails, AiValidationProgress, CurrentPrompt, ProductChangeDetail } from '../hooks/useAiValidation';
|
import { AiValidationDetails, AiValidationProgress, CurrentPrompt } from '../hooks/useAiValidation';
|
||||||
|
|
||||||
interface AiValidationDialogsProps {
|
interface AiValidationDialogsProps {
|
||||||
aiValidationProgress: AiValidationProgress;
|
aiValidationProgress: AiValidationProgress;
|
||||||
@@ -79,7 +79,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all duration-500"
|
className="h-full bg-primary transition-all duration-500"
|
||||||
style={{
|
style={{
|
||||||
width: `${aiValidationProgress.progressPercent ?? Math.floor((aiValidationProgress.step / 5) * 100)}%`,
|
width: `${aiValidationProgress.progressPercent ?? Math.round((aiValidationProgress.step / 5) * 100)}%`,
|
||||||
backgroundColor: aiValidationProgress.step === -1 ? 'var(--destructive)' : undefined
|
backgroundColor: aiValidationProgress.step === -1 ? 'var(--destructive)' : undefined
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
useEffect(() => {
|
||||||
|
// Only log when selectedBrand changes or on mount
|
||||||
|
console.debug('SearchableTemplateSelect brand update:', {
|
||||||
|
selectedBrand,
|
||||||
|
defaultBrand,
|
||||||
|
templatesForBrand: templates?.filter(t => t.company === selectedBrand)?.length || 0
|
||||||
|
});
|
||||||
|
}, [selectedBrand, defaultBrand, templates]);
|
||||||
|
|
||||||
// Set default brand when component mounts or defaultBrand changes
|
// Set default brand when component mounts or defaultBrand changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (defaultBrand) {
|
if (defaultBrand) {
|
||||||
@@ -158,11 +168,14 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
|||||||
const getDisplayText = useCallback(() => {
|
const getDisplayText = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
if (!value) return placeholder;
|
if (!value) return placeholder;
|
||||||
|
const template = templates.find(t => t.id.toString() === value);
|
||||||
|
if (!template) return placeholder;
|
||||||
return getTemplateDisplayText(value);
|
return getTemplateDisplayText(value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Error getting display text:', err);
|
||||||
return placeholder;
|
return placeholder;
|
||||||
}
|
}
|
||||||
}, [getTemplateDisplayText, placeholder, value]);
|
}, [getTemplateDisplayText, placeholder, value, templates]);
|
||||||
|
|
||||||
// Safe render function for CommandItem
|
// Safe render function for CommandItem
|
||||||
const renderCommandItem = useCallback((template: Template) => {
|
const renderCommandItem = useCallback((template: Template) => {
|
||||||
@@ -181,7 +194,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Error selecting template");
|
console.error('Error selecting template:', err);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
@@ -191,6 +204,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
|||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Error rendering template item:', err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [onValueChange, value, getTemplateDisplayText]);
|
}, [onValueChange, value, getTemplateDisplayText]);
|
||||||
|
|||||||
@@ -51,10 +51,21 @@ const BaseCellContent = React.memo(({
|
|||||||
hasErrors: boolean;
|
hasErrors: boolean;
|
||||||
options?: readonly any[];
|
options?: readonly any[];
|
||||||
}) => {
|
}) => {
|
||||||
|
// Get field type information
|
||||||
const fieldType = typeof field.fieldType === 'string'
|
const fieldType = typeof field.fieldType === 'string'
|
||||||
? field.fieldType
|
? field.fieldType
|
||||||
: field.fieldType?.type || 'input';
|
: field.fieldType?.type || 'input';
|
||||||
|
|
||||||
|
// Check for multiline input
|
||||||
|
const isMultiline = typeof field.fieldType === 'object' &&
|
||||||
|
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
|
||||||
|
field.fieldType.multiline === true;
|
||||||
|
|
||||||
|
// Check for price field
|
||||||
|
const isPrice = typeof field.fieldType === 'object' &&
|
||||||
|
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
|
||||||
|
field.fieldType.price === true;
|
||||||
|
|
||||||
if (fieldType === 'select') {
|
if (fieldType === 'select') {
|
||||||
return (
|
return (
|
||||||
<SelectCell
|
<SelectCell
|
||||||
@@ -85,6 +96,8 @@ const BaseCellContent = React.memo(({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasErrors={hasErrors}
|
hasErrors={hasErrors}
|
||||||
|
isMultiline={isMultiline}
|
||||||
|
isPrice={isPrice}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, (prev, next) => {
|
}, (prev, next) => {
|
||||||
@@ -134,7 +147,7 @@ const ItemNumberCell = React.memo(({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCell className="p-1" style={{ width: `${width}px`, minWidth: `${width}px` }}>
|
<TableCell className="p-1" style={{ width: `${width}px`, minWidth: `${width}px` }}>
|
||||||
<div className={`relative ${hasError || isRequiredButEmpty ? 'border-red-500' : ''}`}>
|
<div className={`relative ${hasError ? 'border-red-500' : (isRequiredButEmpty ? 'border-red-500' : '')}`}>
|
||||||
{isValidating ? (
|
{isValidating ? (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||||
@@ -151,7 +164,7 @@ const ItemNumberCell = React.memo(({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{nonRequiredErrors.length > 0 && !isRequiredButEmpty && (
|
{nonRequiredErrors.length > 0 && (
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-20">
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-20">
|
||||||
<ValidationIcon error={{
|
<ValidationIcon error={{
|
||||||
message: nonRequiredErrors.map(e => e.message).join('\n'),
|
message: nonRequiredErrors.map(e => e.message).join('\n'),
|
||||||
@@ -198,15 +211,34 @@ const ValidationCell = ({
|
|||||||
|
|
||||||
// Error states
|
// Error states
|
||||||
const hasError = errors.some(error => error.level === 'error' || error.level === 'warning');
|
const hasError = errors.some(error => error.level === 'error' || error.level === 'warning');
|
||||||
const isRequiredButEmpty = errors.some(error => error.level === 'required' && (!value || value.trim() === ''));
|
const isRequiredButEmpty = errors.some(error => {
|
||||||
|
if (error.level !== 'required') return false;
|
||||||
|
|
||||||
|
// Handle different value types
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length === 0;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return !value || value.trim() === '';
|
||||||
|
}
|
||||||
|
return value === undefined || value === null;
|
||||||
|
});
|
||||||
const nonRequiredErrors = errors.filter(error => error.level !== 'required');
|
const nonRequiredErrors = errors.filter(error => error.level !== 'required');
|
||||||
|
|
||||||
|
// Check if this is a multiline field
|
||||||
|
const isMultiline = typeof field.fieldType === 'object' &&
|
||||||
|
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
|
||||||
|
field.fieldType.multiline === true;
|
||||||
|
|
||||||
|
// Adjust cell height for multiline fields
|
||||||
|
const cellHeight = isMultiline ? 'min-h-[80px]' : 'h-10';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCell className="p-1" style={{ width: `${width}px`, minWidth: `${width}px` }}>
|
<TableCell className="p-1" style={{ width: `${width}px`, minWidth: `${width}px` }}>
|
||||||
<div className={`relative ${hasError || isRequiredButEmpty ? 'border-red-500' : ''}`}>
|
<div className={`relative ${hasError ? 'border-red-500' : (isRequiredButEmpty ? 'border-red-500' : '')} ${cellHeight}`}>
|
||||||
|
|
||||||
|
|
||||||
<div className="truncate overflow-hidden">
|
<div className={`truncate overflow-hidden ${isMultiline ? 'h-full' : ''}`}>
|
||||||
<BaseCellContent
|
<BaseCellContent
|
||||||
field={field}
|
field={field}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -216,7 +248,7 @@ const ValidationCell = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{nonRequiredErrors.length > 0 && !isRequiredButEmpty && (
|
{nonRequiredErrors.length > 0 && (
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-20">
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-20">
|
||||||
<ValidationIcon error={{
|
<ValidationIcon error={{
|
||||||
message: nonRequiredErrors.map(e => e.message).join('\n'),
|
message: nonRequiredErrors.map(e => e.message).join('\n'),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SearchableTemplateSelect from './SearchableTemplateSelect'
|
|||||||
import { useAiValidation } from '../hooks/useAiValidation'
|
import { useAiValidation } from '../hooks/useAiValidation'
|
||||||
import { AiValidationDialogs } from './AiValidationDialogs'
|
import { AiValidationDialogs } from './AiValidationDialogs'
|
||||||
import config from '@/config'
|
import config from '@/config'
|
||||||
|
import { Fields } from '../../../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ValidationContainer component - the main wrapper for the validation step
|
* ValidationContainer component - the main wrapper for the validation step
|
||||||
@@ -351,8 +352,33 @@ const ValidationContainer = <T extends string>({
|
|||||||
|
|
||||||
// Enhanced updateRow function - memoized
|
// Enhanced updateRow function - memoized
|
||||||
const enhancedUpdateRow = useCallback(async (rowIndex: number, fieldKey: T, value: any) => {
|
const enhancedUpdateRow = useCallback(async (rowIndex: number, fieldKey: T, value: any) => {
|
||||||
|
// Process value before updating data
|
||||||
|
let processedValue = value;
|
||||||
|
|
||||||
|
// Strip dollar signs from price fields
|
||||||
|
if ((fieldKey === 'msrp' || fieldKey === '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current scroll position
|
||||||
|
const scrollPosition = {
|
||||||
|
left: window.scrollX,
|
||||||
|
top: window.scrollY
|
||||||
|
};
|
||||||
|
|
||||||
// Update the main data state
|
// Update the main data state
|
||||||
updateRow(rowIndex, fieldKey, value);
|
updateRow(rowIndex, fieldKey, processedValue);
|
||||||
|
|
||||||
|
// Restore scroll position after update
|
||||||
|
setTimeout(() => {
|
||||||
|
window.scrollTo(scrollPosition.left, scrollPosition.top);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
// Now handle any additional logic for specific fields
|
// Now handle any additional logic for specific fields
|
||||||
const rowData = filteredData[rowIndex];
|
const rowData = filteredData[rowIndex];
|
||||||
@@ -502,7 +528,7 @@ const ValidationContainer = <T extends string>({
|
|||||||
const aiValidation = useAiValidation<T>(
|
const aiValidation = useAiValidation<T>(
|
||||||
data,
|
data,
|
||||||
setData,
|
setData,
|
||||||
fields,
|
fields as Fields<T>,
|
||||||
// Create a wrapper function that adapts the rowHook to the expected signature
|
// Create a wrapper function that adapts the rowHook to the expected signature
|
||||||
validationState.rowHook ?
|
validationState.rowHook ?
|
||||||
async (row) => {
|
async (row) => {
|
||||||
@@ -590,9 +616,11 @@ const ValidationContainer = <T extends string>({
|
|||||||
}, [itemNumbers, validatingUpcRows]);
|
}, [itemNumbers, validatingUpcRows]);
|
||||||
|
|
||||||
// Memoize the ValidationTable to prevent unnecessary re-renders
|
// Memoize the ValidationTable to prevent unnecessary re-renders
|
||||||
const renderValidationTable = useMemo(() => (
|
const renderValidationTable = useMemo(() => {
|
||||||
|
return (
|
||||||
<EnhancedValidationTable
|
<EnhancedValidationTable
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
|
// @ts-ignore - The fields are compatible at runtime but TypeScript has issues with the exact type
|
||||||
fields={validationState.fields}
|
fields={validationState.fields}
|
||||||
updateRow={(rowIndex: number, key: string, value: any) =>
|
updateRow={(rowIndex: number, key: string, value: any) =>
|
||||||
enhancedUpdateRow(rowIndex, key as T, value)
|
enhancedUpdateRow(rowIndex, key as T, value)
|
||||||
@@ -614,7 +642,8 @@ const ValidationContainer = <T extends string>({
|
|||||||
validatingCells={new Set()}
|
validatingCells={new Set()}
|
||||||
itemNumbers={new Map()}
|
itemNumbers={new Map()}
|
||||||
/>
|
/>
|
||||||
), [
|
);
|
||||||
|
}, [
|
||||||
EnhancedValidationTable,
|
EnhancedValidationTable,
|
||||||
filteredData,
|
filteredData,
|
||||||
validationState.fields,
|
validationState.fields,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useMemo, useRef, useEffect, useLayoutEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -12,6 +12,8 @@ import ValidationCell from './ValidationCell'
|
|||||||
import { useRsi } from '../../../hooks/useRsi'
|
import { useRsi } from '../../../hooks/useRsi'
|
||||||
import SearchableTemplateSelect from './SearchableTemplateSelect'
|
import SearchableTemplateSelect from './SearchableTemplateSelect'
|
||||||
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
|
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
// Define a simple Error type locally to avoid import issues
|
// Define a simple Error type locally to avoid import issues
|
||||||
type ErrorType = {
|
type ErrorType = {
|
||||||
@@ -249,6 +251,93 @@ const ValidationTable = <T extends string>({
|
|||||||
}: ValidationTableProps<T>) => {
|
}: ValidationTableProps<T>) => {
|
||||||
const { translations } = useRsi<T>();
|
const { translations } = useRsi<T>();
|
||||||
|
|
||||||
|
// Create a global scroll position manager
|
||||||
|
const scrollManager = useRef({
|
||||||
|
windowX: 0,
|
||||||
|
windowY: 0,
|
||||||
|
containerLeft: 0,
|
||||||
|
containerTop: 0,
|
||||||
|
isScrolling: false,
|
||||||
|
|
||||||
|
// Save current scroll positions
|
||||||
|
save: function() {
|
||||||
|
this.windowX = window.scrollX;
|
||||||
|
this.windowY = window.scrollY;
|
||||||
|
if (tableContainerRef.current) {
|
||||||
|
this.containerLeft = tableContainerRef.current.scrollLeft;
|
||||||
|
this.containerTop = tableContainerRef.current.scrollTop;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Restore saved scroll positions
|
||||||
|
restore: function() {
|
||||||
|
if (this.isScrolling) return;
|
||||||
|
this.isScrolling = true;
|
||||||
|
|
||||||
|
// Restore window scroll
|
||||||
|
window.scrollTo(this.windowX, this.windowY);
|
||||||
|
|
||||||
|
// Restore container scroll
|
||||||
|
if (tableContainerRef.current) {
|
||||||
|
tableContainerRef.current.scrollLeft = this.containerLeft;
|
||||||
|
tableContainerRef.current.scrollTop = this.containerTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset flag after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isScrolling = false;
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table container ref
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Save scroll position before any potential re-render
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
scrollManager.current.save();
|
||||||
|
|
||||||
|
// Restore after render
|
||||||
|
return () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollManager.current.restore();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also restore on data changes
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollManager.current.restore();
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Memoize the selection column
|
||||||
|
const selectionColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<div className="flex h-full items-center justify-center py-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex h-[40px] items-center justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Select row"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
size: 50,
|
||||||
|
}), []);
|
||||||
|
|
||||||
// Memoize the template column
|
// Memoize the template column
|
||||||
const templateColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
|
const templateColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
|
||||||
accessorKey: '__template',
|
accessorKey: '__template',
|
||||||
@@ -256,6 +345,7 @@ const ValidationTable = <T extends string>({
|
|||||||
size: 200,
|
size: 200,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const templateValue = row.original.__template || null;
|
const templateValue = row.original.__template || null;
|
||||||
|
const defaultBrand = row.original.company || undefined;
|
||||||
return (
|
return (
|
||||||
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px' }}>
|
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px' }}>
|
||||||
<SearchableTemplateSelect
|
<SearchableTemplateSelect
|
||||||
@@ -264,9 +354,8 @@ const ValidationTable = <T extends string>({
|
|||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
applyTemplate(value, [row.index]);
|
applyTemplate(value, [row.index]);
|
||||||
}}
|
}}
|
||||||
getTemplateDisplayText={(template) =>
|
getTemplateDisplayText={getTemplateDisplayText}
|
||||||
template ? getTemplateDisplayText(template) : 'Select template'
|
defaultBrand={defaultBrand}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
@@ -312,7 +401,7 @@ const ValidationTable = <T extends string>({
|
|||||||
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null), [fields, validationErrors, validatingCells, itemNumbers, updateRow]);
|
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null), [fields, validationErrors, validatingCells, itemNumbers, updateRow]);
|
||||||
|
|
||||||
// Combine columns
|
// Combine columns
|
||||||
const columns = useMemo(() => [templateColumn, ...fieldColumns], [templateColumn, fieldColumns]);
|
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -338,6 +427,7 @@ const ValidationTable = <T extends string>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div ref={tableContainerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -349,7 +439,17 @@ const ValidationTable = <T extends string>({
|
|||||||
minWidth: `${header.getSize()}px`
|
minWidth: `${header.getSize()}px`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
{header.id === 'select' ? (
|
||||||
|
<div className="flex h-full items-center justify-center py-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
flexRender(header.column.columnDef.header, header.getContext())
|
||||||
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -359,15 +459,21 @@ const ValidationTable = <T extends string>({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
className={validationErrors.get(row.index) ? "bg-red-50/40" : "hover:bg-muted/50"}
|
className={cn(
|
||||||
|
"hover:bg-muted/50",
|
||||||
|
row.getIsSelected() ? "bg-muted/50" : ""
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
flexRender(cell.column.columnDef.cell, cell.getContext())
|
<React.Fragment key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,18 @@ const InputCell = <T extends string>({
|
|||||||
// Handle focus event
|
// Handle focus event
|
||||||
const handleFocus = useCallback(() => {
|
const handleFocus = useCallback(() => {
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
|
|
||||||
|
// For price fields, strip formatting when focusing
|
||||||
|
if (isPrice && value !== undefined && value !== null) {
|
||||||
|
// Remove any non-numeric characters except decimal point
|
||||||
|
const numericValue = String(value).replace(/[^\d.]/g, '')
|
||||||
|
setEditValue(numericValue)
|
||||||
|
} else {
|
||||||
setEditValue(value !== undefined && value !== null ? String(value) : '')
|
setEditValue(value !== undefined && value !== null ? String(value) : '')
|
||||||
|
}
|
||||||
|
|
||||||
onStartEdit?.()
|
onStartEdit?.()
|
||||||
}, [value, onStartEdit])
|
}, [value, onStartEdit, isPrice])
|
||||||
|
|
||||||
// Handle blur event
|
// Handle blur event
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
@@ -57,6 +66,23 @@ const InputCell = <T extends string>({
|
|||||||
onEndEdit?.()
|
onEndEdit?.()
|
||||||
}, [editValue, onChange, onEndEdit, isPrice])
|
}, [editValue, onChange, onEndEdit, isPrice])
|
||||||
|
|
||||||
|
// Handle direct input change
|
||||||
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
let newValue = e.target.value
|
||||||
|
|
||||||
|
// For price fields, automatically strip dollar signs as they type
|
||||||
|
if (isPrice) {
|
||||||
|
newValue = newValue.replace(/[$,]/g, '')
|
||||||
|
|
||||||
|
// If they try to enter a dollar sign, just remove it immediately
|
||||||
|
if (e.target.value.includes('$')) {
|
||||||
|
e.target.value = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditValue(newValue)
|
||||||
|
}, [isPrice])
|
||||||
|
|
||||||
// Format price value for display
|
// Format price value for display
|
||||||
const getDisplayValue = useCallback(() => {
|
const getDisplayValue = useCallback(() => {
|
||||||
if (!isPrice || !value) return value
|
if (!isPrice || !value) return value
|
||||||
@@ -64,11 +90,12 @@ const InputCell = <T extends string>({
|
|||||||
// Extract numeric part
|
// Extract numeric part
|
||||||
const numericValue = String(value).replace(/[^\d.]/g, '')
|
const numericValue = String(value).replace(/[^\d.]/g, '')
|
||||||
|
|
||||||
// Parse as float and format with dollar sign
|
// Parse as float and format without dollar sign
|
||||||
const numValue = parseFloat(numericValue)
|
const numValue = parseFloat(numericValue)
|
||||||
if (isNaN(numValue)) return value
|
if (isNaN(numValue)) return value
|
||||||
|
|
||||||
return `$${numValue.toFixed(2)}`
|
// Return just the number without dollar sign
|
||||||
|
return numValue.toFixed(2)
|
||||||
}, [value, isPrice])
|
}, [value, isPrice])
|
||||||
|
|
||||||
// Add outline even when not in focus
|
// Add outline even when not in focus
|
||||||
@@ -79,7 +106,7 @@ const InputCell = <T extends string>({
|
|||||||
{isMultiline ? (
|
{isMultiline ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
value={isEditing ? editValue : (value ?? '')}
|
value={isEditing ? editValue : (value ?? '')}
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
onChange={handleChange}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -93,7 +120,7 @@ const InputCell = <T extends string>({
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={editValue}
|
value={editValue}
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
onChange={handleChange}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|||||||
@@ -86,7 +86,74 @@ export const useValidationState = <T extends string>({
|
|||||||
const { fields, rowHook, tableHook } = useRsi<T>();
|
const { fields, rowHook, tableHook } = useRsi<T>();
|
||||||
|
|
||||||
// Core data state
|
// Core data state
|
||||||
const [data, setData] = useState<RowData<T>[]>(initialData)
|
const [data, setData] = useState<RowData<T>[]>(() => {
|
||||||
|
// Clean price fields in initial data before setting state
|
||||||
|
return initialData.map(row => {
|
||||||
|
const updatedRow = { ...row } as Record<string, any>;
|
||||||
|
|
||||||
|
// Clean MSRP
|
||||||
|
if (typeof updatedRow.msrp === 'string') {
|
||||||
|
updatedRow.msrp = updatedRow.msrp.replace(/[$,]/g, '');
|
||||||
|
const numValue = parseFloat(updatedRow.msrp);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
updatedRow.msrp = numValue.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean cost_each
|
||||||
|
if (typeof updatedRow.cost_each === 'string') {
|
||||||
|
updatedRow.cost_each = updatedRow.cost_each.replace(/[$,]/g, '');
|
||||||
|
const numValue = parseFloat(updatedRow.cost_each);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
updatedRow.cost_each = numValue.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default tax category if not already set
|
||||||
|
if (updatedRow.tax_cat === undefined || updatedRow.tax_cat === null || updatedRow.tax_cat === '') {
|
||||||
|
updatedRow.tax_cat = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default shipping restrictions if not already set
|
||||||
|
if (updatedRow.ship_restrictions === undefined || updatedRow.ship_restrictions === null || updatedRow.ship_restrictions === '') {
|
||||||
|
updatedRow.ship_restrictions = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedRow as RowData<T>;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
// Function to clean price fields in data
|
||||||
|
const cleanPriceFields = useCallback((dataToClean: RowData<T>[]): RowData<T>[] => {
|
||||||
|
return dataToClean.map(row => {
|
||||||
|
const updatedRow = { ...row } as Record<string, any>;
|
||||||
|
let needsUpdate = false;
|
||||||
|
|
||||||
|
// Clean MSRP
|
||||||
|
if (typeof updatedRow.msrp === 'string' && updatedRow.msrp.includes('$')) {
|
||||||
|
updatedRow.msrp = updatedRow.msrp.replace(/[$,]/g, '');
|
||||||
|
// Convert to number if possible
|
||||||
|
const numValue = parseFloat(updatedRow.msrp);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
updatedRow.msrp = numValue.toFixed(2);
|
||||||
|
}
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean cost_each
|
||||||
|
if (typeof updatedRow.cost_each === 'string' && updatedRow.cost_each.includes('$')) {
|
||||||
|
updatedRow.cost_each = updatedRow.cost_each.replace(/[$,]/g, '');
|
||||||
|
// Convert to number if possible
|
||||||
|
const numValue = parseFloat(updatedRow.cost_each);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
updatedRow.cost_each = numValue.toFixed(2);
|
||||||
|
}
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return needsUpdate ? (updatedRow as RowData<T>) : row;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Row selection state
|
// Row selection state
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
@@ -121,9 +188,184 @@ export const useValidationState = <T extends string>({
|
|||||||
const processedUpcMapRef = useRef(new Map<string, string>());
|
const processedUpcMapRef = useRef(new Map<string, string>());
|
||||||
const initialValidationDoneRef = useRef(false);
|
const initialValidationDoneRef = useRef(false);
|
||||||
|
|
||||||
|
// Add debounce timer ref for item number validation
|
||||||
|
const itemNumberValidationTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Function to validate uniqueness of item numbers across the entire table
|
||||||
|
const validateItemNumberUniqueness = useCallback(() => {
|
||||||
|
// Create a map to track item numbers and their occurrences
|
||||||
|
const itemNumberMap = new Map<string, number[]>();
|
||||||
|
|
||||||
|
// First pass: collect all item numbers and their row indices
|
||||||
|
data.forEach((row, rowIndex) => {
|
||||||
|
const itemNumber = row.item_number;
|
||||||
|
if (itemNumber) {
|
||||||
|
if (!itemNumberMap.has(itemNumber)) {
|
||||||
|
itemNumberMap.set(itemNumber, [rowIndex]);
|
||||||
|
} else {
|
||||||
|
itemNumberMap.get(itemNumber)?.push(rowIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only process duplicates - skip if no duplicates found
|
||||||
|
const duplicates = Array.from(itemNumberMap.entries())
|
||||||
|
.filter(([_, indices]) => indices.length > 1);
|
||||||
|
|
||||||
|
if (duplicates.length === 0) return;
|
||||||
|
|
||||||
|
// Prepare batch updates to minimize re-renders
|
||||||
|
const errorsToUpdate = new Map<number, Record<string, ErrorType[]>>();
|
||||||
|
const statusesToUpdate = new Map<number, 'error' | 'validated'>();
|
||||||
|
const rowsToUpdate: {rowIndex: number, errors: Record<string, ErrorType[]>}[] = [];
|
||||||
|
|
||||||
|
// Process only duplicates
|
||||||
|
duplicates.forEach(([, rowIndices]) => {
|
||||||
|
rowIndices.forEach(rowIndex => {
|
||||||
|
// Collect errors for batch update
|
||||||
|
const rowErrors = validationErrors.get(rowIndex) || {};
|
||||||
|
errorsToUpdate.set(rowIndex, {
|
||||||
|
...rowErrors,
|
||||||
|
item_number: [{
|
||||||
|
message: 'Duplicate item number',
|
||||||
|
level: 'error',
|
||||||
|
source: 'validation'
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect status updates
|
||||||
|
statusesToUpdate.set(rowIndex, 'error');
|
||||||
|
|
||||||
|
// Collect data updates
|
||||||
|
rowsToUpdate.push({
|
||||||
|
rowIndex,
|
||||||
|
errors: {
|
||||||
|
...(data[rowIndex].__errors || {}),
|
||||||
|
item_number: [{
|
||||||
|
message: 'Duplicate item number',
|
||||||
|
level: 'error',
|
||||||
|
source: 'validation'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply all updates in batch
|
||||||
|
if (errorsToUpdate.size > 0) {
|
||||||
|
// Update validation errors
|
||||||
|
setValidationErrors(prev => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
errorsToUpdate.forEach((errors, rowIndex) => {
|
||||||
|
updated.set(rowIndex, errors);
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update row statuses
|
||||||
|
setRowValidationStatus(prev => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
statusesToUpdate.forEach((status, rowIndex) => {
|
||||||
|
updated.set(rowIndex, status);
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update data rows
|
||||||
|
if (rowsToUpdate.length > 0) {
|
||||||
|
setData(prevData => {
|
||||||
|
const newData = [...prevData];
|
||||||
|
rowsToUpdate.forEach(({rowIndex, errors}) => {
|
||||||
|
if (newData[rowIndex]) {
|
||||||
|
newData[rowIndex] = {
|
||||||
|
...newData[rowIndex],
|
||||||
|
__errors: errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, validationErrors]);
|
||||||
|
|
||||||
|
// Effect to trigger validation when UPC results change
|
||||||
|
useEffect(() => {
|
||||||
|
if (upcValidationResults.size === 0) return;
|
||||||
|
|
||||||
|
// Create a single batch update for all changes
|
||||||
|
const updatedData = [...data];
|
||||||
|
const updatedStatus = new Map(rowValidationStatus);
|
||||||
|
const updatedErrors = new Map(validationErrors);
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
upcValidationResults.forEach((result, rowIndex) => {
|
||||||
|
if (result.itemNumber && updatedData[rowIndex]) {
|
||||||
|
// Only update if the item number has actually changed
|
||||||
|
if (updatedData[rowIndex].item_number !== result.itemNumber) {
|
||||||
|
hasChanges = true;
|
||||||
|
updatedData[rowIndex] = {
|
||||||
|
...updatedData[rowIndex],
|
||||||
|
item_number: result.itemNumber
|
||||||
|
};
|
||||||
|
|
||||||
|
updatedStatus.set(rowIndex, 'pending');
|
||||||
|
|
||||||
|
const rowErrors = updatedErrors.get(rowIndex) || {};
|
||||||
|
delete rowErrors['item_number'];
|
||||||
|
updatedErrors.set(rowIndex, rowErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only update state if there were actual changes
|
||||||
|
if (hasChanges) {
|
||||||
|
// Clean price fields before updating
|
||||||
|
const cleanedData = cleanPriceFields(updatedData);
|
||||||
|
|
||||||
|
// Save current scroll position before updating
|
||||||
|
const scrollPosition = {
|
||||||
|
left: window.scrollX,
|
||||||
|
top: window.scrollY
|
||||||
|
};
|
||||||
|
|
||||||
|
setData(cleanedData);
|
||||||
|
setRowValidationStatus(updatedStatus);
|
||||||
|
setValidationErrors(updatedErrors);
|
||||||
|
|
||||||
|
// Validate uniqueness after a short delay to allow UI to update
|
||||||
|
// Use requestAnimationFrame for better performance
|
||||||
|
if (itemNumberValidationTimerRef.current !== null) {
|
||||||
|
cancelAnimationFrame(itemNumberValidationTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
itemNumberValidationTimerRef.current = requestAnimationFrame(() => {
|
||||||
|
// Restore scroll position
|
||||||
|
window.scrollTo(scrollPosition.left, scrollPosition.top);
|
||||||
|
|
||||||
|
validateItemNumberUniqueness();
|
||||||
|
itemNumberValidationTimerRef.current = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [upcValidationResults, validateItemNumberUniqueness, data, rowValidationStatus, validationErrors, cleanPriceFields]);
|
||||||
|
|
||||||
// Fetch product by UPC from API - optimized with proper error handling and types
|
// Fetch product by UPC from API - optimized with proper error handling and types
|
||||||
const fetchProductByUpc = useCallback(async (supplier: string, upc: string): Promise<ValidationResult> => {
|
const fetchProductByUpc = useCallback(async (supplier: string, upc: string): Promise<ValidationResult> => {
|
||||||
try {
|
try {
|
||||||
|
// Check cache first
|
||||||
|
const cacheKey = `${supplier}-${upc}`;
|
||||||
|
if (processedUpcMapRef.current.has(cacheKey)) {
|
||||||
|
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
|
||||||
|
if (cachedItemNumber) {
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
data: {
|
||||||
|
itemNumber: cachedItemNumber
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use the correct endpoint and parameter names
|
// Use the correct endpoint and parameter names
|
||||||
const response = await fetch(`${getApiUrl()}/import/check-upc-and-generate-sku?supplierId=${encodeURIComponent(supplier)}&upc=${encodeURIComponent(upc)}`, {
|
const response = await fetch(`${getApiUrl()}/import/check-upc-and-generate-sku?supplierId=${encodeURIComponent(supplier)}&upc=${encodeURIComponent(upc)}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -174,6 +416,11 @@ export const useValidationState = <T extends string>({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
if (data.itemNumber) {
|
||||||
|
processedUpcMapRef.current.set(cacheKey, data.itemNumber);
|
||||||
|
}
|
||||||
|
|
||||||
// Return successful validation with product data
|
// Return successful validation with product data
|
||||||
return {
|
return {
|
||||||
error: false,
|
error: false,
|
||||||
@@ -206,15 +453,10 @@ export const useValidationState = <T extends string>({
|
|||||||
|
|
||||||
if (cachedItemNumber) {
|
if (cachedItemNumber) {
|
||||||
// Update data directly with the cached item number
|
// Update data directly with the cached item number
|
||||||
setData(prevData => {
|
setUpcValidationResults(prev => {
|
||||||
const newData = [...prevData];
|
const newResults = new Map(prev);
|
||||||
if (newData[rowIndex]) {
|
newResults.set(rowIndex, { itemNumber: cachedItemNumber });
|
||||||
newData[rowIndex] = {
|
return newResults;
|
||||||
...newData[rowIndex],
|
|
||||||
item_number: cachedItemNumber
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return newData;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, itemNumber: cachedItemNumber };
|
return { success: true, itemNumber: cachedItemNumber };
|
||||||
@@ -224,65 +466,49 @@ export const useValidationState = <T extends string>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make API call to validate UPC
|
// Make API call to validate UPC
|
||||||
const response = await fetch(`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`);
|
const result = await fetchProductByUpc(supplierId, upcValue);
|
||||||
|
|
||||||
if (response.status === 409) {
|
if (result.error) {
|
||||||
// UPC already exists - show validation error
|
// Handle error case
|
||||||
const errorData = await response.json();
|
if (result.message && result.message.includes('already exists') && result.data?.itemNumber) {
|
||||||
|
// UPC already exists - update with existing item number
|
||||||
|
setUpcValidationResults(prev => {
|
||||||
|
const newResults = new Map(prev);
|
||||||
|
newResults.set(rowIndex, { itemNumber: result.data!.itemNumber });
|
||||||
|
return newResults;
|
||||||
|
});
|
||||||
|
|
||||||
setData(prevData => {
|
return { success: true, itemNumber: result.data.itemNumber };
|
||||||
const newData = [...prevData];
|
} else {
|
||||||
const rowToUpdate = newData[rowIndex];
|
// Other error - show validation error
|
||||||
if (rowToUpdate) {
|
setValidationErrors(prev => {
|
||||||
const fieldKey = 'upc' in rowToUpdate ? 'upc' : 'barcode';
|
const newErrors = new Map(prev);
|
||||||
newData[rowIndex] = {
|
const rowErrors = newErrors.get(rowIndex) || {};
|
||||||
...rowToUpdate,
|
const fieldKey = 'upc';
|
||||||
__errors: {
|
|
||||||
...(rowToUpdate.__errors || {}),
|
newErrors.set(rowIndex, {
|
||||||
[fieldKey]: {
|
...rowErrors,
|
||||||
|
[fieldKey]: [{
|
||||||
|
message: result.message || 'Invalid UPC',
|
||||||
level: 'error',
|
level: 'error',
|
||||||
message: `UPC already exists (${errorData.existingItemNumber})`
|
source: 'validation'
|
||||||
}
|
}]
|
||||||
}
|
});
|
||||||
};
|
|
||||||
}
|
return newErrors;
|
||||||
return newData;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: false };
|
return { success: false };
|
||||||
} else if (response.ok) {
|
|
||||||
const responseData = await response.json();
|
|
||||||
|
|
||||||
if (responseData.success && responseData.itemNumber) {
|
|
||||||
// Store in cache
|
|
||||||
processedUpcMapRef.current.set(cacheKey, responseData.itemNumber);
|
|
||||||
|
|
||||||
// Update data directly with the new item number
|
|
||||||
setData(prevData => {
|
|
||||||
const newData = [...prevData];
|
|
||||||
if (newData[rowIndex]) {
|
|
||||||
newData[rowIndex] = {
|
|
||||||
...newData[rowIndex],
|
|
||||||
item_number: responseData.itemNumber
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clear any UPC errors if they exist
|
|
||||||
if (newData[rowIndex].__errors) {
|
|
||||||
const updatedErrors = { ...newData[rowIndex].__errors };
|
|
||||||
delete updatedErrors.upc;
|
|
||||||
delete updatedErrors.barcode;
|
|
||||||
if (Object.keys(updatedErrors).length > 0) {
|
|
||||||
newData[rowIndex].__errors = updatedErrors;
|
|
||||||
} else {
|
|
||||||
delete newData[rowIndex].__errors;
|
|
||||||
}
|
}
|
||||||
}
|
} else if (result.data && result.data.itemNumber) {
|
||||||
}
|
// Success case - update with new item number
|
||||||
return newData;
|
setUpcValidationResults(prev => {
|
||||||
|
const newResults = new Map(prev);
|
||||||
|
newResults.set(rowIndex, { itemNumber: result.data!.itemNumber });
|
||||||
|
return newResults;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, itemNumber: responseData.itemNumber };
|
return { success: true, itemNumber: result.data.itemNumber };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false };
|
return { success: false };
|
||||||
@@ -290,7 +516,7 @@ export const useValidationState = <T extends string>({
|
|||||||
console.error(`Error validating UPC for row ${rowIndex}:`, error);
|
console.error(`Error validating UPC for row ${rowIndex}:`, error);
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
}, [setData]);
|
}, [fetchProductByUpc]);
|
||||||
|
|
||||||
// Track which cells are currently being validated - allows targeted re-rendering
|
// Track which cells are currently being validated - allows targeted re-rendering
|
||||||
const isValidatingUpc = useCallback((rowIndex: number) => {
|
const isValidatingUpc = useCallback((rowIndex: number) => {
|
||||||
@@ -380,76 +606,141 @@ export const useValidationState = <T extends string>({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// First, let's restore the original validateField function
|
// Validate a single field against its validation rules
|
||||||
const validateField = useCallback((value: any, field: Field<T>): ErrorType[] => {
|
const validateField = useCallback((value: any, field: Field<T>): ErrorType[] => {
|
||||||
const errors: ErrorType[] = []
|
const errors: ErrorType[] = [];
|
||||||
|
|
||||||
if (!field.validations) return errors
|
// Skip validation for disabled fields
|
||||||
|
if (field.disabled) return errors;
|
||||||
|
|
||||||
// Type casting to handle readonly fields
|
// Process value for price fields before validation
|
||||||
const validations = field.validations as Array<{ rule: string; value?: string; flags?: string; errorMessage?: string; level?: string; }>
|
let processedValue = value;
|
||||||
|
const isPrice = typeof field.fieldType === 'object' &&
|
||||||
|
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
|
||||||
|
field.fieldType.price === true;
|
||||||
|
|
||||||
for (const validation of validations) {
|
if (isPrice && typeof value === 'string') {
|
||||||
if (validation.rule === 'required') {
|
processedValue = value.replace(/[$,]/g, '');
|
||||||
// Check if the field is empty - handle different value types
|
}
|
||||||
const isEmpty =
|
|
||||||
value === undefined ||
|
|
||||||
value === null ||
|
|
||||||
value === '' ||
|
|
||||||
(Array.isArray(value) && value.length === 0);
|
|
||||||
|
|
||||||
if (isEmpty) {
|
// Check each validation rule
|
||||||
|
field.validations?.forEach(validation => {
|
||||||
|
// Skip if already has an error for this rule
|
||||||
|
if (errors.some(e => e.message === validation.errorMessage)) return;
|
||||||
|
|
||||||
|
const rule = validation.rule;
|
||||||
|
|
||||||
|
// Required validation
|
||||||
|
if (rule === 'required') {
|
||||||
|
if (processedValue === undefined || processedValue === null || processedValue === '') {
|
||||||
errors.push({
|
errors.push({
|
||||||
message: validation.errorMessage || 'This field is required',
|
message: validation.errorMessage || 'This field is required',
|
||||||
level: 'required',
|
level: 'required', // Use 'required' level to distinguish from other errors
|
||||||
source: 'required' // Mark as required error specifically
|
source: 'required'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (validation.rule === 'regex') {
|
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
// Skip other validations if value is empty and not required
|
||||||
try {
|
if (processedValue === undefined || processedValue === null || processedValue === '') return;
|
||||||
const regex = new RegExp(validation.value || '', validation.flags);
|
|
||||||
if (!regex.test(String(value))) {
|
// Regex validation
|
||||||
|
if (rule === 'regex' && validation.value) {
|
||||||
|
const regex = new RegExp(validation.value);
|
||||||
|
if (!regex.test(String(processedValue))) {
|
||||||
errors.push({
|
errors.push({
|
||||||
message: validation.errorMessage || 'Invalid format',
|
message: validation.errorMessage || 'Invalid format',
|
||||||
level: validation.level || 'error',
|
level: validation.level || 'error',
|
||||||
source: 'validation' // Mark as validation error
|
source: 'validation'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Invalid regex in validation:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (validation.rule === 'unique') {
|
|
||||||
// Unique validation will be handled at the table level
|
|
||||||
// This is just a placeholder for now
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unique validation is handled separately in batch processing
|
||||||
|
});
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Now, let's update the updateRow function to trigger validation after updating data
|
// Now, let's update the updateRow function to trigger validation after updating data
|
||||||
const updateRow = useCallback((rowIndex: number, key: T, value: any) => {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current scroll position
|
||||||
|
const scrollPosition = {
|
||||||
|
left: window.scrollX,
|
||||||
|
top: window.scrollY
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the data immediately for responsive UI
|
||||||
setData(prevData => {
|
setData(prevData => {
|
||||||
const newData = [...prevData]
|
const newData = [...prevData];
|
||||||
const row = { ...newData[rowIndex] }
|
|
||||||
row[key] = value
|
// Create a deep copy of the row to avoid reference issues
|
||||||
|
const row = JSON.parse(JSON.stringify(newData[rowIndex]));
|
||||||
|
|
||||||
|
// Update the field value
|
||||||
|
row[key] = processedValue;
|
||||||
|
|
||||||
// Mark row as needing validation
|
// Mark row as needing validation
|
||||||
setRowValidationStatus(prev => {
|
setRowValidationStatus(prev => {
|
||||||
const updated = new Map(prev)
|
const updated = new Map(prev);
|
||||||
updated.set(rowIndex, 'pending')
|
updated.set(rowIndex, 'pending');
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
|
|
||||||
newData[rowIndex] = row as RowData<T>
|
// Update the row in the data array
|
||||||
return newData
|
newData[rowIndex] = row as RowData<T>;
|
||||||
})
|
|
||||||
|
|
||||||
// Validate just this single field immediately to provide feedback
|
// Clean all price fields to ensure consistency
|
||||||
|
return newData.map(dataRow => {
|
||||||
|
if (dataRow === row) return row as RowData<T>;
|
||||||
|
|
||||||
|
const updatedRow = { ...dataRow } as Record<string, any>;
|
||||||
|
let needsUpdate = false;
|
||||||
|
|
||||||
|
// Clean MSRP
|
||||||
|
if (typeof updatedRow.msrp === 'string' && updatedRow.msrp.includes('$')) {
|
||||||
|
updatedRow.msrp = updatedRow.msrp.replace(/[$,]/g, '');
|
||||||
|
const numValue = parseFloat(updatedRow.msrp);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
updatedRow.msrp = numValue.toFixed(2);
|
||||||
|
}
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean cost_each
|
||||||
|
if (typeof updatedRow.cost_each === 'string' && updatedRow.cost_each.includes('$')) {
|
||||||
|
updatedRow.cost_each = updatedRow.cost_each.replace(/[$,]/g, '');
|
||||||
|
const numValue = parseFloat(updatedRow.cost_each);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
updatedRow.cost_each = numValue.toFixed(2);
|
||||||
|
}
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return needsUpdate ? (updatedRow as RowData<T>) : dataRow;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore scroll position after update
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo(scrollPosition.left, scrollPosition.top);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounce validation to avoid excessive processing
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const field = fields.find(f => f.key === key);
|
const field = fields.find(f => f.key === key);
|
||||||
if (field) {
|
if (field) {
|
||||||
@@ -460,6 +751,13 @@ export const useValidationState = <T extends string>({
|
|||||||
// Validate just this field
|
// Validate just this field
|
||||||
const fieldErrors = validateField(value, field as unknown as Field<T>);
|
const fieldErrors = validateField(value, field as unknown as Field<T>);
|
||||||
|
|
||||||
|
// Only update if errors have changed
|
||||||
|
const currentFieldErrors = rowErrorsMap[key] || [];
|
||||||
|
const errorsChanged =
|
||||||
|
fieldErrors.length !== currentFieldErrors.length ||
|
||||||
|
JSON.stringify(fieldErrors) !== JSON.stringify(currentFieldErrors);
|
||||||
|
|
||||||
|
if (errorsChanged) {
|
||||||
// Update the errors for this field
|
// Update the errors for this field
|
||||||
const updatedRowErrors = {
|
const updatedRowErrors = {
|
||||||
...rowErrorsMap,
|
...rowErrorsMap,
|
||||||
@@ -477,6 +775,7 @@ export const useValidationState = <T extends string>({
|
|||||||
newData[rowIndex] = row as RowData<T>;
|
newData[rowIndex] = row as RowData<T>;
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// If this is a UPC or supplier field and both have values, validate UPC
|
// If this is a UPC or supplier field and both have values, validate UPC
|
||||||
if ((key === 'upc' || key === 'supplier') && data[rowIndex]) {
|
if ((key === 'upc' || key === 'supplier') && data[rowIndex]) {
|
||||||
@@ -490,57 +789,22 @@ export const useValidationState = <T extends string>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate item numbers
|
// Check for duplicate item numbers with debouncing
|
||||||
if (key === 'item_number' && value) {
|
if (key === 'item_number' && value) {
|
||||||
const duplicates = data.filter((r, idx) =>
|
// Cancel any pending validation
|
||||||
idx !== rowIndex &&
|
if (itemNumberValidationTimerRef.current !== null) {
|
||||||
r.item_number === value
|
cancelAnimationFrame(itemNumberValidationTimerRef.current);
|
||||||
);
|
|
||||||
|
|
||||||
if (duplicates.length > 0) {
|
|
||||||
// Add a duplicate error
|
|
||||||
const currentRowErrors = new Map(validationErrors);
|
|
||||||
const rowErrorsMap = currentRowErrors.get(rowIndex) || {};
|
|
||||||
|
|
||||||
// Get existing errors for this field
|
|
||||||
const existingErrors = rowErrorsMap[key] || [];
|
|
||||||
|
|
||||||
// Add the duplicate error if it doesn't already exist
|
|
||||||
const hasDuplicateError = existingErrors.some(e => e.message === 'Duplicate item number');
|
|
||||||
|
|
||||||
if (!hasDuplicateError) {
|
|
||||||
const updatedErrors = [
|
|
||||||
...existingErrors,
|
|
||||||
{
|
|
||||||
message: 'Duplicate item number',
|
|
||||||
level: 'error',
|
|
||||||
source: 'validation'
|
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
|
||||||
// Update the errors for this field
|
// Schedule validation for next frame
|
||||||
const updatedRowErrors = {
|
itemNumberValidationTimerRef.current = requestAnimationFrame(() => {
|
||||||
...rowErrorsMap,
|
validateItemNumberUniqueness();
|
||||||
[key]: updatedErrors
|
itemNumberValidationTimerRef.current = null;
|
||||||
};
|
|
||||||
|
|
||||||
// Update the validation errors
|
|
||||||
currentRowErrors.set(rowIndex, updatedRowErrors);
|
|
||||||
setValidationErrors(currentRowErrors);
|
|
||||||
|
|
||||||
// Also update __errors in the data row
|
|
||||||
setData(prevData => {
|
|
||||||
const newData = [...prevData];
|
|
||||||
const row = { ...newData[rowIndex], __errors: updatedRowErrors };
|
|
||||||
newData[rowIndex] = row as RowData<T>;
|
|
||||||
return newData;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}, 100); // Small delay to batch updates
|
||||||
}
|
}, [data, fields, validateField, validationErrors, validateUpc, validateItemNumberUniqueness]);
|
||||||
}, 0);
|
|
||||||
}, [data, fields, validateField, validationErrors]);
|
|
||||||
|
|
||||||
// Validate a single row - optimized version
|
// Validate a single row - optimized version
|
||||||
const validateRow = useCallback(async (rowIndex: number) => {
|
const validateRow = useCallback(async (rowIndex: number) => {
|
||||||
@@ -632,25 +896,38 @@ export const useValidationState = <T extends string>({
|
|||||||
// Load templates
|
// Load templates
|
||||||
const loadTemplates = useCallback(async () => {
|
const loadTemplates = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Fetching templates...');
|
console.log('Fetching templates from:', `${getApiUrl()}/templates`);
|
||||||
// Fetch templates from the API
|
// Fetch templates from the API
|
||||||
const response = await fetch(`${getApiUrl()}/templates`)
|
const response = await fetch(`${getApiUrl()}/templates`)
|
||||||
console.log('Templates response status:', response.status);
|
console.log('Templates response:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries())
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch templates')
|
if (!response.ok) throw new Error('Failed to fetch templates')
|
||||||
|
|
||||||
const templateData = await response.json()
|
const templateData = await response.json()
|
||||||
console.log('Templates fetched successfully:', templateData);
|
console.log('Templates response data:', templateData);
|
||||||
|
|
||||||
// Validate template data
|
// Validate template data
|
||||||
const validTemplates = templateData.filter((t: any) =>
|
const validTemplates = templateData.filter((t: any) =>
|
||||||
t && typeof t === 'object' && t.id && t.company && t.product_type
|
t && typeof t === 'object' && t.id && t.company && t.product_type
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('Valid templates:', {
|
||||||
|
total: templateData.length,
|
||||||
|
valid: validTemplates.length,
|
||||||
|
templates: validTemplates
|
||||||
|
});
|
||||||
|
|
||||||
if (validTemplates.length !== templateData.length) {
|
if (validTemplates.length !== templateData.length) {
|
||||||
console.warn('Some templates were filtered out due to invalid data', {
|
console.warn('Some templates were filtered out due to invalid data', {
|
||||||
original: templateData.length,
|
original: templateData.length,
|
||||||
valid: validTemplates.length
|
valid: validTemplates.length,
|
||||||
|
filtered: templateData.filter((t: any) =>
|
||||||
|
!(t && typeof t === 'object' && t.id && t.company && t.product_type)
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,6 +938,11 @@ export const useValidationState = <T extends string>({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load templates on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadTemplates();
|
||||||
|
}, [loadTemplates]);
|
||||||
|
|
||||||
// Save a new template
|
// Save a new template
|
||||||
const saveTemplate = useCallback(async (name: string, type: string) => {
|
const saveTemplate = useCallback(async (name: string, type: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -971,11 +1253,43 @@ export const useValidationState = <T extends string>({
|
|||||||
|
|
||||||
console.log(`Validating ${data.length} rows`);
|
console.log(`Validating ${data.length} rows`);
|
||||||
|
|
||||||
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
|
// Process in batches to avoid blocking the UI
|
||||||
|
const BATCH_SIZE = 50;
|
||||||
|
let currentBatch = 0;
|
||||||
|
|
||||||
|
const processBatch = () => {
|
||||||
|
const startIdx = currentBatch * BATCH_SIZE;
|
||||||
|
const endIdx = Math.min(startIdx + BATCH_SIZE, data.length);
|
||||||
|
|
||||||
|
for (let rowIndex = startIdx; rowIndex < endIdx; rowIndex++) {
|
||||||
const row = data[rowIndex];
|
const row = data[rowIndex];
|
||||||
const fieldErrors: Record<string, ErrorType[]> = {};
|
const fieldErrors: Record<string, ErrorType[]> = {};
|
||||||
let hasErrors = false;
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Set default values for tax_cat and ship_restrictions if not already set
|
||||||
|
if (row.tax_cat === undefined || row.tax_cat === null || row.tax_cat === '') {
|
||||||
|
newData[rowIndex] = {
|
||||||
|
...newData[rowIndex],
|
||||||
|
tax_cat: '0'
|
||||||
|
} as RowData<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.ship_restrictions === undefined || row.ship_restrictions === null || row.ship_restrictions === '') {
|
||||||
|
newData[rowIndex] = {
|
||||||
|
...newData[rowIndex],
|
||||||
|
ship_restrictions: '0'
|
||||||
|
} as RowData<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process price fields to strip dollar signs - use the cleanPriceFields function
|
||||||
|
const rowAsRecord = row as Record<string, any>;
|
||||||
|
if ((typeof rowAsRecord.msrp === 'string' && rowAsRecord.msrp.includes('$')) ||
|
||||||
|
(typeof rowAsRecord.cost_each === 'string' && rowAsRecord.cost_each.includes('$'))) {
|
||||||
|
// Clean just this row
|
||||||
|
const cleanedRow = cleanPriceFields([row])[0];
|
||||||
|
newData[rowIndex] = cleanedRow;
|
||||||
|
}
|
||||||
|
|
||||||
fields.forEach(field => {
|
fields.forEach(field => {
|
||||||
if (field.disabled) return;
|
if (field.disabled) return;
|
||||||
const key = String(field.key);
|
const key = String(field.key);
|
||||||
@@ -1018,12 +1332,27 @@ export const useValidationState = <T extends string>({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch update all state at once
|
currentBatch++;
|
||||||
|
|
||||||
|
// If there are more batches to process, schedule the next one
|
||||||
|
if (endIdx < data.length) {
|
||||||
|
setTimeout(processBatch, 0);
|
||||||
|
} else {
|
||||||
|
// All batches processed, update state
|
||||||
setData(newData);
|
setData(newData);
|
||||||
setRowValidationStatus(initialStatus);
|
setRowValidationStatus(initialStatus);
|
||||||
setValidationErrors(initialErrors);
|
setValidationErrors(initialErrors);
|
||||||
|
|
||||||
console.log('Basic field validation complete');
|
console.log('Basic field validation complete');
|
||||||
|
|
||||||
|
// Schedule UPC validations after basic validation is complete
|
||||||
|
setTimeout(() => {
|
||||||
|
runUPCValidation();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start processing batches
|
||||||
|
processBatch();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to perform UPC validations asynchronously
|
// Function to perform UPC validations asynchronously
|
||||||
@@ -1098,7 +1427,7 @@ export const useValidationState = <T extends string>({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (i + BATCH_SIZE < rowsWithUpc.length) {
|
if (i + BATCH_SIZE < rowsWithUpc.length) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,10 +1436,6 @@ export const useValidationState = <T extends string>({
|
|||||||
|
|
||||||
// Run basic validations immediately to update UI
|
// Run basic validations immediately to update UI
|
||||||
runBasicValidation();
|
runBasicValidation();
|
||||||
// Schedule UPC validations asynchronously to avoid blocking the UI
|
|
||||||
setTimeout(() => {
|
|
||||||
runUPCValidation();
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
initialValidationDoneRef.current = true;
|
initialValidationDoneRef.current = true;
|
||||||
}, [data, fields, validateField, fetchProductByUpc]);
|
}, [data, fields, validateField, fetchProductByUpc]);
|
||||||
@@ -1120,7 +1445,9 @@ export const useValidationState = <T extends string>({
|
|||||||
if (!fieldOptionsData) return fields;
|
if (!fieldOptionsData) return fields;
|
||||||
|
|
||||||
return fields.map(field => {
|
return fields.map(field => {
|
||||||
if (field.fieldType.type !== 'select' && field.fieldType.type !== 'multi-select') {
|
// Skip fields that aren't select or multi-select
|
||||||
|
if (typeof field.fieldType !== 'object' ||
|
||||||
|
(field.fieldType.type !== 'select' && field.fieldType.type !== 'multi-select')) {
|
||||||
return field;
|
return field;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1144,7 +1471,14 @@ export const useValidationState = <T extends string>({
|
|||||||
break;
|
break;
|
||||||
case 'tax_cat':
|
case 'tax_cat':
|
||||||
options = [...(fieldOptionsData.taxCategories || [])];
|
options = [...(fieldOptionsData.taxCategories || [])];
|
||||||
break;
|
// Ensure tax_cat is always a select, not multi-select
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
type: 'select',
|
||||||
|
options
|
||||||
|
}
|
||||||
|
};
|
||||||
case 'ship_restrictions':
|
case 'ship_restrictions':
|
||||||
options = [...(fieldOptionsData.shippingRestrictions || [])];
|
options = [...(fieldOptionsData.shippingRestrictions || [])];
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user