Fix dropdown values saving, add back checkbox column, mostly fix validation, fix some field types

This commit is contained in:
2025-03-06 01:45:05 -05:00
parent bc5607f48c
commit 68ca7e93a1
12 changed files with 860 additions and 4847 deletions

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]);

View File

@@ -51,9 +51,20 @@ 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 (
@@ -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'),

View File

@@ -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,31 +616,34 @@ 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(() => {
<EnhancedValidationTable return (
data={filteredData} <EnhancedValidationTable
fields={validationState.fields} data={filteredData}
updateRow={(rowIndex: number, key: string, value: any) => // @ts-ignore - The fields are compatible at runtime but TypeScript has issues with the exact type
enhancedUpdateRow(rowIndex, key as T, value) fields={validationState.fields}
} updateRow={(rowIndex: number, key: string, value: any) =>
rowSelection={rowSelection} enhancedUpdateRow(rowIndex, key as T, value)
setRowSelection={setRowSelection} }
validationErrors={validationErrors} rowSelection={rowSelection}
isValidatingUpc={isRowValidatingUpc} setRowSelection={setRowSelection}
validatingUpcRows={Array.from(validatingUpcRows)} validationErrors={validationErrors}
filters={filters} isValidatingUpc={isRowValidatingUpc}
templates={templates} validatingUpcRows={Array.from(validatingUpcRows)}
applyTemplate={applyTemplate} filters={filters}
getTemplateDisplayText={getTemplateDisplayText} templates={templates}
rowProductLines={rowProductLines} applyTemplate={applyTemplate}
rowSublines={rowSublines} getTemplateDisplayText={getTemplateDisplayText}
isLoadingLines={isLoadingLines} rowProductLines={rowProductLines}
isLoadingSublines={isLoadingSublines} rowSublines={rowSublines}
upcValidationResults={new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), { itemNumber: value }]))} isLoadingLines={isLoadingLines}
validatingCells={new Set()} isLoadingSublines={isLoadingSublines}
itemNumbers={new Map()} upcValidationResults={new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), { itemNumber: value }]))}
/> validatingCells={new Set()}
), [ itemNumbers={new Map()}
/>
);
}, [
EnhancedValidationTable, EnhancedValidationTable,
filteredData, filteredData,
validationState.fields, validationState.fields,

View File

@@ -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 = {
@@ -248,6 +250,93 @@ const ValidationTable = <T extends string>({
itemNumbers itemNumbers
}: 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> => ({
@@ -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,36 +427,53 @@ const ValidationTable = <T extends string>({
} }
return ( return (
<Table> <div ref={tableContainerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
<TableHeader> <Table>
<TableRow> <TableHeader>
{table.getFlatHeaders().map((header) => ( <TableRow>
<TableHead {table.getFlatHeaders().map((header) => (
key={header.id} <TableHead
style={{ key={header.id}
width: `${header.getSize()}px`, style={{
minWidth: `${header.getSize()}px` width: `${header.getSize()}px`,
}} minWidth: `${header.getSize()}px`
> }}
{flexRender(header.column.columnDef.header, header.getContext())} >
</TableHead> {header.id === 'select' ? (
))} <div className="flex h-full items-center justify-center py-2">
</TableRow> <Checkbox
</TableHeader> checked={table.getIsAllPageRowsSelected()}
<TableBody> onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
{table.getRowModel().rows.map((row) => ( aria-label="Select all"
<TableRow />
key={row.id} </div>
data-state={row.getIsSelected() && "selected"} ) : (
className={validationErrors.get(row.index) ? "bg-red-50/40" : "hover:bg-muted/50"} flexRender(header.column.columnDef.header, header.getContext())
> )}
{row.getVisibleCells().map((cell) => ( </TableHead>
flexRender(cell.column.columnDef.cell, cell.getContext())
))} ))}
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : ""
)}
>
{row.getVisibleCells().map((cell) => (
<React.Fragment key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</React.Fragment>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
); );
}; };

View File

@@ -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)
setEditValue(value !== undefined && value !== null ? String(value) : '')
// 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) : '')
}
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

View File

@@ -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
setData(prevData => { setUpcValidationResults(prev => {
const newData = [...prevData]; const newResults = new Map(prev);
const rowToUpdate = newData[rowIndex]; newResults.set(rowIndex, { itemNumber: result.data!.itemNumber });
if (rowToUpdate) { return newResults;
const fieldKey = 'upc' in rowToUpdate ? 'upc' : 'barcode';
newData[rowIndex] = {
...rowToUpdate,
__errors: {
...(rowToUpdate.__errors || {}),
[fieldKey]: {
level: 'error',
message: `UPC already exists (${errorData.existingItemNumber})`
}
}
};
}
return newData;
});
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;
}
}
}
return newData;
}); });
return { success: true, itemNumber: responseData.itemNumber }; return { success: true, itemNumber: result.data.itemNumber };
} else {
// Other error - show validation error
setValidationErrors(prev => {
const newErrors = new Map(prev);
const rowErrors = newErrors.get(rowIndex) || {};
const fieldKey = 'upc';
newErrors.set(rowIndex, {
...rowErrors,
[fieldKey]: [{
message: result.message || 'Invalid UPC',
level: 'error',
source: 'validation'
}]
});
return newErrors;
});
return { success: false };
} }
} else if (result.data && result.data.itemNumber) {
// Success case - update with new item number
setUpcValidationResults(prev => {
const newResults = new Map(prev);
newResults.set(rowIndex, { itemNumber: result.data!.itemNumber });
return newResults;
});
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 || // Check each validation rule
value === null || field.validations?.forEach(validation => {
value === '' || // Skip if already has an error for this rule
(Array.isArray(value) && value.length === 0); if (errors.some(e => e.message === validation.errorMessage)) return;
if (isEmpty) { 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
errors.push({ if (rule === 'regex' && validation.value) {
message: validation.errorMessage || 'Invalid format', const regex = new RegExp(validation.value);
level: validation.level || 'error', if (!regex.test(String(processedValue))) {
source: 'validation' // Mark as validation error errors.push({
}); message: validation.errorMessage || 'Invalid format',
} level: validation.level || 'error',
} catch (error) { source: 'validation'
console.error('Invalid regex in validation:', error); });
}
} }
} }
else if (validation.rule === 'unique') {
// Unique validation will be handled at the table level // Unique validation is handled separately in batch processing
// This is just a placeholder for now });
}
}
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>;
})
// 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;
});
});
// Validate just this single field immediately to provide feedback // 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,23 +751,31 @@ 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>);
// Update the errors for this field // Only update if errors have changed
const updatedRowErrors = { const currentFieldErrors = rowErrorsMap[key] || [];
...rowErrorsMap, const errorsChanged =
[key]: fieldErrors fieldErrors.length !== currentFieldErrors.length ||
}; JSON.stringify(fieldErrors) !== JSON.stringify(currentFieldErrors);
// Update the validation errors if (errorsChanged) {
currentRowErrors.set(rowIndex, updatedRowErrors); // Update the errors for this field
setValidationErrors(currentRowErrors); const updatedRowErrors = {
...rowErrorsMap,
// Also update __errors in the data row [key]: fieldErrors
setData(prevData => { };
const newData = [...prevData];
const row = { ...newData[rowIndex], __errors: updatedRowErrors }; // Update the validation errors
newData[rowIndex] = row as RowData<T>; currentRowErrors.set(rowIndex, updatedRowErrors);
return newData; 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;
});
}
// 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
const updatedRowErrors = {
...rowErrorsMap,
[key]: updatedErrors
};
// 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;
});
}
} }
// Schedule validation for next frame
itemNumberValidationTimerRef.current = requestAnimationFrame(() => {
validateItemNumberUniqueness();
itemNumberValidationTimerRef.current = null;
});
} }
} }
}, 0); }, 100); // Small delay to batch updates
}, [data, fields, validateField, validationErrors]); }, [data, fields, validateField, validationErrors, validateUpc, validateItemNumberUniqueness]);
// 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,59 +1253,106 @@ 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 row = data[rowIndex]; const BATCH_SIZE = 50;
const fieldErrors: Record<string, ErrorType[]> = {}; let currentBatch = 0;
let hasErrors = false;
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 fieldErrors: Record<string, ErrorType[]> = {};
let hasErrors = false;
fields.forEach(field => { // Set default values for tax_cat and ship_restrictions if not already set
if (field.disabled) return; if (row.tax_cat === undefined || row.tax_cat === null || row.tax_cat === '') {
const key = String(field.key); newData[rowIndex] = {
const value = row[key as keyof typeof row]; ...newData[rowIndex],
const errors = validateField(value, field as Field<T>); tax_cat: '0'
if (errors.length > 0) { } as RowData<T>;
fieldErrors[key] = errors; }
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 => {
if (field.disabled) return;
const key = String(field.key);
const value = row[key as keyof typeof row];
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
fieldErrors[key] = errors;
hasErrors = true;
}
});
// Special validation for supplier and company
if (!row.supplier) {
fieldErrors['supplier'] = [{
message: 'Supplier is required',
level: 'error',
source: 'required'
}];
hasErrors = true; hasErrors = true;
} }
}); if (!row.company) {
fieldErrors['company'] = [{
message: 'Company is required',
level: 'error',
source: 'required'
}];
hasErrors = true;
}
if (hasErrors) {
initialErrors.set(rowIndex, fieldErrors);
initialStatus.set(rowIndex, 'error');
} else {
initialStatus.set(rowIndex, 'validated');
}
newData[rowIndex] = {
...newData[rowIndex],
__errors: Object.keys(fieldErrors).length > 0 ? fieldErrors : undefined
};
}
// Special validation for supplier and company currentBatch++;
if (!row.supplier) {
fieldErrors['supplier'] = [{ // If there are more batches to process, schedule the next one
message: 'Supplier is required', if (endIdx < data.length) {
level: 'error', setTimeout(processBatch, 0);
source: 'required'
}];
hasErrors = true;
}
if (!row.company) {
fieldErrors['company'] = [{
message: 'Company is required',
level: 'error',
source: 'required'
}];
hasErrors = true;
}
if (hasErrors) {
initialErrors.set(rowIndex, fieldErrors);
initialStatus.set(rowIndex, 'error');
} else { } else {
initialStatus.set(rowIndex, 'validated'); // All batches processed, update state
setData(newData);
setRowValidationStatus(initialStatus);
setValidationErrors(initialErrors);
console.log('Basic field validation complete');
// Schedule UPC validations after basic validation is complete
setTimeout(() => {
runUPCValidation();
}, 100);
} }
};
newData[rowIndex] = {
...newData[rowIndex], // Start processing batches
__errors: Object.keys(fieldErrors).length > 0 ? fieldErrors : undefined processBatch();
};
}
// Batch update all state at once
setData(newData);
setRowValidationStatus(initialStatus);
setValidationErrors(initialErrors);
console.log('Basic field validation complete');
}; };
// 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;