2 Commits

35 changed files with 1386 additions and 5229 deletions

View File

@@ -742,7 +742,7 @@ router.post("/validate", async (req, res) => {
console.log("🤖 Sending request to OpenAI...");
const completion = await openai.chat.completions.create({
model: "gpt-4o",
model: "o3-mini",
messages: [
{
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 = '';
const upcStr = String(upc);
// Extract the last 6 digits of the UPC, removing the last digit (checksum)
// So we get 5 digits from positions: length-7 to length-2
if (upcStr.length >= 7) {
const lastSixMinusOne = upcStr.substring(upcStr.length - 7, upcStr.length - 1);
itemNumber = `${supplierId}-${lastSixMinusOne}`;
} else if (upcStr.length >= 6) {
// Extract the last 5 digits of the UPC, removing the last digit (checksum)
// So we get 5 digits from positions: length-6 to length-2
if (upcStr.length >= 6) {
const lastFiveMinusOne = upcStr.substring(upcStr.length - 6, upcStr.length - 1);
itemNumber = `${supplierId}-${lastFiveMinusOne}`;
} else if (upcStr.length >= 5) {
// If UPC is shorter, use as many digits as possible
const digitsToUse = upcStr.substring(0, upcStr.length - 1);
itemNumber = `${supplierId}-${digitsToUse}`;

View File

@@ -37,18 +37,22 @@ import {
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { createPortal } from "react-dom";
type Props<T extends string = string> = {
data: any[];
type Product = {
id?: string | number;
name?: string;
supplier_no?: string;
upc?: string;
sku?: string;
model?: string;
product_images?: string | string[];
};
type Props = {
data: Product[];
file: File;
onBack?: () => void;
onSubmit: (data: any[], file: File) => void | Promise<any>;
onSubmit: (data: Product[], file: File) => void | Promise<any>;
}
type ProductImage = {
@@ -56,6 +60,12 @@ type ProductImage = {
imageUrl: string;
loading: boolean;
fileName: string;
pid?: string | number;
iid?: string;
width?: number;
height?: number;
type?: number;
order?: number;
}
type UnassignedImage = {
@@ -68,13 +78,13 @@ type ProductImageSortable = ProductImage & {
id: string;
};
export const ImageUploadStep = <T extends string>({
export const ImageUploadStep = ({
data,
file,
onBack,
onSubmit
}: Props<T>) => {
const { translations } = useRsi<T>();
}: Props) => {
useRsi();
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]);
@@ -95,7 +105,7 @@ export const ImageUploadStep = <T extends string>({
// Convert existing product_images to ProductImageSortable objects
const initialImages: ProductImageSortable[] = [];
data.forEach((product, productIndex) => {
data.forEach((product: Product, productIndex: number) => {
if (product.product_images) {
let imageUrls: string[] = [];
@@ -119,7 +129,11 @@ export const ImageUploadStep = <T extends string>({
productIndex,
imageUrl: url.trim(),
loading: false,
fileName: `Image ${i + 1}`
fileName: `Image ${i + 1}`,
pid: product.id || '',
iid: '',
width: 0,
height: 0
});
}
});
@@ -232,7 +246,7 @@ export const ImageUploadStep = <T extends string>({
// Add product IDs to the valid droppable elements
useEffect(() => {
// Add data-droppable attributes to make product containers easier to identify
data.forEach((_, index) => {
data.forEach((_: Product, index: number) => {
const container = document.getElementById(`product-${index}`);
if (container) {
container.setAttribute('data-droppable', 'true');
@@ -255,7 +269,7 @@ export const ImageUploadStep = <T extends string>({
// Effect to register browser-level drag events on product containers
useEffect(() => {
// For each product container
data.forEach((_, index) => {
data.forEach((_: Product, index: number) => {
const container = document.getElementById(`product-${index}`);
if (container) {
@@ -343,88 +357,6 @@ export const ImageUploadStep = <T extends string>({
};
// Function to add an image URL to a product
const addImageToProduct = (productIndex: number, imageUrl: string, imageData?: any) => {
// Create a copy of the data
const newData = [...data];
// Get the current product
const product = newData[productIndex];
// Initialize product_images array if it doesn't exist
if (!product.product_images) {
product.product_images = [];
}
// Handle different formats of product_images
let images: any[] = [];
if (typeof product.product_images === 'string') {
try {
// Try to parse as JSON
images = JSON.parse(product.product_images);
} catch (e) {
// If not JSON, split by comma if it's a string
images = product.product_images.split(',').filter(Boolean).map(url => ({
imageUrl: url.trim(),
pid: product.id || 0,
iid: 0,
type: 0,
order: 255,
width: 0,
height: 0,
hidden: 0
}));
}
} else if (Array.isArray(product.product_images)) {
// Use the array directly
images = product.product_images;
} else if (product.product_images) {
// Handle case where it might be a single value
images = [product.product_images];
}
// Check if the image URL already exists
const exists = images.some(img => {
const imgUrl = typeof img === 'string' ? img : img.imageUrl;
return imgUrl === imageUrl;
});
// Only add if the URL doesn't already exist
if (!exists) {
// Create a new image object with schema fields
const newImage = imageData || {
imageUrl,
pid: product.id || 0,
iid: Math.floor(Math.random() * 10000), // Generate a temporary iid
type: 0,
order: images.length,
width: 0,
height: 0,
hidden: 0
};
// If imageData is a string, convert it to an object
if (typeof newImage === 'string') {
newImage = {
imageUrl: newImage,
pid: product.id || 0,
iid: Math.floor(Math.random() * 10000),
type: 0,
order: images.length,
width: 0,
height: 0,
hidden: 0
};
}
images.push(newImage);
}
// Update the product_images field
product.product_images = images;
return newData;
};
// Update handleDragEnd to work with the updated product data structure
const handleDragEnd = (event: DragEndEvent) => {
@@ -524,13 +456,8 @@ export const ImageUploadStep = <T extends string>({
});
// Update both products' image data fields
let updatedData = [...data]; // Start with a fresh copy
// First remove from source
updatedData = removeImageFromProduct(sourceProductIndex, activeImage.imageUrl);
// Then add to target
updatedData = addImageToProduct(targetProductIndex, activeImage.imageUrl);
// Show notification
toast.success(`Image moved from ${data[sourceProductIndex].name || `Product #${sourceProductIndex + 1}`} to ${data[targetProductIndex].name || `Product #${targetProductIndex + 1}`}`);
@@ -602,44 +529,41 @@ export const ImageUploadStep = <T extends string>({
setActiveImage(null);
};
// Function to handle image upload - update product data
const handleImageUpload = async (files: FileList | File[], productIndex: number) => {
// Function to handle image upload
const handleImageUpload = async (files: FileList | File[], productIndex: number): Promise<void> => {
if (!files || files.length === 0) return;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileArray = Array.from(files);
// Add placeholder for this image
const newImage: ProductImageSortable = {
id: `image-${productIndex}-${Date.now()}-${i}`, // Generate a unique ID
for (let i = 0; i < fileArray.length; i++) {
const file = fileArray[i];
// Create initial image data
let imageData: ProductImageSortable = {
id: `image-${productIndex}-${Date.now()}-${i}`,
productIndex,
imageUrl: '',
loading: true,
fileName: file.name,
// Add schema fields
pid: data[productIndex].id || 0,
iid: 0, // Will be assigned by server
pid: String(data[productIndex].id || ''),
iid: '',
type: 0,
order: productImages.filter(img => img.productIndex === productIndex).length + i,
width: 0,
height: 0,
hidden: 0
order: productImages.filter(img => img.productIndex === productIndex).length + i
};
setProductImages(prev => [...prev, newImage]);
// Create form data for upload
const formData = new FormData();
formData.append('image', file);
formData.append('productIndex', productIndex.toString());
formData.append('upc', data[productIndex].upc || '');
formData.append('supplier_no', data[productIndex].supplier_no || '');
// Add to state
setProductImages(prev => [...prev, imageData]);
try {
// Upload the image
const response = await fetch(`${config.apiUrl}/import/upload-image`, {
// Create form data for upload
const formData = new FormData();
formData.append('file', file);
formData.append('productIndex', String(productIndex));
// Upload the file
const response = await fetch(`${config.apiUrl}/upload-image`, {
method: 'POST',
body: formData,
body: formData
});
if (!response.ok) {
@@ -648,38 +572,32 @@ export const ImageUploadStep = <T extends string>({
const result = await response.json();
// Update the image URL in our state
setProductImages(prev =>
prev.map(img =>
(img.loading && img.productIndex === productIndex && img.fileName === file.name)
? {
...img,
imageUrl: result.imageUrl,
loading: false,
// Update schema fields if returned from server
iid: result.iid || img.iid,
width: result.width || img.width,
height: result.height || img.height
}
: img
)
);
// Update the image data with server response
if (result) {
imageData = {
...imageData,
imageUrl: result.imageUrl,
loading: false,
iid: result.iid || imageData.iid,
width: result.width || imageData.width,
height: result.height || imageData.height
};
// Update the product data with the new image URL
addImageToProduct(productIndex, result.imageUrl, result);
toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
// Update the product images state
setProductImages(prev =>
prev.map(img =>
img.id === imageData.id ? imageData : img
)
);
}
} catch (error) {
console.error('Upload error:', error);
console.error('Error uploading image:', error);
toast.error('Failed to upload image');
// Remove the failed image from our state
// Remove the placeholder image on error
setProductImages(prev =>
prev.filter(img =>
!(img.loading && img.productIndex === productIndex && img.fileName === file.name)
)
prev.filter(img => img.id !== imageData.id)
);
toast.error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
};
@@ -717,7 +635,7 @@ export const ImageUploadStep = <T extends string>({
// Function to find product index by identifier
const findProductByIdentifier = (identifier: string): number => {
// Try to match against supplier_no, upc, SKU, or name
return data.findIndex(product => {
return data.findIndex((product: Product) => {
// Skip if product is missing all identifiers
if (!product.supplier_no && !product.upc && !product.sku && !product.name) {
return false;
@@ -756,7 +674,7 @@ export const ImageUploadStep = <T extends string>({
};
// Function to handle bulk image upload
const handleBulkUpload = async (files: File[]) => {
const handleBulkUpload = async (files: File[]): Promise<void> => {
if (!files.length) return;
setProcessingBulk(true);
@@ -765,26 +683,80 @@ export const ImageUploadStep = <T extends string>({
for (const file of files) {
// Extract identifiers from filename
const identifiers = extractIdentifiers(file.name);
let assigned = false;
// Try to match each identifier
// Try to find matching product
let productIndex = -1;
for (const identifier of identifiers) {
const productIndex = findProductByIdentifier(identifier);
if (productIndex !== -1) {
// Found a match, upload to this product
await handleImageUpload([file], productIndex);
assigned = true;
break;
}
productIndex = findProductByIdentifier(identifier);
if (productIndex !== -1) break;
}
// If no match was found, add to unassigned
if (!assigned) {
unassigned.push({
file,
previewUrl: createPreviewUrl(file)
});
if (productIndex === -1) {
// If no match found, add to unassigned images
const previewUrl = createPreviewUrl(file);
setUnassignedImages(prev => [...prev, { file, previewUrl }]);
} else {
// If match found, add to product images
let imageData: ProductImageSortable = {
id: `image-${productIndex}-${Date.now()}`,
productIndex,
imageUrl: '',
loading: true,
fileName: file.name,
pid: String(data[productIndex].id || ''),
iid: '',
type: 0,
order: productImages.filter(img => img.productIndex === productIndex).length
};
// Add to state
setProductImages(prev => [...prev, imageData]);
try {
// Create form data for upload
const formData = new FormData();
formData.append('file', file);
formData.append('productIndex', String(productIndex));
// Upload the file
const response = await fetch(`${config.apiUrl}/upload-image`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Failed to upload image');
}
const result = await response.json();
// Update the image data with server response
if (result) {
imageData = {
...imageData,
imageUrl: result.imageUrl,
loading: false,
iid: result.iid || imageData.iid,
width: result.width || imageData.width,
height: result.height || imageData.height
};
// Update the product images state
setProductImages(prev =>
prev.map(img =>
img.id === imageData.id ? imageData : img
)
);
}
} catch (error) {
console.error('Error uploading image:', error);
toast.error('Failed to upload image');
// Remove the placeholder image on error
setProductImages(prev =>
prev.filter(img => img.id !== imageData.id)
);
}
}
}
@@ -938,18 +910,13 @@ export const ImageUploadStep = <T extends string>({
}
}, [data, file, onSubmit, productImages]);
// Function to ensure URLs are properly formatted with absolute paths
const getFullImageUrl = (url: string): string => {
// If the URL is already absolute (starts with http:// or https://) return it as is
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
// Function to get full image URL
const getFullImageUrl = (urlInput: string | undefined | null): string => {
if (!urlInput) return '';
if (urlInput.startsWith('http://') || urlInput.startsWith('https://')) {
return urlInput;
}
// Otherwise, it's a relative URL, prepend the domain
const baseUrl = 'https://inventory.acot.site';
// Make sure url starts with / for path
const path = url.startsWith('/') ? url : `/${url}`;
return `${baseUrl}${path}`;
return `${config.apiUrl}/images/${urlInput}`;
};
// Generic dropzone component
@@ -1057,7 +1024,7 @@ export const ImageUploadStep = <T extends string>({
<SelectValue placeholder="Assign to..." />
</SelectTrigger>
<SelectContent>
{data.map((product: any, productIndex: number) => (
{data.map((product: Product, productIndex: number) => (
<SelectItem key={productIndex} value={productIndex.toString()}>
{product.name || `Product #${productIndex + 1}`}
</SelectItem>
@@ -1342,112 +1309,76 @@ export const ImageUploadStep = <T extends string>({
};
// Add a URL input component that doesn't expand/collapse
const ImageUrlInput = ({ productIndex }: { productIndex: number }) => {
// Use a stable format that won't get affected by DndContext events
return (
<form
className="flex items-center gap-1"
onSubmit={(e) => {
e.preventDefault();
if (urlInputs[productIndex]) {
handleAddImageFromUrl(productIndex, urlInputs[productIndex]);
}
}}
>
<Input
placeholder="Image URL"
value={urlInputs[productIndex] || ''}
onChange={(e) => updateUrlInput(productIndex, e.target.value)}
className="text-xs h-8 max-w-[180px]"
/>
<Button
type="submit"
size="sm"
className="h-8 whitespace-nowrap"
disabled={processingUrls[productIndex] || !urlInputs[productIndex]}
>
{processingUrls[productIndex] ?
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> :
<Link2 className="h-3.5 w-3.5 mr-1" />}
Add
</Button>
</form>
);
};
// Handle adding an image from a URL - simplified to skip server
const handleAddImageFromUrl = async (productIndex: number, url: string) => {
if (!url || !url.trim()) {
toast.error("Please enter a valid URL");
return;
}
// Function to handle adding image from URL
const handleAddImageFromUrl = async (productIndex: number, urlInput: string): Promise<void> => {
if (!urlInput) return;
// Set processing state
setProcessingUrls(prev => ({ ...prev, [productIndex]: true }));
// Create initial image data
let newImage: ProductImageSortable = {
id: `image-${productIndex}-${Date.now()}`,
productIndex,
imageUrl: urlInput,
loading: true,
fileName: urlInput.split('/').pop() || 'url-image',
pid: String(data[productIndex].id || ''),
iid: '',
type: 0,
order: productImages.filter(img => img.productIndex === productIndex).length
};
// Add to state
setProductImages(prev => [...prev, newImage]);
try {
// Set processing state
setProcessingUrls(prev => ({ ...prev, [productIndex]: true }));
// Validate URL format
let validatedUrl = url.trim();
// Add protocol if missing
if (!validatedUrl.startsWith('http://') && !validatedUrl.startsWith('https://')) {
validatedUrl = `https://${validatedUrl}`;
const validUrl = getFullImageUrl(urlInput);
if (!validUrl) {
throw new Error('Invalid URL format');
}
// Basic URL validation
try {
new URL(validatedUrl);
} catch (e) {
toast.error("Invalid URL format. Please enter a valid URL");
setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
return;
}
// Create a unique ID for this image
const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// Get the next order value for this product
const nextOrder = productImages
.filter(img => img.productIndex === productIndex)
.length;
// Create the new image object with the URL
const newImage: ProductImageSortable = {
id: imageId,
productIndex,
imageUrl: validatedUrl,
loading: false, // We're not loading from server, so it's ready immediately
fileName: "From URL",
// Add schema fields
pid: data[productIndex].id || 0,
iid: Math.floor(Math.random() * 10000), // Generate a temporary iid
type: 0,
order: nextOrder,
width: 0,
height: 0,
hidden: 0
// Update image data with validated URL
newImage = {
...newImage,
imageUrl: validUrl,
loading: false
};
// Add the image directly to the product images list
setProductImages(prev => [...prev, newImage]);
// Update product images state
setProductImages(prev =>
prev.map(img =>
img.id === newImage.id ? newImage : img
)
);
// Update the product data with the new image URL
addImageToProduct(productIndex, validatedUrl, newImage);
// Clear URL input
updateUrlInput(productIndex, '');
// Clear the URL input field on success
setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));
toast.success(`Image URL added to ${data[productIndex].name || `Product #${productIndex + 1}`}`);
toast.success(`Image added from URL for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
} catch (error) {
console.error('Add image from URL error:', error);
toast.error(`Failed to add image URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
console.error('Error adding image from URL:', error);
toast.error('Failed to add image from URL');
// Remove the placeholder image on error
setProductImages(prev =>
prev.filter(img => img.id !== newImage.id)
);
} finally {
// Clear processing state
setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
}
};
// Function to update URL input
const updateUrlInput = (productIndex: number, value: string): void => {
setUrlInputs((prev: { [key: number]: string }) => ({ ...prev, [productIndex]: value }));
};
// Function to copy text to clipboard
const copyToClipboard = (text: string, key: string) => {
const copyToClipboard = (text: string, key: string): void => {
if (!text || text === 'N/A') return;
navigator.clipboard.writeText(text)
@@ -1500,11 +1431,6 @@ export const ImageUploadStep = <T extends string>({
);
};
// Update the URL input value
const updateUrlInput = (productIndex: number, value: string) => {
setUrlInputs(prev => ({ ...prev, [productIndex]: value }));
};
return (
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
{/* Header - fixed at top */}
@@ -1555,7 +1481,7 @@ export const ImageUploadStep = <T extends string>({
)}
<div className="space-y-2">
{data.map((product: any, index: number) => (
{data.map((product: Product, index: number) => (
<Card
key={index}
className={cn(
@@ -1585,7 +1511,6 @@ export const ImageUploadStep = <T extends string>({
e.preventDefault();
if (urlInputs[index]) {
handleAddImageFromUrl(index, urlInputs[index]);
updateUrlInput(index, '');
}
}}
>

View File

@@ -535,7 +535,6 @@ const SubLineSelector = React.memo(({
// Add this new component before the MatchColumnsStep component
const FieldSelector = React.memo(({
column,
isUnmapped = false,
fieldCategories,
allFields,
onChange,
@@ -794,18 +793,13 @@ export const MatchColumnsStep = React.memo(<T extends string>({
const fieldOptions = fieldOptionsData || { suppliers: [], companies: [] };
// Create a stable identity for these queries to avoid re-renders
const stableFieldOptions = useMemo(() => fieldOptionsData || { suppliers: [], companies: [] }, [fieldOptionsData]);
const stableProductLines = useMemo(() => productLines || [], [productLines]);
const stableSublines = useMemo(() => sublines || [], [sublines]);
const stableMappedProductLines = useMemo(() => mappedProductLines || [], [mappedProductLines]);
const stableMappedSublines = useMemo(() => mappedSublines || [], [mappedSublines]);
// Type guard for suppliers and companies
const hasSuppliers = (options: any): options is { suppliers: any[] } =>
options && Array.isArray(options.suppliers);
const hasCompanies = (options: any): options is { companies: any[] } =>
options && Array.isArray(options.companies);
// Check if a field is covered by global selections
const isFieldCoveredByGlobalSelections = useCallback((key: string) => {
@@ -976,20 +970,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
}, [availableFields]);
// Group all fields by category (for editing mapped columns)
const allFieldCategories = useMemo(() => {
return [
{ name: "Basic Info", fields: getFieldsByKeyPrefix('basic', allFields) },
{ name: "Product", fields: getFieldsByKeyPrefix('product', allFields) },
{ name: "Inventory", fields: getFieldsByKeyPrefix('inventory', allFields) },
{ name: "Pricing", fields: getFieldsByKeyPrefix('pricing', allFields) },
{ name: "Other", fields: allFields.filter(f =>
!f.key.startsWith('basic') &&
!f.key.startsWith('product') &&
!f.key.startsWith('inventory') &&
!f.key.startsWith('pricing')
) }
].filter(category => category.fields.length > 0);
}, [allFields, getFieldsByKeyPrefix]);
// Group available fields by category (for unmapped columns)
const availableFieldCategories = useMemo(() => {

View File

@@ -15,30 +15,30 @@ export const normalizeTableData = <T extends string>(columns: Columns<T>, data:
(key) => key.toLowerCase() === curr?.toLowerCase(),
)!
const booleanMatch = field.fieldType.booleanMatches?.[booleanMatchKey]
acc[column.value] = booleanMatchKey ? booleanMatch : normalizeCheckboxValue(curr)
acc[column.value] = (booleanMatchKey ? booleanMatch : normalizeCheckboxValue(curr)) as Data<T>[T]
} else {
acc[column.value] = normalizeCheckboxValue(curr)
acc[column.value] = normalizeCheckboxValue(curr) as Data<T>[T]
}
return acc
}
case ColumnType.matched: {
acc[column.value] = curr === "" ? undefined : curr
acc[column.value] = (curr === "" ? undefined : curr) as Data<T>[T]
return acc
}
case ColumnType.matchedMultiInput: {
const field = fields.find((field) => field.key === column.value)!
if (curr) {
const separator = field.fieldType.type === "multi-input" ? field.fieldType.separator || "," : ","
acc[column.value] = curr.split(separator).map(v => v.trim()).filter(Boolean)
acc[column.value] = curr.split(separator).map(v => v.trim()).filter(Boolean) as Data<T>[T]
} else {
acc[column.value] = undefined
acc[column.value] = undefined as Data<T>[T]
}
return acc
}
case ColumnType.matchedSelect:
case ColumnType.matchedSelectOptions: {
const matchedOption = column.matchedOptions.find(({ entry, value }) => entry === curr)
acc[column.value] = matchedOption?.value || undefined
const matchedOption = column.matchedOptions.find(({ entry }) => entry === curr)
acc[column.value] = (matchedOption?.value || undefined) as Data<T>[T]
return acc
}
case ColumnType.matchedMultiSelect: {
@@ -50,9 +50,9 @@ export const normalizeTableData = <T extends string>(columns: Columns<T>, data:
const matchedOption = column.matchedOptions.find(({ entry: optEntry }) => optEntry === entry)
return matchedOption?.value
}).filter(Boolean) as string[]
acc[column.value] = values.length ? values : undefined
acc[column.value] = (values.length ? values : undefined) as Data<T>[T]
} else {
acc[column.value] = undefined
acc[column.value] = undefined as Data<T>[T]
}
return acc
}

View File

@@ -1,8 +1,7 @@
import DataGrid, { Column, FormatterProps, useRowSelection } from "react-data-grid"
import { Column, FormatterProps, useRowSelection } from "react-data-grid"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import type { RawData } from "../../../types"
import { cn } from "@/lib/utils"
const SELECT_COLUMN_KEY = "select-row"
@@ -58,7 +57,7 @@ export const generateSelectionColumns = (data: RawData[]) => {
key: index.toString(),
name: `Column ${index + 1}`,
width: 150,
formatter: ({ row }) => (
formatter: ({ row }: { row: RawData }) => (
<div className="p-2 overflow-hidden text-ellipsis whitespace-nowrap">
{row[index]}
</div>

View File

@@ -6,13 +6,13 @@ import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStepNew } from "./ValidationStepNew"
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep"
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
import { useRsi } from "../hooks/useRsi"
import type { RawData } from "../types"
import type { RawData, Data } from "../types"
import { Progress } from "@/components/ui/progress"
import { useToast } from "@/hooks/use-toast"
import { addErrorsAndRunHooks } from "./ValidationStepNew/utils/dataMutations"
export enum StepType {
upload = "upload",
@@ -185,7 +185,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
// Apply global selections to each row of data if they exist
const dataWithGlobalSelections = globalSelections
? dataWithMeta.map(row => {
? dataWithMeta.map((row: Data<string> & { __errors?: any; __index?: string }) => {
const newRow = { ...row };
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
if (globalSelections.company) newRow.company = globalSelections.company;

View File

@@ -1,25 +0,0 @@
import type { Fields } from "../../../types"
import { useMemo } from "react"
import { Table } from "../../../components/Table"
import { generateColumns } from "./columns"
import { generateExampleRow } from "../utils/generateExampleRow"
interface Props<T extends string> {
fields: Fields<T>
}
export const ExampleTable = <T extends string>({ fields }: Props<T>) => {
const data = useMemo(() => generateExampleRow(fields), [fields])
const columns = useMemo(() => generateColumns(fields), [fields])
return (
<div className="h-full w-full">
<Table
rows={data}
columns={columns}
className="rdg-example h-full"
style={{ height: '100%' }}
/>
</div>
)
}

View File

@@ -1,5 +0,0 @@
export const FadingOverlay = () => (
<div
className="absolute inset-x-0 bottom-0 h-12 pointer-events-none bg-gradient-to-t from-background to-transparent"
/>
)

View File

@@ -1,14 +0,0 @@
import type { Field, Fields } from "../../../types"
const titleMap: Record<Field<string>["fieldType"]["type"], string> = {
checkbox: "Boolean",
select: "Options",
input: "Text",
}
export const generateExampleRow = <T extends string>(fields: Fields<T>) => [
fields.reduce((acc, field) => {
acc[field.key as T] = field.example || titleMap[field.fieldType.type]
return acc
}, {} as Record<T, string>),
]

View File

@@ -1,9 +0,0 @@
export const getDropZoneBorder = (color: string) => {
return {
bgGradient: `repeating-linear(0deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(90deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(180deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(270deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px)`,
backgroundSize: "2px 100%, 100% 2px, 2px 100% , 100% 2px",
backgroundPosition: "0 0, 0 0, 100% 0, 0 100%",
backgroundRepeat: "no-repeat",
borderRadius: "4px",
}
}

View File

@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Loader2, CheckIcon } from 'lucide-react';
import { Code } from '@/components/ui/code';
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 {
aiValidationProgress: AiValidationProgress;
@@ -79,7 +79,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
<div
className="h-full bg-primary transition-all duration-500"
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
}}
/>

View File

@@ -50,7 +50,17 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
const [searchTerm, setSearchTerm] = useState("");
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [] = 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
useEffect(() => {
@@ -158,11 +168,14 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
const getDisplayText = useCallback(() => {
try {
if (!value) return placeholder;
const template = templates.find(t => t.id.toString() === value);
if (!template) return placeholder;
return getTemplateDisplayText(value);
} catch (err) {
console.error('Error getting display text:', err);
return placeholder;
}
}, [getTemplateDisplayText, placeholder, value]);
}, [getTemplateDisplayText, placeholder, value, templates]);
// Safe render function for CommandItem
const renderCommandItem = useCallback((template: Template) => {
@@ -181,7 +194,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
setOpen(false);
setSearchTerm("");
} catch (err) {
setError("Error selecting template");
console.error('Error selecting template:', err);
}
}}
className="flex items-center justify-between"
@@ -191,6 +204,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
</CommandItem>
);
} catch (err) {
console.error('Error rendering template item:', err);
return null;
}
}, [onValueChange, value, getTemplateDisplayText]);

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
@@ -22,7 +22,7 @@ import {
CommandItem,
CommandList
} from '@/components/ui/command'
import { ChevronsUpDown, Check, X } from 'lucide-react'
import { ChevronsUpDown, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Template } from '../hooks/useValidationState'
import { toast } from 'sonner'

View File

@@ -51,10 +51,21 @@ const BaseCellContent = React.memo(({
hasErrors: boolean;
options?: readonly any[];
}) => {
// Get field type information
const fieldType = typeof field.fieldType === 'string'
? field.fieldType
: 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') {
return (
<SelectCell
@@ -85,6 +96,8 @@ const BaseCellContent = React.memo(({
value={value}
onChange={onChange}
hasErrors={hasErrors}
isMultiline={isMultiline}
isPrice={isPrice}
/>
);
}, (prev, next) => {
@@ -134,7 +147,7 @@ const ItemNumberCell = React.memo(({
return (
<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 ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
@@ -151,7 +164,7 @@ const ItemNumberCell = React.memo(({
/>
</div>
)}
{nonRequiredErrors.length > 0 && !isRequiredButEmpty && (
{nonRequiredErrors.length > 0 && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-20">
<ValidationIcon error={{
message: nonRequiredErrors.map(e => e.message).join('\n'),
@@ -198,15 +211,34 @@ const ValidationCell = ({
// Error states
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');
// 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 (
<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
field={field}
value={value}
@@ -216,7 +248,7 @@ const ValidationCell = ({
/>
</div>
{nonRequiredErrors.length > 0 && !isRequiredButEmpty && (
{nonRequiredErrors.length > 0 && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-20">
<ValidationIcon error={{
message: nonRequiredErrors.map(e => e.message).join('\n'),

View File

@@ -11,6 +11,7 @@ import SearchableTemplateSelect from './SearchableTemplateSelect'
import { useAiValidation } from '../hooks/useAiValidation'
import { AiValidationDialogs } from './AiValidationDialogs'
import config from '@/config'
import { Fields } from '../../../types'
/**
* ValidationContainer component - the main wrapper for the validation step
@@ -351,8 +352,33 @@ const ValidationContainer = <T extends string>({
// Enhanced updateRow function - memoized
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
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
const rowData = filteredData[rowIndex];
@@ -502,7 +528,7 @@ const ValidationContainer = <T extends string>({
const aiValidation = useAiValidation<T>(
data,
setData,
fields,
fields as Fields<T>,
// Create a wrapper function that adapts the rowHook to the expected signature
validationState.rowHook ?
async (row) => {
@@ -590,31 +616,34 @@ const ValidationContainer = <T extends string>({
}, [itemNumbers, validatingUpcRows]);
// Memoize the ValidationTable to prevent unnecessary re-renders
const renderValidationTable = useMemo(() => (
<EnhancedValidationTable
data={filteredData}
fields={validationState.fields}
updateRow={(rowIndex: number, key: string, value: any) =>
enhancedUpdateRow(rowIndex, key as T, value)
}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
validationErrors={validationErrors}
isValidatingUpc={isRowValidatingUpc}
validatingUpcRows={Array.from(validatingUpcRows)}
filters={filters}
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
rowProductLines={rowProductLines}
rowSublines={rowSublines}
isLoadingLines={isLoadingLines}
isLoadingSublines={isLoadingSublines}
upcValidationResults={new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), { itemNumber: value }]))}
validatingCells={new Set()}
itemNumbers={new Map()}
/>
), [
const renderValidationTable = useMemo(() => {
return (
<EnhancedValidationTable
data={filteredData}
// @ts-ignore - The fields are compatible at runtime but TypeScript has issues with the exact type
fields={validationState.fields}
updateRow={(rowIndex: number, key: string, value: any) =>
enhancedUpdateRow(rowIndex, key as T, value)
}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
validationErrors={validationErrors}
isValidatingUpc={isRowValidatingUpc}
validatingUpcRows={Array.from(validatingUpcRows)}
filters={filters}
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
rowProductLines={rowProductLines}
rowSublines={rowSublines}
isLoadingLines={isLoadingLines}
isLoadingSublines={isLoadingSublines}
upcValidationResults={new Map(Object.entries(itemNumbers).map(([key, value]) => [parseInt(key), { itemNumber: value }]))}
validatingCells={new Set()}
itemNumbers={new Map()}
/>
);
}, [
EnhancedValidationTable,
filteredData,
validationState.fields,

View File

@@ -1,65 +0,0 @@
import React, { useEffect, useCallback } from 'react';
import { useValidationState } from '../hooks/useValidationState';
import ValidationTable from './ValidationTable';
import { Fields, Field } from '../../../types';
import { RowData } from '../hooks/useValidationState';
interface ValidationStepProps<T extends string> {
data: RowData<T>[];
fields: Fields<T>;
onContinue?: (data: RowData<T>[]) => void;
onBack?: () => void;
initialValidationErrors?: Map<number, Record<string, any>>;
initialValidationState?: Map<number, 'pending' | 'validating' | 'validated' | 'error'>;
}
export const ValidationStep = <T extends string>({
data,
fields,
onContinue,
onBack,
initialValidationErrors,
initialValidationState,
}: ValidationStepProps<T>) => {
const {
data: rowData,
validationErrors,
rowValidationStatus,
validateRow,
hasErrors,
fields: fieldsWithOptions,
rowSelection,
setRowSelection,
updateRow,
isValidatingUpc,
validatingUpcRows,
filters,
templates,
applyTemplate,
getTemplateDisplayText,
} = useValidationState({
initialData: data,
fields,
onNext: onContinue,
onBack,
});
return (
<div className="flex flex-col h-full">
<ValidationTable
data={rowData}
fields={fieldsWithOptions}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
updateRow={updateRow}
validationErrors={validationErrors}
isValidatingUpc={isValidatingUpc}
validatingUpcRows={validatingUpcRows}
filters={filters}
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
/>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'
import React, { useMemo, useRef, useEffect, useLayoutEffect } from 'react'
import {
useReactTable,
getCoreRowModel,
@@ -12,6 +12,8 @@ import ValidationCell from './ValidationCell'
import { useRsi } from '../../../hooks/useRsi'
import SearchableTemplateSelect from './SearchableTemplateSelect'
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
type ErrorType = {
@@ -249,6 +251,93 @@ const ValidationTable = <T extends string>({
}: ValidationTableProps<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
const templateColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
accessorKey: '__template',
@@ -256,6 +345,7 @@ const ValidationTable = <T extends string>({
size: 200,
cell: ({ row }) => {
const templateValue = row.original.__template || null;
const defaultBrand = row.original.company || undefined;
return (
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px' }}>
<SearchableTemplateSelect
@@ -264,9 +354,8 @@ const ValidationTable = <T extends string>({
onValueChange={(value) => {
applyTemplate(value, [row.index]);
}}
getTemplateDisplayText={(template) =>
template ? getTemplateDisplayText(template) : 'Select template'
}
getTemplateDisplayText={getTemplateDisplayText}
defaultBrand={defaultBrand}
/>
</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]);
// Combine columns
const columns = useMemo(() => [templateColumn, ...fieldColumns], [templateColumn, fieldColumns]);
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
const table = useReactTable({
data,
@@ -338,36 +427,53 @@ const ValidationTable = <T extends string>({
}
return (
<Table>
<TableHeader>
<TableRow>
{table.getFlatHeaders().map((header) => (
<TableHead
key={header.id}
style={{
width: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={validationErrors.get(row.index) ? "bg-red-50/40" : "hover:bg-muted/50"}
>
{row.getVisibleCells().map((cell) => (
flexRender(cell.column.columnDef.cell, cell.getContext())
<div ref={tableContainerRef} className="overflow-auto max-h-[calc(100vh-300px)]">
<Table>
<TableHeader>
<TableRow>
{table.getFlatHeaders().map((header) => (
<TableHead
key={header.id}
style={{
width: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`
}}
>
{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>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{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

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { Field } from '../../../../types'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'

View File

@@ -16,7 +16,6 @@ interface InputCellProps<T extends string> {
}
const InputCell = <T extends string>({
field,
value,
onChange,
onStartEdit,
@@ -31,9 +30,18 @@ const InputCell = <T extends string>({
// Handle focus event
const handleFocus = useCallback(() => {
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?.()
}, [value, onStartEdit])
}, [value, onStartEdit, isPrice])
// Handle blur event
const handleBlur = useCallback(() => {
@@ -57,6 +65,23 @@ const InputCell = <T extends string>({
onEndEdit?.()
}, [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
const getDisplayValue = useCallback(() => {
if (!isPrice || !value) return value
@@ -64,11 +89,12 @@ const InputCell = <T extends string>({
// Extract numeric part
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)
if (isNaN(numValue)) return value
return `$${numValue.toFixed(2)}`
// Return just the number without dollar sign
return numValue.toFixed(2)
}, [value, isPrice])
// Add outline even when not in focus
@@ -79,7 +105,7 @@ const InputCell = <T extends string>({
{isMultiline ? (
<Textarea
value={isEditing ? editValue : (value ?? '')}
onChange={(e) => setEditValue(e.target.value)}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
className={cn(
@@ -93,7 +119,7 @@ const InputCell = <T extends string>({
<Input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
autoFocus

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import React, { useState, useCallback, useMemo } from 'react'
import { Field } from '../../../../types'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
@@ -15,12 +14,6 @@ interface FieldOption {
value: string;
}
// Define extended field type that includes multi-select
interface MultiSelectFieldType {
type: 'multi-select';
options?: readonly FieldOption[];
separator?: string;
}
interface MultiInputCellProps<T extends string> {
field: Field<T>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import { useState } from 'react'
import { Field } from '../../../../types'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'

View File

@@ -1,8 +1,9 @@
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { getApiUrl, RowData } from './useValidationState';
import { Fields } from '../../../types';
import { addErrorsAndRunHooks } from '../../ValidationStep/utils/dataMutations';
import { Fields, InfoWithSource, ErrorSources } from '../../../types';
import { Meta } from '../types';
import { addErrorsAndRunHooks } from '../utils/dataMutations';
import * as Diff from 'diff';
// Define interfaces for AI validation
@@ -81,6 +82,51 @@ export const useAiValidation = <T extends string>(
// Track reverted changes
const [revertedChanges, setRevertedChanges] = useState<Set<string>>(new Set());
// Create an adapter for the rowHook to match the expected RowHook<T> type
const adaptedRowHook = rowHook ? async (
row: RowData<T> ): Promise<Meta> => {
// Call the original hook
const result = await rowHook(row);
// Extract Meta-specific properties
const { __index, __errors } = result;
// Return a Meta object with properly typed errors
return {
__index: __index || row.__index || '',
__errors: __errors ?
Object.fromEntries(
Object.entries(__errors).map(([key, value]) => {
const errorArray = Array.isArray(value) ? value : [value];
return [key, {
message: errorArray[0].message,
level: errorArray[0].level,
source: ErrorSources.Row
} as InfoWithSource]
})
) : null
};
} : undefined;
// Create an adapter for the tableHook to match the expected TableHook<T> type
const adaptedTableHook = tableHook ? async (rows: RowData<T>[]): Promise<Meta[]> => {
// Call the original hook
const results = await tableHook(rows);
// Extract Meta-specific properties from each result
return results.map((result, index) => ({
__index: result.__index || rows[index].__index || '',
__errors: result.__errors ?
Object.fromEntries(
Object.entries(result.__errors).map(([key, value]) => {
const errorArray = Array.isArray(value) ? value : [value];
return [key, {
message: errorArray[0].message,
level: errorArray[0].level,
source: ErrorSources.Table
} as InfoWithSource]
})
) : null
}));
} : undefined;
// Get field display value
const getFieldDisplayValue = useCallback((fieldKey: string, value: any): string => {
const field = fields.find(f => f.key === fieldKey);
@@ -543,8 +589,8 @@ export const useAiValidation = <T extends string>(
const validatedData = await addErrorsAndRunHooks<T>(
processedData,
fields,
rowHook,
tableHook
adaptedRowHook,
adaptedTableHook
);
// Update the component state with the validated data
@@ -620,7 +666,7 @@ export const useAiValidation = <T extends string>(
elapsedSeconds: prev.elapsedSeconds
}));
}
}, [isAiValidating, data, aiValidationProgress.estimatedSeconds, aiValidationProgress.promptLength, fields, rowHook, tableHook]);
}, [isAiValidating, data, aiValidationProgress.estimatedSeconds, aiValidationProgress.promptLength, fields, adaptedRowHook, adaptedTableHook]);
return {
isAiValidating,

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { Data } from '../../../types'
import { RowSelectionState } from '@tanstack/react-table'
import { Template, RowData, getApiUrl } from './useValidationState'
@@ -100,7 +99,11 @@ export const useTemplates = <T extends string>(
body: JSON.stringify({
company: template.company,
product_type: type,
...template
...Object.fromEntries(
Object.entries(template).filter(([key]) =>
!['company', 'product_type'].includes(key)
)
)
}),
})

View File

@@ -1,8 +1,7 @@
import { useCallback } from 'react'
import type { Data, Field, Fields, RowHook, TableHook } from '../../../types'
import type { Meta } from '../../ValidationStep/types'
import type { Field, Fields, RowHook, TableHook } from '../../../types'
import type { Meta } from '../types'
import { ErrorSources } from '../../../types'
import { addErrorsAndRunHooks } from '../../ValidationStep/utils/dataMutations'
import { RowData } from './useValidationState'
interface ValidationError {
@@ -10,6 +9,12 @@ interface ValidationError {
level: 'info' | 'warning' | 'error'
}
interface InfoWithSource {
message: string
level: 'info' | 'warning' | 'error'
source: ErrorSources
}
export const useValidation = <T extends string>(
fields: Fields<T>,
rowHook?: RowHook<T>,
@@ -62,31 +67,31 @@ export const useValidation = <T extends string>(
// Validate a single row
const validateRow = useCallback(async (
row: Data<T>,
row: RowData<T>,
rowIndex: number,
allRows: Data<T>[]
allRows: RowData<T>[]
): Promise<Meta> => {
// Run field-level validations
const fieldErrors: Record<string, ValidationError[]> = {}
fields.forEach(field => {
const value = row[field.key]
const errors = validateField(value, field)
const value = row[String(field.key) as keyof typeof row]
const errors = validateField(value, field as Field<T>)
if (errors.length > 0) {
fieldErrors[field.key] = errors
fieldErrors[String(field.key)] = errors
}
})
// Special validation for supplier and company fields
if (fields.some(field => field.key === 'supplier' as any) && !row.supplier) {
if (fields.some(field => String(field.key) === 'supplier') && !row.supplier) {
fieldErrors['supplier'] = [{
message: 'Supplier is required',
level: 'error'
}]
}
if (fields.some(field => field.key === 'company' as any) && !row.company) {
if (fields.some(field => String(field.key) === 'company') && !row.company) {
fieldErrors['company'] = [{
message: 'Company is required',
level: 'error'
@@ -94,7 +99,10 @@ export const useValidation = <T extends string>(
}
// Run row hook if provided
let rowHookResult: Meta = { __errors: {} }
let rowHookResult: Meta = {
__index: row.__index || String(rowIndex),
__errors: {}
}
if (rowHook) {
try {
rowHookResult = await rowHook(row, rowIndex, allRows)
@@ -104,52 +112,82 @@ export const useValidation = <T extends string>(
}
// Merge field errors and row hook errors
const mergedErrors: Record<string, ValidationError[]> = { ...fieldErrors }
const mergedErrors: Record<string, InfoWithSource> = {}
// Convert field errors to InfoWithSource
Object.entries(fieldErrors).forEach(([key, errors]) => {
if (errors.length > 0) {
mergedErrors[key] = {
message: errors[0].message,
level: errors[0].level,
source: ErrorSources.Row
}
}
})
// Merge row hook errors
if (rowHookResult.__errors) {
Object.entries(rowHookResult.__errors).forEach(([key, errors]) => {
const errorArray = Array.isArray(errors) ? errors : [errors]
mergedErrors[key] = [
...(mergedErrors[key] || []),
...errorArray
]
Object.entries(rowHookResult.__errors).forEach(([key, error]) => {
if (error) {
mergedErrors[key] = error
}
})
}
return {
__index: row.__index || String(rowIndex),
__errors: mergedErrors
}
}, [fields, validateField, rowHook])
// Validate all data at the table level
const validateTable = useCallback(async (data: RowData<T>[]): Promise<Meta[]> => {
if (!tableHook) return data.map(() => ({ __errors: {} }))
if (!tableHook) {
return data.map((row, index) => ({
__index: row.__index || String(index),
__errors: {}
}))
}
try {
const tableResults = await tableHook(data)
// Process table validation results
return tableResults.map(result => {
return tableResults.map((result, index) => {
// Ensure errors are properly formatted
const formattedErrors: Record<string, ValidationError[]> = {}
const formattedErrors: Record<string, InfoWithSource> = {}
if (result.__errors) {
Object.entries(result.__errors).forEach(([key, errors]) => {
formattedErrors[key] = Array.isArray(errors) ? errors : [errors]
Object.entries(result.__errors).forEach(([key, error]) => {
if (error) {
formattedErrors[key] = {
...error,
source: ErrorSources.Table
}
}
})
}
return { __errors: formattedErrors }
return {
__index: result.__index || data[index].__index || String(index),
__errors: formattedErrors
}
})
} catch (error) {
console.error('Error in table hook:', error)
return data.map(() => ({ __errors: {} }))
return data.map((row, index) => ({
__index: row.__index || String(index),
__errors: {}
}))
}
}, [tableHook])
// Validate unique fields across the table
const validateUnique = useCallback((data: RowData<T>[]) => {
const uniqueErrors: Meta[] = data.map(() => ({ __errors: {} }))
const uniqueErrors: Meta[] = data.map((row, index) => ({
__index: row.__index || String(index),
__errors: {}
}))
// Find fields with unique validation
const uniqueFields = fields.filter(field =>
@@ -173,7 +211,7 @@ export const useValidation = <T extends string>(
// Build value map
data.forEach((row, rowIndex) => {
const value = String(row[key] || '')
const value = String(row[String(key) as keyof typeof row] || '')
// Skip empty values if allowed
if (allowEmpty && (value === '' || value === undefined || value === null)) {
@@ -188,20 +226,17 @@ export const useValidation = <T extends string>(
})
// Add errors for duplicate values
valueMap.forEach((rowIndexes, value) => {
valueMap.forEach((rowIndexes) => {
if (rowIndexes.length > 1) {
// Add error to all duplicate rows
rowIndexes.forEach(rowIndex => {
const rowErrors = uniqueErrors[rowIndex].__errors || {}
rowErrors[String(key)] = [
...(rowErrors[String(key)] || []),
{
message: errorMessage,
level,
source: ErrorSources.Table
}
]
rowErrors[String(key)] = {
message: errorMessage,
level,
source: ErrorSources.Table
}
uniqueErrors[rowIndex].__errors = rowErrors
})
@@ -248,10 +283,10 @@ export const useValidation = <T extends string>(
}, [validateRow, validateUnique, validateTable])
return {
validateData,
validateField,
validateRow,
validateTable,
validateUnique,
validateData
validateUnique
}
}

View File

@@ -86,7 +86,74 @@ export const useValidationState = <T extends string>({
const { fields, rowHook, tableHook } = useRsi<T>();
// 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
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
@@ -121,9 +188,184 @@ export const useValidationState = <T extends string>({
const processedUpcMapRef = useRef(new Map<string, string>());
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
const fetchProductByUpc = useCallback(async (supplier: string, upc: string): Promise<ValidationResult> => {
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
const response = await fetch(`${getApiUrl()}/import/check-upc-and-generate-sku?supplierId=${encodeURIComponent(supplier)}&upc=${encodeURIComponent(upc)}`, {
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 {
error: false,
@@ -206,15 +453,10 @@ export const useValidationState = <T extends string>({
if (cachedItemNumber) {
// Update data directly with the cached item number
setData(prevData => {
const newData = [...prevData];
if (newData[rowIndex]) {
newData[rowIndex] = {
...newData[rowIndex],
item_number: cachedItemNumber
};
}
return newData;
setUpcValidationResults(prev => {
const newResults = new Map(prev);
newResults.set(rowIndex, { itemNumber: cachedItemNumber });
return newResults;
});
return { success: true, itemNumber: cachedItemNumber };
@@ -224,65 +466,49 @@ export const useValidationState = <T extends string>({
}
// 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) {
// UPC already exists - show validation error
const errorData = await response.json();
setData(prevData => {
const newData = [...prevData];
const rowToUpdate = newData[rowIndex];
if (rowToUpdate) {
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;
if (result.error) {
// Handle error case
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;
});
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 };
@@ -290,7 +516,7 @@ export const useValidationState = <T extends string>({
console.error(`Error validating UPC for row ${rowIndex}:`, error);
return { success: false };
}
}, [setData]);
}, [fetchProductByUpc]);
// Track which cells are currently being validated - allows targeted re-rendering
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 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
const validations = field.validations as Array<{ rule: string; value?: string; flags?: string; errorMessage?: string; level?: string; }>
// Process value for price fields before validation
let processedValue = value;
const isPrice = typeof field.fieldType === 'object' &&
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
field.fieldType.price === true;
for (const validation of validations) {
if (validation.rule === 'required') {
// Check if the field is empty - handle different value types
const isEmpty =
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0);
if (isPrice && typeof value === 'string') {
processedValue = value.replace(/[$,]/g, '');
}
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({
message: validation.errorMessage || 'This field is required',
level: 'required',
source: 'required' // Mark as required error specifically
level: 'required', // Use 'required' level to distinguish from other errors
source: 'required'
});
}
}
else if (validation.rule === 'regex') {
if (value !== undefined && value !== null && value !== '') {
try {
const regex = new RegExp(validation.value || '', validation.flags);
if (!regex.test(String(value))) {
errors.push({
message: validation.errorMessage || 'Invalid format',
level: validation.level || 'error',
source: 'validation' // Mark as validation error
});
}
} catch (error) {
console.error('Invalid regex in validation:', error);
}
// Skip other validations if value is empty and not required
if (processedValue === undefined || processedValue === null || processedValue === '') return;
// Regex validation
if (rule === 'regex' && validation.value) {
const regex = new RegExp(validation.value);
if (!regex.test(String(processedValue))) {
errors.push({
message: validation.errorMessage || 'Invalid format',
level: validation.level || 'error',
source: 'validation'
});
}
}
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;
}, []);
// Now, let's update the updateRow function to trigger validation after updating data
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 => {
const newData = [...prevData]
const row = { ...newData[rowIndex] }
row[key] = value
const newData = [...prevData];
// 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
setRowValidationStatus(prev => {
const updated = new Map(prev)
updated.set(rowIndex, 'pending')
return updated
})
const updated = new Map(prev);
updated.set(rowIndex, 'pending');
return updated;
});
newData[rowIndex] = row as RowData<T>
return newData
})
// Update the row in the data array
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(() => {
const field = fields.find(f => f.key === key);
if (field) {
@@ -460,23 +751,31 @@ export const useValidationState = <T extends string>({
// Validate just this field
const fieldErrors = validateField(value, field as unknown as Field<T>);
// Update the errors for this field
const updatedRowErrors = {
...rowErrorsMap,
[key]: fieldErrors
};
// Only update if errors have changed
const currentFieldErrors = rowErrorsMap[key] || [];
const errorsChanged =
fieldErrors.length !== currentFieldErrors.length ||
JSON.stringify(fieldErrors) !== JSON.stringify(currentFieldErrors);
// Update the validation errors
currentRowErrors.set(rowIndex, updatedRowErrors);
setValidationErrors(currentRowErrors);
if (errorsChanged) {
// Update the errors for this field
const updatedRowErrors = {
...rowErrorsMap,
[key]: fieldErrors
};
// 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;
});
// 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;
});
}
// If this is a UPC or supplier field and both have values, validate UPC
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) {
const duplicates = data.filter((r, idx) =>
idx !== rowIndex &&
r.item_number === value
);
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;
});
}
// Cancel any pending validation
if (itemNumberValidationTimerRef.current !== null) {
cancelAnimationFrame(itemNumberValidationTimerRef.current);
}
// Schedule validation for next frame
itemNumberValidationTimerRef.current = requestAnimationFrame(() => {
validateItemNumberUniqueness();
itemNumberValidationTimerRef.current = null;
});
}
}
}, 0);
}, [data, fields, validateField, validationErrors]);
}, 100); // Small delay to batch updates
}, [data, fields, validateField, validationErrors, validateUpc, validateItemNumberUniqueness]);
// Validate a single row - optimized version
const validateRow = useCallback(async (rowIndex: number) => {
@@ -632,25 +896,38 @@ export const useValidationState = <T extends string>({
// Load templates
const loadTemplates = useCallback(async () => {
try {
console.log('Fetching templates...');
console.log('Fetching templates from:', `${getApiUrl()}/templates`);
// Fetch templates from the API
const response = await fetch(`${getApiUrl()}/templates`)
console.log('Templates response status:', response.status);
console.log('Templates response:', {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries())
});
if (!response.ok) throw new Error('Failed to fetch templates')
const templateData = await response.json()
console.log('Templates fetched successfully:', templateData);
console.log('Templates response data:', templateData);
// Validate template data
const validTemplates = templateData.filter((t: any) =>
t && typeof t === 'object' && t.id && t.company && t.product_type
);
console.log('Valid templates:', {
total: templateData.length,
valid: validTemplates.length,
templates: validTemplates
});
if (validTemplates.length !== templateData.length) {
console.warn('Some templates were filtered out due to invalid data', {
original: templateData.length,
valid: validTemplates.length
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
const saveTemplate = useCallback(async (name: string, type: string) => {
try {
@@ -971,59 +1253,106 @@ export const useValidationState = <T extends string>({
console.log(`Validating ${data.length} rows`);
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
const row = data[rowIndex];
const fieldErrors: Record<string, ErrorType[]> = {};
let hasErrors = false;
// Process in batches to avoid blocking the UI
const BATCH_SIZE = 50;
let currentBatch = 0;
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;
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;
// 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 => {
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;
}
if (!row.company) {
fieldErrors['company'] = [{
message: 'Company is required',
level: 'error',
source: 'required'
}];
hasErrors = true;
}
});
// Special validation for supplier and company
if (!row.supplier) {
fieldErrors['supplier'] = [{
message: 'Supplier is required',
level: 'error',
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 {
initialStatus.set(rowIndex, 'validated');
}
newData[rowIndex] = {
...newData[rowIndex],
__errors: Object.keys(fieldErrors).length > 0 ? fieldErrors : undefined
};
}
if (hasErrors) {
initialErrors.set(rowIndex, fieldErrors);
initialStatus.set(rowIndex, 'error');
currentBatch++;
// If there are more batches to process, schedule the next one
if (endIdx < data.length) {
setTimeout(processBatch, 0);
} 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],
__errors: Object.keys(fieldErrors).length > 0 ? fieldErrors : undefined
};
}
// Batch update all state at once
setData(newData);
setRowValidationStatus(initialStatus);
setValidationErrors(initialErrors);
console.log('Basic field validation complete');
// Start processing batches
processBatch();
};
// Function to perform UPC validations asynchronously
@@ -1098,7 +1427,7 @@ export const useValidationState = <T extends string>({
}));
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
runBasicValidation();
// Schedule UPC validations asynchronously to avoid blocking the UI
setTimeout(() => {
runUPCValidation();
}, 0);
initialValidationDoneRef.current = true;
}, [data, fields, validateField, fetchProductByUpc]);
@@ -1120,7 +1445,9 @@ export const useValidationState = <T extends string>({
if (!fieldOptionsData) return fields;
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;
}
@@ -1144,7 +1471,14 @@ export const useValidationState = <T extends string>({
break;
case 'tax_cat':
options = [...(fieldOptionsData.taxCategories || [])];
break;
// Ensure tax_cat is always a select, not multi-select
return {
...field,
fieldType: {
type: 'select',
options
}
};
case 'ship_restrictions':
options = [...(fieldOptionsData.shippingRestrictions || [])];
break;

View File

@@ -1,4 +1,3 @@
import React from 'react'
import ValidationContainer from './components/ValidationContainer'
import { Props } from './hooks/useValidationState'

View File

@@ -1,4 +1,4 @@
import { InfoWithSource, ErrorLevel } from "../../types"
import { InfoWithSource, ErrorLevel } from "../../../types"
// Define our own Error type that's compatible with the original
export interface ErrorType {

View File

@@ -3,45 +3,70 @@ import type { Meta, Error, Errors } from "../types"
import { v4 } from "uuid"
import { ErrorSources } from "../../../types"
type DataWithMeta<T extends string> = Data<T> & Meta & {
__index?: string;
__errors?: Error | null;
}
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)[]> => {
): Promise<DataWithMeta<T>[]> => {
const errors: Errors = {}
const addError = (source: ErrorSources, rowIndex: number, fieldKey: T, error: Info) => {
const addError = (source: ErrorSources, rowIndex: number, fieldKey: string, error: Info) => {
errors[rowIndex] = {
...errors[rowIndex],
[fieldKey]: { ...error, source },
}
}
let processedData = [...data] as DataWithMeta<T>[]
if (tableHook) {
data = await tableHook(data, (...props) => addError(ErrorSources.Table, ...props))
const tableResults = await tableHook(processedData)
processedData = tableResults.map((result, index) => ({
...processedData[index],
...result
}))
}
if (rowHook) {
if (changedRowIndexes) {
for (const index of changedRowIndexes) {
data[index] = await rowHook(data[index], (...props) => addError(ErrorSources.Row, index, ...props), data)
const rowResult = await rowHook(processedData[index], index, processedData)
processedData[index] = {
...processedData[index],
...rowResult
}
}
} else {
data = await Promise.all(
data.map(async (value, index) =>
rowHook(value, (...props) => addError(ErrorSources.Row, index, ...props), data),
),
const rowResults = await Promise.all(
processedData.map(async (value, index) => {
const result = await rowHook(value, index, processedData)
return {
...value,
...result
}
})
)
processedData = rowResults
}
}
fields.forEach((field) => {
const fieldKey = field.key as string
field.validations?.forEach((validation) => {
switch (validation.rule) {
case "unique": {
const values = data.map((entry) => entry[field.key as T])
const values = processedData.map((entry) => {
const value = entry[fieldKey as keyof typeof entry]
return value
})
const taken = new Set() // Set of items used at least once
const duplicates = new Set() // Set of items used multiple times
@@ -61,7 +86,7 @@ export const addErrorsAndRunHooks = async <T extends string>(
values.forEach((value, index) => {
if (duplicates.has(value)) {
addError(ErrorSources.Table, index, field.key as T, {
addError(ErrorSources.Table, index, fieldKey, {
level: validation.level || "error",
message: validation.errorMessage || "Field must be unique",
})
@@ -70,71 +95,71 @@ export const addErrorsAndRunHooks = async <T extends string>(
break
}
case "required": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => data[index]) : data
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
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, {
const value = entry[fieldKey as keyof typeof entry]
if (value === null || value === undefined || value === "") {
addError(ErrorSources.Row, realIndex, fieldKey, {
level: validation.level || "error",
message: validation.errorMessage || "Field is required",
})
}
})
break
}
case "regex": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => data[index]) : data
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
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, {
const value = entry[fieldKey as keyof typeof entry]
const stringValue = value?.toString() ?? ""
if (!stringValue.match(regex)) {
addError(ErrorSources.Row, realIndex, fieldKey, {
level: validation.level || "error",
message:
validation.errorMessage || `Field did not match the regex /${validation.value}/${validation.flags} `,
})
}
})
break
}
}
})
})
return data.map((value, index) => {
return processedData.map((value, index) => {
// This is required only for table. Mutates to prevent needless rerenders
if (!("__index" in value)) {
value.__index = v4()
const result: DataWithMeta<T> = { ...value }
if (!result.__index) {
result.__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] }
return { ...result, __errors: errors[index] }
}
if (!errors[index] && value?.__errors) {
return { ...newValue, __errors: null }
if (!errors[index] && result.__errors) {
return { ...result, __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)
result.__errors && Object.values(result.__errors).some((error) => error.source === ErrorSources.Row)
if (!hasRowErrors) {
if (errors[index]) {
return { ...newValue, __errors: errors[index] }
return { ...result, __errors: errors[index] }
}
return newValue
return result
}
const errorsWithoutTableError = Object.entries(value.__errors!).reduce((acc, [key, value]) => {
const errorsWithoutTableError = Object.entries(result.__errors!).reduce((acc, [key, value]) => {
if (value.source === ErrorSources.Row) {
acc[key] = value
}
@@ -143,9 +168,9 @@ export const addErrorsAndRunHooks = async <T extends string>(
const newErrors = { ...errorsWithoutTableError, ...errors[index] }
return { ...newValue, __errors: newErrors }
return { ...result, __errors: newErrors }
}
return newValue
return result
})
}

View File

@@ -1,5 +1,4 @@
import { InfoWithSource } from '../../../types'
import { ErrorType, ErrorTypes } from '../types/index'
import { ErrorType } from '../types/index'
/**
* Converts an InfoWithSource or similar error object to our Error type

View File

@@ -1,5 +1,5 @@
import { Field, Data, ErrorSources } from '../../../types'
import { ErrorType, ErrorTypes } from '../types/index'
import { ErrorType } from '../types/index'
/**
* Formats a price value to a consistent format
@@ -29,7 +29,10 @@ export const formatPrice = (value: string | number): string => {
* @returns True if the field is a price field
*/
export const isPriceField = (field: Field<any>): boolean => {
return !!field.fieldType.price
const fieldType = field.fieldType;
return (fieldType.type === 'input' || fieldType.type === 'multi-input') &&
'price' in fieldType &&
!!fieldType.price;
}
/**

View File

@@ -1,4 +1,4 @@
import type { Meta } from "./steps/ValidationStep/types"
import type { Meta } from "./steps/ValidationStepNew/types"
import type { DeepReadonly } from "ts-essentials"
import type { TranslationsRSIProps } from "./translationsRSIProps"
import type { Columns } from "./steps/MatchColumnsStep/MatchColumnsStep"
@@ -53,9 +53,9 @@ export type RsiProps<T extends string> = {
export type RawData = (string | undefined)[]
export type Data<T extends string> = {
[key in T]?: string | boolean | undefined
} & {
export type DataValue = string | boolean | string[] | undefined
export type Data<T extends string> = Partial<Record<T, DataValue>> & {
supplier?: string
company?: string
line?: string

100
inventory/src/types/react-data-grid.d.ts vendored Normal file
View File

@@ -0,0 +1,100 @@
declare module 'react-data-grid' {
export interface FormatterProps<TRow = any, TSummaryRow = unknown> {
row: TRow;
column: Column<TRow, TSummaryRow>;
isCellSelected: boolean;
onRowChange: (row: TRow) => void;
rowIdx: number;
}
export interface Column<TRow, TSummaryRow = unknown> {
/** The name of the column. By default it will be displayed in the header cell */
name: string | JSX.Element;
/** A unique key to distinguish each column */
key: string;
/** Column width. If not specified, it will be determined automatically based on grid width and specified widths of other columns */
width?: number | string;
/** Minimum column width in px. */
minWidth?: number;
/** Maximum column width in px. */
maxWidth?: number;
cellClass?: string | ((row: TRow) => string);
headerCellClass?: string;
summaryCellClass?: string | ((row: TSummaryRow) => string);
/** Formatter to be used to render the cell content */
formatter?: React.ComponentType<FormatterProps<TRow, TSummaryRow>>;
/** Formatter to be used to render the summary cell content */
summaryFormatter?: React.ComponentType<FormatterProps<TRow, TSummaryRow>>;
/** Formatter to be used to render the group cell content */
groupFormatter?: React.ComponentType<FormatterProps<TRow, TSummaryRow>>;
/** Enables cell editing. If set and no editor property specified, then a textinput will be used as the cell editor */
editable?: boolean | ((row: TRow) => boolean);
/** Enable filtering for this column */
filterable?: boolean;
/** Enable sorting for this column */
sortable?: boolean;
/** Sets the column sort order to be descending instead of ascending the first time the column is sorted */
sortDescendingFirst?: boolean;
/** Editor to be rendered when cell of column is being edited. If set, then the column is automatically set to be editable */
editor?: React.ComponentType<any>;
/** Header renderer for each header cell */
headerRenderer?: React.ComponentType<any>;
/** Component to be used to filter the data of the column */
filterRenderer?: React.ComponentType<any>;
/** Whether the column can be resized */
resizable?: boolean;
/** Whether the column is frozen (fixed position) */
frozen?: boolean;
}
export interface SelectRowEvent<TRow> {
row: TRow;
checked: boolean;
isShiftClick: boolean;
}
export interface RowSelectionHook {
(): [boolean, (selectRowEvent: SelectRowEvent<any>) => void];
}
export const useRowSelection: RowSelectionHook;
export interface DataGridProps<TRow, TSummaryRow = unknown> {
/** Grid columns. */
columns: readonly Column<TRow, TSummaryRow>[];
/** An array of objects representing each row */
rows: readonly TRow[];
/** Rows to be pinned at the top of the rows view for summary */
summaryRows?: readonly TSummaryRow[];
/** Used to uniquely identify each row */
rowKeyGetter?: (row: TRow) => React.Key;
onRowsChange?: (rows: TRow[], data: any) => void;
/** Function called whenever row data is updated */
onRowChange?: (row: TRow, data: any) => void;
/** Called when the grid is scrolled */
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
/** Number of rows to render at a time */
rowHeight?: number;
/** Header row height */
headerRowHeight?: number;
/** Summary row height */
summaryRowHeight?: number;
/** Set of selected row keys */
selectedRows?: ReadonlySet<React.Key>;
/** Function called whenever row selection is changed */
onSelectedRowsChange?: (selectedRows: Set<React.Key>) => void;
/** Used to specify the height of the grid */
height?: number;
/** Used to specify the width of the grid */
width?: number;
/** Toggles whether cells should be autofocused when navigated to via keyboard */
enableCellAutoFocus?: boolean;
className?: string;
style?: React.CSSProperties;
/** Direction of the grid */
direction?: 'ltr' | 'rtl';
}
const DataGrid: React.FC<DataGridProps<any, any>>;
export default DataGrid;
}