Clean up/optimize validation step
This commit is contained in:
172
.claude/CLAUDE.md
Normal file
172
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a full-stack inventory management system with a React + TypeScript frontend and Node.js/Express backend using PostgreSQL. The system includes product management, analytics, forecasting, purchase orders, and a comprehensive dashboard for business metrics.
|
||||
|
||||
**Monorepo Structure:**
|
||||
- `inventory/` - Vite-based React frontend with TypeScript
|
||||
- `inventory-server/` - Express backend API server
|
||||
- Root `package.json` contains shared dependencies
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Frontend (inventory/)
|
||||
```bash
|
||||
cd inventory
|
||||
npm run dev # Start dev server on port 5175
|
||||
npm run build # Build for production (outputs to build/ then copies to ../inventory-server/frontend/build)
|
||||
npm run lint # Run ESLint
|
||||
npm run preview # Preview production build
|
||||
```
|
||||
|
||||
### Backend (inventory-server/)
|
||||
```bash
|
||||
cd inventory-server
|
||||
npm run dev # Start with nodemon (auto-reload)
|
||||
npm start # Start server (production)
|
||||
npm run prod # Start with PM2 for production
|
||||
npm run prod:stop # Stop PM2 instance
|
||||
npm run prod:restart # Restart PM2 instance
|
||||
npm run prod:logs # View PM2 logs
|
||||
npm run setup # Create required directories (logs, uploads)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
**Router Structure:** React Router with lazy loading for code splitting:
|
||||
- Main chunks: Core inventory, Dashboard, Product Import, Chat Archive
|
||||
- Authentication flow uses `RequireAuth` and `Protected` components with permission-based access
|
||||
- All routes except `/login` and `/small` require authentication
|
||||
|
||||
**Key Directories:**
|
||||
- `src/pages/` - Top-level page components (Overview, Products, Analytics, Dashboard, etc.)
|
||||
- `src/components/` - Organized by feature (dashboard/, products/, analytics/, etc.)
|
||||
- `src/components/ui/` - shadcn/ui components
|
||||
- `src/types/` - TypeScript type definitions
|
||||
- `src/contexts/` - React contexts (AuthContext, DashboardScrollContext)
|
||||
- `src/hooks/` - Custom React hooks (use-toast, useDebounce, use-mobile)
|
||||
- `src/utils/` - Utility functions (emojiUtils, productUtils, naturalLanguagePeriod)
|
||||
- `src/services/` - API service layer
|
||||
- `src/config/` - Configuration files
|
||||
|
||||
**State Management:**
|
||||
- React Context for auth and global state
|
||||
- @tanstack/react-query for server state management
|
||||
- zustand for client state management
|
||||
- Local storage for auth tokens, session storage for login state
|
||||
|
||||
**Key Dependencies:**
|
||||
- UI: Radix UI primitives, shadcn/ui, Tailwind CSS, Framer Motion
|
||||
- Data: @tanstack/react-table, react-data-grid, @tanstack/react-virtual
|
||||
- Forms: react-hook-form, zod
|
||||
- Charts: recharts, chart.js, react-chartjs-2
|
||||
- File handling: xlsx for Excel export, react-dropzone for uploads
|
||||
- Other: axios for HTTP, date-fns/luxon for dates
|
||||
|
||||
**Path Alias:** `@/` maps to `./src/`
|
||||
|
||||
### Backend Architecture
|
||||
|
||||
**Entry Point:** `inventory-server/src/server.js`
|
||||
|
||||
**Key Directories:**
|
||||
- `src/routes/` - Express route handlers (products, dashboard, analytics, import, etc.)
|
||||
- `src/middleware/` - Express middleware (CORS, auth, etc.)
|
||||
- `src/utils/` - Utility functions (database connection, API helpers)
|
||||
- `src/types/` - Type definitions (e.g., status-codes)
|
||||
|
||||
**Database:**
|
||||
- PostgreSQL with connection pooling (pg library)
|
||||
- Pool initialized in `utils/db.js` via `initPool()`
|
||||
- Pool attached to `app.locals.pool` for route access
|
||||
- Environment variables loaded from `/var/www/html/inventory/.env` (production path)
|
||||
|
||||
**API Routes:** All prefixed with `/api/`
|
||||
- `/api/products` - Product CRUD operations
|
||||
- `/api/dashboard` - Dashboard metrics and data
|
||||
- `/api/analytics` - Analytics and reporting
|
||||
- `/api/orders` - Order management
|
||||
- `/api/purchase-orders` - Purchase order management
|
||||
- `/api/csv` - CSV import/export (data management)
|
||||
- `/api/import` - Product import workflows
|
||||
- `/api/config` - Configuration management
|
||||
- `/api/metrics` - System metrics
|
||||
- `/api/ai-validation` - AI-powered validation
|
||||
- `/api/ai-prompts` - AI prompt management
|
||||
- `/api/templates` - Template management
|
||||
- `/api/reusable-images` - Image management
|
||||
- `/api/categoriesAggregate`, `/api/vendorsAggregate`, `/api/brandsAggregate` - Aggregate data endpoints
|
||||
|
||||
**Authentication:**
|
||||
- External auth service at `/auth-inv` endpoint
|
||||
- Token-based authentication (Bearer tokens)
|
||||
- Frontend stores tokens in localStorage
|
||||
- Protected routes verify tokens via auth service `/me` endpoint
|
||||
|
||||
**File Uploads:**
|
||||
- Multer middleware for file handling
|
||||
- Uploads directory: `inventory-server/uploads/`
|
||||
|
||||
### Development Proxy Setup
|
||||
|
||||
The Vite dev server (port 5175) proxies API requests to `https://inventory.kent.pw`:
|
||||
- `/api/*` → production API
|
||||
- `/auth-inv/*` → authentication service
|
||||
- `/chat-api/*` → chat service
|
||||
- `/uploads/*` → uploaded files
|
||||
- Various third-party services (Aircall, Klaviyo, Meta, Gorgias, Typeform, ACOT, Clarity)
|
||||
|
||||
### Build Process
|
||||
|
||||
When building the frontend:
|
||||
1. TypeScript compilation (`tsc -b`)
|
||||
2. Vite build (outputs to `inventory/build/`)
|
||||
3. Custom Vite plugin copies build to `inventory-server/frontend/build/`
|
||||
4. Manual chunks for vendor splitting (react-vendor, ui-vendor, query-vendor)
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests for individual components or features:
|
||||
```bash
|
||||
# No test suite currently configured
|
||||
# Tests would typically use Jest or Vitest with React Testing Library
|
||||
```
|
||||
|
||||
## Common Development Workflows
|
||||
|
||||
### Adding a New Page
|
||||
1. Create page component in `inventory/src/pages/YourPage.tsx`
|
||||
2. Add lazy import in `inventory/src/App.tsx`
|
||||
3. Add route with `<Protected>` wrapper and permission check
|
||||
4. Add corresponding backend route in `inventory-server/src/routes/`
|
||||
5. Update permission system if needed
|
||||
|
||||
### Adding a New API Endpoint
|
||||
1. Create or update route file in `inventory-server/src/routes/`
|
||||
2. Use `executeQuery()` helper for database queries
|
||||
3. Register router in `inventory-server/src/server.js`
|
||||
4. Frontend can access at `/api/{route-name}`
|
||||
|
||||
### Working with Database
|
||||
- Use parameterized queries: `executeQuery(sql, [param1, param2])`
|
||||
- Pool is accessed via `db.getPool()` or `app.locals.pool`
|
||||
- Connection helper: `db.getConnection()` returns a client for transactions
|
||||
|
||||
### Permissions System
|
||||
- User permissions stored in `user.permissions` array (permission codes)
|
||||
- Check permissions in `<Protected page="permission_code">` component
|
||||
- Admin users (`is_admin: true`) have access to all pages
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Environment variables must be configured in `/var/www/html/inventory/.env` for production
|
||||
- The frontend expects the backend at `/api` (proxied in dev, served together in production)
|
||||
- PM2 is used for production process management
|
||||
- Database uses PostgreSQL with SSL support (configurable via `DB_SSL` env var)
|
||||
- File uploads stored in `inventory-server/uploads/` directory
|
||||
- Build artifacts in `inventory/build/` are copied to `inventory-server/frontend/build/`
|
||||
340
inventory/src/components/product-import/config.ts
Normal file
340
inventory/src/components/product-import/config.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import type { ErrorLevel } from "@/components/product-import";
|
||||
|
||||
/**
|
||||
* Base field configuration for product import
|
||||
*
|
||||
* These fields define the structure and validation rules for the import process.
|
||||
* Options for select/multi-select fields are populated dynamically from the API.
|
||||
*/
|
||||
export const BASE_IMPORT_FIELDS = [
|
||||
{
|
||||
label: "Supplier",
|
||||
key: "supplier",
|
||||
description: "Primary supplier/manufacturer of the product",
|
||||
fieldType: {
|
||||
type: "select" as const,
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 220,
|
||||
validations: [{ rule: "required" as const, errorMessage: "Required", level: "error" as ErrorLevel }],
|
||||
},
|
||||
{
|
||||
label: "Company",
|
||||
key: "company",
|
||||
description: "Company/Brand name",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 220,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "Line",
|
||||
key: "line",
|
||||
description: "Product line",
|
||||
alternateMatches: ["collection"],
|
||||
fieldType: {
|
||||
type: "select" as const,
|
||||
options: [], // Will be populated dynamically based on company selection
|
||||
},
|
||||
width: 220,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "Sub Line",
|
||||
key: "subline",
|
||||
description: "Product sub-line",
|
||||
fieldType: {
|
||||
type: "select" as const,
|
||||
options: [], // Will be populated dynamically based on line selection
|
||||
},
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
label: "UPC",
|
||||
key: "upc",
|
||||
description: "Universal Product Code/Barcode",
|
||||
alternateMatches: ["barcode", "bar code", "jan", "ean", "upc code"],
|
||||
fieldType: { type: "input" },
|
||||
width: 145,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Item Number",
|
||||
key: "item_number",
|
||||
description: "Internal item reference number",
|
||||
fieldType: { type: "input" },
|
||||
width: 130,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Supplier #",
|
||||
key: "supplier_no",
|
||||
description: "Supplier's product identifier",
|
||||
alternateMatches: ["sku", "item#", "mfg item #", "item", "supplier #"],
|
||||
fieldType: { type: "input" },
|
||||
width: 130,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Notions #",
|
||||
key: "notions_no",
|
||||
description: "Internal notions number",
|
||||
alternateMatches: ["notions #","nmc"],
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Name",
|
||||
key: "name",
|
||||
description: "Product name/title",
|
||||
alternateMatches: ["sku description","product name"],
|
||||
fieldType: { type: "input" },
|
||||
width: 500,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "MSRP",
|
||||
key: "msrp",
|
||||
description: "Manufacturer's Suggested Retail Price",
|
||||
alternateMatches: ["retail", "retail price", "sugg retail", "price", "sugg. retail","default price"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
price: true
|
||||
},
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Min Qty",
|
||||
key: "qty_per_unit",
|
||||
description: "Quantity of items per individual unit",
|
||||
alternateMatches: ["inner pack", "inner", "min qty", "unit qty", "min. order qty", "supplier qty/unit"],
|
||||
fieldType: { type: "input" },
|
||||
width: 80,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Cost Each",
|
||||
key: "cost_each",
|
||||
description: "Wholesale cost per unit",
|
||||
alternateMatches: ["wholesale", "wholesale price", "supplier cost each", "cost each"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
price: true
|
||||
},
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Case Pack",
|
||||
key: "case_qty",
|
||||
description: "Number of units per case",
|
||||
alternateMatches: ["mc qty","case qty","case pack","box ct"],
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Tax Category",
|
||||
key: "tax_cat",
|
||||
description: "Product tax category",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 200,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "Artist",
|
||||
key: "artist",
|
||||
description: "Artist/Designer name",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
label: "ETA Date",
|
||||
key: "eta",
|
||||
description: "Estimated arrival date",
|
||||
alternateMatches: ["shipping month"],
|
||||
fieldType: { type: "input" },
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
label: "Weight",
|
||||
key: "weight",
|
||||
description: "Product weight (in lbs)",
|
||||
alternateMatches: ["weight (lbs.)"],
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Length",
|
||||
key: "length",
|
||||
description: "Product length (in inches)",
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Width",
|
||||
key: "width",
|
||||
description: "Product width (in inches)",
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Height",
|
||||
key: "height",
|
||||
description: "Product height (in inches)",
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Shipping Restrictions",
|
||||
key: "ship_restrictions",
|
||||
description: "Product shipping restrictions",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 190,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "COO",
|
||||
key: "coo",
|
||||
description: "2-letter country code (ISO)",
|
||||
alternateMatches: ["coo", "country of origin"],
|
||||
fieldType: { type: "input" },
|
||||
width: 70,
|
||||
validations: [
|
||||
{ rule: "regex", value: "^[A-Z]{2}$", errorMessage: "Must be 2 letters", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "HTS Code",
|
||||
key: "hts_code",
|
||||
description: "Harmonized Tariff Schedule code",
|
||||
alternateMatches: ["taric","hts"],
|
||||
fieldType: { type: "input" },
|
||||
width: 130,
|
||||
validations: [
|
||||
{ rule: "regex", value: "^[0-9.]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Size Category",
|
||||
key: "size_cat",
|
||||
description: "Product size category",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
label: "Description",
|
||||
key: "description",
|
||||
description: "Detailed product description",
|
||||
alternateMatches: ["details/description"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
multiline: true
|
||||
},
|
||||
width: 500,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "Private Notes",
|
||||
key: "priv_notes",
|
||||
description: "Internal notes about the product",
|
||||
fieldType: {
|
||||
type: "input",
|
||||
multiline: true
|
||||
},
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
label: "Categories",
|
||||
key: "categories",
|
||||
description: "Product categories",
|
||||
fieldType: {
|
||||
type: "multi-select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 350,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "Themes",
|
||||
key: "themes",
|
||||
description: "Product themes/styles",
|
||||
fieldType: {
|
||||
type: "multi-select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
label: "Colors",
|
||||
key: "colors",
|
||||
description: "Product colors",
|
||||
fieldType: {
|
||||
type: "multi-select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 200,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type ImportFieldKey = (typeof BASE_IMPORT_FIELDS)[number]["key"];
|
||||
@@ -1,80 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AiValidationDialogs } from './components/AiValidationDialogs';
|
||||
import { Product } from '../../../../types/products';
|
||||
import {
|
||||
AiValidationProgress,
|
||||
AiValidationDetails,
|
||||
CurrentPrompt as AiValidationCurrentPrompt
|
||||
} from './hooks/useAiValidation';
|
||||
|
||||
const ValidationStepNew: React.FC = () => {
|
||||
const [aiValidationProgress, setAiValidationProgress] = useState<AiValidationProgress>({
|
||||
isOpen: false,
|
||||
status: 'idle',
|
||||
step: 0
|
||||
});
|
||||
|
||||
const [aiValidationDetails, setAiValidationDetails] = useState<AiValidationDetails>({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
changeDetails: [],
|
||||
isOpen: false
|
||||
});
|
||||
|
||||
const [currentPrompt, setCurrentPrompt] = useState<AiValidationCurrentPrompt>({
|
||||
isOpen: false,
|
||||
prompt: '',
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
// Track reversion state (for internal use)
|
||||
const [reversionState, setReversionState] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [fieldData] = useState<Product[]>([]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const revertAiChange = (productIndex: number, fieldKey: string) => {
|
||||
const key = `${productIndex}-${fieldKey}`;
|
||||
setReversionState(prev => ({
|
||||
...prev,
|
||||
[key]: true
|
||||
}));
|
||||
};
|
||||
|
||||
const isChangeReverted = (productIndex: number, fieldKey: string): boolean => {
|
||||
const key = `${productIndex}-${fieldKey}`;
|
||||
return !!reversionState[key];
|
||||
};
|
||||
|
||||
const getFieldDisplayValueWithHighlight = (
|
||||
_fieldKey: string,
|
||||
originalValue: any,
|
||||
correctedValue: any
|
||||
) => {
|
||||
return {
|
||||
originalHtml: String(originalValue),
|
||||
correctedHtml: String(correctedValue)
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AiValidationDialogs
|
||||
aiValidationProgress={aiValidationProgress}
|
||||
aiValidationDetails={aiValidationDetails}
|
||||
currentPrompt={currentPrompt}
|
||||
setAiValidationProgress={setAiValidationProgress}
|
||||
setAiValidationDetails={setAiValidationDetails}
|
||||
setCurrentPrompt={setCurrentPrompt}
|
||||
revertAiChange={revertAiChange}
|
||||
isChangeReverted={isChangeReverted}
|
||||
getFieldDisplayValueWithHighlight={getFieldDisplayValueWithHighlight}
|
||||
fields={fieldData}
|
||||
debugData={currentPrompt.debugData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidationStepNew;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useValidationState } from '../hooks/useValidationState'
|
||||
import { Props } from '../hooks/validationTypes'
|
||||
import { Props, RowData } from '../hooks/validationTypes'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, X, Plus, Edit3, Sparkles, FileText } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -14,11 +14,12 @@ import { SearchProductTemplateDialog } from '@/components/templates/SearchProduc
|
||||
import { TemplateForm } from '@/components/templates/TemplateForm'
|
||||
import axios from 'axios'
|
||||
import { RowSelectionState } from '@tanstack/react-table'
|
||||
import { useUpcValidation } from '../hooks/useUpcValidation'
|
||||
import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
|
||||
import UpcValidationTableAdapter from './UpcValidationTableAdapter'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Protected } from '@/components/auth/Protected'
|
||||
import { normalizeCountryCode } from '../utils/countryUtils'
|
||||
import { cleanPriceField } from '../utils/priceUtils'
|
||||
/**
|
||||
* ValidationContainer component - the main wrapper for the validation step
|
||||
*
|
||||
@@ -59,13 +60,29 @@ const ValidationContainer = <T extends string>({
|
||||
loadTemplates,
|
||||
setData,
|
||||
fields,
|
||||
upcValidation,
|
||||
isLoadingTemplates,
|
||||
isValidating,
|
||||
isInitializing,
|
||||
validatingCells,
|
||||
setValidatingCells,
|
||||
editingCells,
|
||||
setEditingCells
|
||||
setEditingCells,
|
||||
updateRow,
|
||||
revalidateRows
|
||||
} = validationState
|
||||
|
||||
const dataIndexByRowId = useMemo(() => {
|
||||
const map = new Map<any, number>()
|
||||
data.forEach((row, index) => {
|
||||
const rowId = (row as Record<string, any>).__index
|
||||
if (rowId !== undefined && rowId !== null) {
|
||||
map.set(rowId, index)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [data])
|
||||
|
||||
// Use product lines fetching hook
|
||||
const {
|
||||
rowProductLines,
|
||||
@@ -76,9 +93,6 @@ const ValidationContainer = <T extends string>({
|
||||
fetchSublines
|
||||
} = useProductLinesFetching(data);
|
||||
|
||||
// Use UPC validation hook
|
||||
const upcValidation = useUpcValidation(data, setData);
|
||||
|
||||
// Function to check if a specific row is being validated - memoized
|
||||
const isRowValidatingUpc = upcValidation.isRowValidatingUpc;
|
||||
|
||||
@@ -115,11 +129,16 @@ const ValidationContainer = <T extends string>({
|
||||
// Add new state for template form dialog
|
||||
const [isTemplateFormOpen, setIsTemplateFormOpen] = useState(false)
|
||||
const [templateFormInitialData, setTemplateFormInitialData] = useState<any>(null)
|
||||
|
||||
const pendingInitializationTasks: string[] = []
|
||||
if (isValidating) pendingInitializationTasks.push('validating rows')
|
||||
if (upcValidation.validatingRows.size > 0) pendingInitializationTasks.push('checking UPCs')
|
||||
if (isLoadingTemplates) pendingInitializationTasks.push('loading templates')
|
||||
const [fieldOptions, setFieldOptions] = useState<any>(null)
|
||||
|
||||
// Track fields that need revalidation due to value changes
|
||||
const [fieldsToRevalidate, setFieldsToRevalidate] = useState<Set<number>>(new Set());
|
||||
const [fieldsToRevalidateMap, setFieldsToRevalidateMap] = useState<{[rowIndex: number]: string[]}>({});
|
||||
// Combined state: Map<rowIndex, fieldKeys[]> - if empty array, revalidate all fields
|
||||
const [fieldsToRevalidate, setFieldsToRevalidate] = useState<Map<number, string[]>>(new Map());
|
||||
|
||||
// Function to mark a row for revalidation
|
||||
const markRowForRevalidation = useCallback((rowIndex: number, fieldKey?: string) => {
|
||||
@@ -138,24 +157,17 @@ const ValidationContainer = <T extends string>({
|
||||
})();
|
||||
|
||||
setFieldsToRevalidate(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(originalIndex);
|
||||
return newSet;
|
||||
});
|
||||
const newMap = new Map(prev);
|
||||
const existingFields = newMap.get(originalIndex) || [];
|
||||
|
||||
// Also track which specific field needs to be revalidated
|
||||
if (fieldKey) {
|
||||
setFieldsToRevalidateMap(prev => {
|
||||
const newMap = { ...prev };
|
||||
if (!newMap[originalIndex]) {
|
||||
newMap[originalIndex] = [];
|
||||
}
|
||||
if (!newMap[originalIndex].includes(fieldKey)) {
|
||||
newMap[originalIndex] = [...newMap[originalIndex], fieldKey];
|
||||
if (fieldKey && !existingFields.includes(fieldKey)) {
|
||||
newMap.set(originalIndex, [...existingFields, fieldKey]);
|
||||
} else if (!fieldKey) {
|
||||
newMap.set(originalIndex, existingFields);
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [data, filteredData]);
|
||||
|
||||
// Add a ref to track the last validation time
|
||||
@@ -164,21 +176,19 @@ const ValidationContainer = <T extends string>({
|
||||
useEffect(() => {
|
||||
if (fieldsToRevalidate.size === 0) return;
|
||||
|
||||
// Revalidate the marked rows
|
||||
const rowsToRevalidate = Array.from(fieldsToRevalidate);
|
||||
// Extract rows and fields map
|
||||
const rowsToRevalidate = Array.from(fieldsToRevalidate.keys());
|
||||
const fieldsMap: {[rowIndex: number]: string[]} = {};
|
||||
fieldsToRevalidate.forEach((fields, rowIndex) => {
|
||||
fieldsMap[rowIndex] = fields;
|
||||
});
|
||||
|
||||
// Clear the revalidation set
|
||||
setFieldsToRevalidate(new Set());
|
||||
|
||||
// Get the fields map for revalidation
|
||||
const fieldsMap = { ...fieldsToRevalidateMap };
|
||||
|
||||
// Clear the fields map
|
||||
setFieldsToRevalidateMap({});
|
||||
// Clear the revalidation map
|
||||
setFieldsToRevalidate(new Map());
|
||||
|
||||
// Revalidate each row with specific fields information
|
||||
validationState.revalidateRows(rowsToRevalidate, fieldsMap);
|
||||
}, [fieldsToRevalidate, validationState, fieldsToRevalidateMap]);
|
||||
revalidateRows(rowsToRevalidate, fieldsMap);
|
||||
}, [fieldsToRevalidate, revalidateRows]);
|
||||
|
||||
// Function to fetch field options for template form
|
||||
const fetchFieldOptions = useCallback(async () => {
|
||||
@@ -395,107 +405,40 @@ const ValidationContainer = <T extends string>({
|
||||
[setRowSelection]
|
||||
);
|
||||
|
||||
// Add scroll container ref at the container level
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollPosition = useRef({ left: 0, top: 0 });
|
||||
|
||||
// Track if we're currently validating a UPC
|
||||
|
||||
// Track last UPC update to prevent conflicting changes
|
||||
|
||||
// Add these ref declarations here, at component level
|
||||
|
||||
// Memoize scroll handlers - simplified to avoid performance issues
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement> | Event) => {
|
||||
// Store scroll position directly without conditions
|
||||
const target = event.currentTarget as HTMLDivElement;
|
||||
lastScrollPosition.current = {
|
||||
left: target.scrollLeft,
|
||||
top: target.scrollTop
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add scroll event listener
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
// Convert React event handler to native event handler
|
||||
const nativeHandler = ((evt: Event) => {
|
||||
handleScroll(evt);
|
||||
}) as EventListener;
|
||||
|
||||
container.addEventListener('scroll', nativeHandler, { passive: true });
|
||||
return () => container.removeEventListener('scroll', nativeHandler);
|
||||
}
|
||||
}, [handleScroll]);
|
||||
|
||||
// Use a ref to track if we need to restore scroll position
|
||||
const needScrollRestore = useRef(false);
|
||||
|
||||
// Set flag when data changes
|
||||
useEffect(() => {
|
||||
needScrollRestore.current = true;
|
||||
// Only restore scroll on layout effects to avoid triggering rerenders
|
||||
}, []);
|
||||
|
||||
// Use layout effect for DOM manipulations
|
||||
useLayoutEffect(() => {
|
||||
if (!needScrollRestore.current) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (container && (lastScrollPosition.current.left > 0 || lastScrollPosition.current.top > 0)) {
|
||||
container.scrollLeft = lastScrollPosition.current.left;
|
||||
container.scrollTop = lastScrollPosition.current.top;
|
||||
needScrollRestore.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Ensure manual edits to item numbers persist with minimal changes to validation logic
|
||||
const handleUpdateRow = useCallback(async (rowIndex: number, key: T, value: any) => {
|
||||
// Process value before updating data
|
||||
// Helper: Process field value transformations
|
||||
const processFieldValue = useCallback((key: T, value: any): any => {
|
||||
let processedValue = value;
|
||||
|
||||
// Strip dollar signs from price fields
|
||||
if ((key === 'msrp' || key === 'cost_each') && typeof value === 'string') {
|
||||
processedValue = value.replace(/[$,]/g, '');
|
||||
// Clean price fields
|
||||
if ((key === 'msrp' || key === 'cost_each') && value !== undefined && value !== null) {
|
||||
processedValue = cleanPriceField(value);
|
||||
}
|
||||
|
||||
// Also ensure it's a valid number
|
||||
const numValue = parseFloat(processedValue);
|
||||
if (!isNaN(numValue)) {
|
||||
processedValue = numValue.toFixed(2);
|
||||
// Normalize country code
|
||||
if (key === 'coo' && typeof value === 'string' && value.trim()) {
|
||||
const normalized = normalizeCountryCode(value);
|
||||
if (normalized) {
|
||||
processedValue = normalized;
|
||||
} else {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 2) {
|
||||
processedValue = trimmed.toUpperCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the row in the data
|
||||
const rowData = filteredData[rowIndex];
|
||||
if (!rowData) {
|
||||
console.error(`No row data found for index ${rowIndex}`);
|
||||
return;
|
||||
}
|
||||
return processedValue;
|
||||
}, []);
|
||||
|
||||
// Use __index to find the actual row in the full data array
|
||||
const rowId = rowData.__index;
|
||||
const originalIndex = data.findIndex(item => item.__index === rowId);
|
||||
|
||||
// Detect if this is a direct item_number edit
|
||||
const isItemNumberEdit = key === 'item_number' as T;
|
||||
|
||||
// For item_number edits, use core updateRow to atomically update + validate
|
||||
if (isItemNumberEdit) {
|
||||
const idx = originalIndex >= 0 ? originalIndex : rowIndex;
|
||||
validationState.updateRow(idx, key as unknown as any, processedValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other fields, use core updateRow for atomic update + validation
|
||||
const idx = originalIndex >= 0 ? originalIndex : rowIndex;
|
||||
validationState.updateRow(idx, key as unknown as any, processedValue);
|
||||
|
||||
// Secondary effects - using requestAnimationFrame for better performance
|
||||
requestAnimationFrame(() => {
|
||||
// Handle company change - clear line/subline and fetch product lines
|
||||
if (key === 'company' && value) {
|
||||
// Clear any existing line/subline values immediately
|
||||
// Helper: Handle company change side effects
|
||||
const handleCompanyChange = useCallback((rowIndex: number, rowId: any, companyId: string) => {
|
||||
// Clear line/subline values
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
const idx = newData.findIndex(item => item.__index === rowId);
|
||||
@@ -509,18 +452,9 @@ const ValidationContainer = <T extends string>({
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Fetch product lines for the new company with debouncing
|
||||
if (rowId && value !== undefined) {
|
||||
const companyId = value.toString();
|
||||
// Fetch product lines
|
||||
setValidatingCells(prev => new Set(prev).add(`${rowIndex}-line`));
|
||||
|
||||
// Set loading state first
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(`${rowIndex}-line`);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Debounce the API call to prevent excessive requests
|
||||
setTimeout(() => {
|
||||
fetchProductLines(rowId, companyId)
|
||||
.catch(err => {
|
||||
@@ -528,62 +462,18 @@ const ValidationContainer = <T extends string>({
|
||||
toast.error("Failed to load product lines");
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear loading indicator
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(`${rowIndex}-line`);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
}, 100); // 100ms debounce
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}, [setData, fetchProductLines]);
|
||||
|
||||
// Handle supplier + UPC validation - using the most recent values
|
||||
if (key === 'supplier' && value) {
|
||||
// Get the latest UPC value from the updated row
|
||||
const upcValue = (data[rowIndex] as any)?.upc || (data[rowIndex] as any)?.barcode;
|
||||
|
||||
if (upcValue) {
|
||||
|
||||
// Mark the item_number cell as being validated
|
||||
const cellKey = `${rowIndex}-item_number`;
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Use a regular promise-based approach instead of await
|
||||
upcValidation.validateUpc(rowIndex, value.toString(), upcValue.toString())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
upcValidation.applyItemNumbersToData();
|
||||
|
||||
// Mark for revalidation after item numbers are updated
|
||||
setTimeout(() => {
|
||||
markRowForRevalidation(rowIndex, 'item_number');
|
||||
}, 50);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error validating UPC:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear validation state for the item_number cell
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle line change - clear subline and fetch sublines
|
||||
if (key === 'line' && value) {
|
||||
|
||||
// Clear any existing subline value
|
||||
// Helper: Handle line change side effects
|
||||
const handleLineChange = useCallback((rowIndex: number, rowId: any, lineId: string) => {
|
||||
// Clear subline value
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
const idx = newData.findIndex(item => item.__index === rowId);
|
||||
@@ -598,270 +488,151 @@ const ValidationContainer = <T extends string>({
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Fetch sublines for the new line
|
||||
if (rowId && value !== undefined) {
|
||||
const lineId = value.toString();
|
||||
|
||||
// Set loading state first
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(`${rowIndex}-subline`);
|
||||
return newSet;
|
||||
});
|
||||
// Fetch sublines
|
||||
setValidatingCells(prev => new Set(prev).add(`${rowIndex}-subline`));
|
||||
|
||||
fetchSublines(rowId, lineId)
|
||||
.then(() => {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error fetching sublines for line ${lineId}:`, err);
|
||||
toast.error("Failed to load sublines");
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear loading indicator
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(`${rowIndex}-subline`);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [setData, fetchSublines]);
|
||||
|
||||
// Add the UPC/barcode validation handler back:
|
||||
// Handle UPC/barcode + supplier validation
|
||||
if ((key === 'upc' || key === 'barcode') && value) {
|
||||
// Get latest supplier from the updated row
|
||||
const supplier = (data[rowIndex] as any)?.supplier;
|
||||
|
||||
if (supplier) {
|
||||
|
||||
// Mark the item_number cell as being validated
|
||||
// Helper: Handle UPC validation
|
||||
const handleUpcValidation = useCallback((rowIndex: number, supplier: string, upc: string) => {
|
||||
const cellKey = `${rowIndex}-item_number`;
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
setValidatingCells(prev => new Set(prev).add(cellKey));
|
||||
|
||||
// Use a regular promise-based approach
|
||||
upcValidation.validateUpc(rowIndex, supplier.toString(), value.toString())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
upcValidation.applyItemNumbersToData();
|
||||
|
||||
// Mark for revalidation after item numbers are updated
|
||||
setTimeout(() => {
|
||||
markRowForRevalidation(rowIndex, 'item_number');
|
||||
}, 50);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error validating UPC:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear validation state for the item_number cell
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}); // Using requestAnimationFrame to defer execution until after the UI update
|
||||
}, [data, filteredData, setData, fetchProductLines, fetchSublines, upcValidation, markRowForRevalidation]);
|
||||
|
||||
// Fix the missing loading indicator clear code
|
||||
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => {
|
||||
// Get the value to copy from the source row
|
||||
const sourceRow = data[rowIndex];
|
||||
if (!sourceRow) {
|
||||
console.error(`Source row ${rowIndex} not found for copyDown`);
|
||||
return;
|
||||
}
|
||||
|
||||
const valueToCopy = sourceRow[fieldKey];
|
||||
|
||||
// Create a proper copy of the value to avoid reference issues, especially for arrays (MultiSelectCell)
|
||||
const valueCopy = Array.isArray(valueToCopy) ? [...valueToCopy] : valueToCopy;
|
||||
|
||||
// Get all rows below the source row, up to endRowIndex if specified
|
||||
const lastRowIndex = endRowIndex !== undefined ? Math.min(endRowIndex, data.length - 1) : data.length - 1;
|
||||
const rowsToUpdate = Array.from({ length: lastRowIndex - rowIndex }, (_, i) => rowIndex + i + 1);
|
||||
|
||||
// Mark all cells as updating at once
|
||||
const updatingCells = new Set<string>();
|
||||
rowsToUpdate.forEach(targetRowIndex => {
|
||||
updatingCells.add(`${targetRowIndex}-${fieldKey}`);
|
||||
});
|
||||
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
updatingCells.forEach(cell => newSet.add(cell));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Update all rows at once efficiently with a single state update
|
||||
setData(prevData => {
|
||||
// Create a new copy of the data
|
||||
const newData = [...prevData];
|
||||
|
||||
// Update all rows at once
|
||||
rowsToUpdate.forEach(targetRowIndex => {
|
||||
// Find the original row using __index
|
||||
const rowData = filteredData[targetRowIndex];
|
||||
if (!rowData) return;
|
||||
|
||||
const rowId = rowData.__index;
|
||||
const originalIndex = newData.findIndex(item => item.__index === rowId);
|
||||
|
||||
if (originalIndex !== -1) {
|
||||
// Update the specific field on this row
|
||||
newData[originalIndex] = {
|
||||
...newData[originalIndex],
|
||||
[fieldKey]: valueCopy
|
||||
};
|
||||
} else {
|
||||
// Fall back to direct index if __index not found
|
||||
if (targetRowIndex < newData.length) {
|
||||
newData[targetRowIndex] = {
|
||||
...newData[targetRowIndex],
|
||||
[fieldKey]: valueCopy
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Mark rows for revalidation
|
||||
rowsToUpdate.forEach(targetRowIndex => {
|
||||
markRowForRevalidation(targetRowIndex, fieldKey);
|
||||
});
|
||||
|
||||
// Clear the loading state for all cells efficiently
|
||||
requestAnimationFrame(() => {
|
||||
setValidatingCells(prev => {
|
||||
if (prev.size === 0 || updatingCells.size === 0) return prev;
|
||||
const newSet = new Set(prev);
|
||||
updatingCells.forEach(cell => newSet.delete(cell));
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
|
||||
// If copying UPC or supplier fields, validate UPC for all rows
|
||||
if (fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier') {
|
||||
// Process each row in parallel
|
||||
const validationsToRun: {rowIndex: number, supplier: string, upc: string}[] = [];
|
||||
|
||||
// Process each row separately to collect validation tasks
|
||||
rowsToUpdate.forEach(targetRowIndex => {
|
||||
const rowData = filteredData[targetRowIndex];
|
||||
if (!rowData) return;
|
||||
|
||||
// Only validate if both UPC and supplier are present after the update
|
||||
const updatedRow = {
|
||||
...rowData,
|
||||
[fieldKey]: valueCopy
|
||||
};
|
||||
|
||||
const hasUpc = updatedRow.upc || updatedRow.barcode;
|
||||
const hasSupplier = updatedRow.supplier;
|
||||
|
||||
if (hasUpc && hasSupplier) {
|
||||
const upcValue = updatedRow.upc || updatedRow.barcode;
|
||||
const supplierId = updatedRow.supplier;
|
||||
|
||||
// Queue this validation if both values are defined
|
||||
if (supplierId !== undefined && upcValue !== undefined) {
|
||||
validationsToRun.push({
|
||||
rowIndex: targetRowIndex,
|
||||
supplier: supplierId.toString(),
|
||||
upc: upcValue.toString()
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Run validations in parallel but limit the batch size
|
||||
if (validationsToRun.length > 0) {
|
||||
|
||||
// Mark all cells as validating
|
||||
validationsToRun.forEach(({ rowIndex }) => {
|
||||
const cellKey = `${rowIndex}-item_number`;
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
|
||||
// Process in smaller batches to avoid overwhelming the system
|
||||
const BATCH_SIZE = 5; // Process 5 validations at a time
|
||||
const processBatch = (startIdx: number) => {
|
||||
const endIdx = Math.min(startIdx + BATCH_SIZE, validationsToRun.length);
|
||||
const batch = validationsToRun.slice(startIdx, endIdx);
|
||||
|
||||
Promise.all(
|
||||
batch.map(({ rowIndex, supplier, upc }) =>
|
||||
upcValidation.validateUpc(rowIndex, supplier, upc)
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
// Apply immediately for better UX
|
||||
if (startIdx + BATCH_SIZE >= validationsToRun.length) {
|
||||
// Apply all updates at the end with callback to mark for revalidation
|
||||
upcValidation.applyItemNumbersToData(updatedRowIds => {
|
||||
// Mark these rows for revalidation after a delay
|
||||
setTimeout(() => {
|
||||
updatedRowIds.forEach(rowIdx => {
|
||||
markRowForRevalidation(rowIdx, 'item_number');
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
upcValidation.applyItemNumbersToData();
|
||||
setTimeout(() => markRowForRevalidation(rowIndex, 'item_number'), 50);
|
||||
}
|
||||
}
|
||||
return { rowIndex, success: result.success };
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error validating UPC for row ${rowIndex}:`, err);
|
||||
return { rowIndex, success: false };
|
||||
})
|
||||
.catch(err => console.error("Error validating UPC:", err))
|
||||
.finally(() => {
|
||||
// Clear validation state for this cell
|
||||
const cellKey = `${rowIndex}-item_number`;
|
||||
setValidatingCells(prev => {
|
||||
if (!prev.has(cellKey)) return prev;
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(cellKey);
|
||||
return newSet;
|
||||
});
|
||||
})
|
||||
)
|
||||
).then(() => {
|
||||
// If there are more validations to run, process the next batch
|
||||
if (endIdx < validationsToRun.length) {
|
||||
// Add a small delay between batches to prevent UI freezing
|
||||
setTimeout(() => processBatch(endIdx), 100);
|
||||
} else {
|
||||
// Final application of all item numbers if not done by individual batches
|
||||
upcValidation.applyItemNumbersToData(updatedRowIds => {
|
||||
// Mark these rows for revalidation after a delay
|
||||
setTimeout(() => {
|
||||
updatedRowIds.forEach(rowIdx => {
|
||||
markRowForRevalidation(rowIdx, 'item_number');
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [upcValidation, markRowForRevalidation]);
|
||||
|
||||
// Start processing the first batch
|
||||
processBatch(0);
|
||||
// Main update handler - simplified to focus on core logic
|
||||
const handleUpdateRow = useCallback(async (rowIndex: number, key: T, value: any) => {
|
||||
// Process value transformations
|
||||
const processedValue = processFieldValue(key, value);
|
||||
|
||||
// Find the row in the data
|
||||
const rowData = filteredData[rowIndex];
|
||||
if (!rowData) {
|
||||
console.error(`No row data found for index ${rowIndex}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use __index to find the actual row in the full data array
|
||||
const rowId = rowData.__index;
|
||||
const originalIndex = data.findIndex(item => item.__index === rowId);
|
||||
const idx = originalIndex >= 0 ? originalIndex : rowIndex;
|
||||
|
||||
// Update the row with validation
|
||||
updateRow(idx, key as unknown as any, processedValue);
|
||||
|
||||
// Handle secondary effects asynchronously
|
||||
requestAnimationFrame(() => {
|
||||
if (key === 'company' && value) {
|
||||
handleCompanyChange(rowIndex, rowId, value.toString());
|
||||
}
|
||||
|
||||
if (key === 'line' && value) {
|
||||
handleLineChange(rowIndex, rowId, value.toString());
|
||||
}
|
||||
|
||||
if (key === 'supplier' && value) {
|
||||
const upcValue = (data[rowIndex] as any)?.upc || (data[rowIndex] as any)?.barcode;
|
||||
if (upcValue) {
|
||||
handleUpcValidation(rowIndex, value.toString(), upcValue.toString());
|
||||
}
|
||||
}
|
||||
}, [data, filteredData, setData, setValidatingCells, upcValidation, markRowForRevalidation]);
|
||||
|
||||
if ((key === 'upc' || key === 'barcode') && value) {
|
||||
const supplier = (data[rowIndex] as any)?.supplier;
|
||||
if (supplier) {
|
||||
handleUpcValidation(rowIndex, supplier.toString(), value.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [data, filteredData, updateRow, processFieldValue, handleCompanyChange, handleLineChange, handleUpcValidation]);
|
||||
|
||||
// Copy-down that keeps validations in sync for all affected rows
|
||||
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => {
|
||||
const getActualIndex = (filteredIndex: number) => {
|
||||
const filteredRow = filteredData[filteredIndex] as Record<string, any> | undefined
|
||||
if (!filteredRow) return -1
|
||||
const rowId = filteredRow.__index
|
||||
if (rowId === undefined || rowId === null) return -1
|
||||
const actualIndex = dataIndexByRowId.get(rowId)
|
||||
return actualIndex === undefined ? -1 : actualIndex
|
||||
}
|
||||
|
||||
const sourceActualIndex = getActualIndex(rowIndex)
|
||||
if (sourceActualIndex < 0) {
|
||||
console.error(`Unable to resolve source index ${rowIndex} for copyDown`)
|
||||
return
|
||||
}
|
||||
|
||||
const sourceRow = data[sourceActualIndex] as Record<string, any> | undefined
|
||||
if (!sourceRow) {
|
||||
console.error(`Source row ${sourceActualIndex} not found for copyDown`)
|
||||
return
|
||||
}
|
||||
|
||||
const baseValue = sourceRow[fieldKey]
|
||||
const cloneValue = (value: any) => {
|
||||
if (Array.isArray(value)) return [...value]
|
||||
if (value && typeof value === 'object') return { ...value }
|
||||
return value
|
||||
}
|
||||
|
||||
const maxFilteredIndex = filteredData.length - 1
|
||||
const lastFilteredIndex = endRowIndex !== undefined ? Math.min(endRowIndex, maxFilteredIndex) : maxFilteredIndex
|
||||
if (lastFilteredIndex <= rowIndex) return
|
||||
|
||||
const updatedRows = new Set<number>()
|
||||
updatedRows.add(sourceActualIndex)
|
||||
|
||||
const snapshot = data.map((row) => ({ ...row })) as RowData<T>[]
|
||||
|
||||
for (let idx = rowIndex + 1; idx <= lastFilteredIndex; idx++) {
|
||||
const targetActualIndex = getActualIndex(idx)
|
||||
if (targetActualIndex < 0) continue
|
||||
|
||||
updatedRows.add(targetActualIndex)
|
||||
snapshot[targetActualIndex] = {
|
||||
...snapshot[targetActualIndex],
|
||||
[fieldKey]: cloneValue(baseValue)
|
||||
}
|
||||
|
||||
updateRow(targetActualIndex, fieldKey as T, cloneValue(baseValue))
|
||||
}
|
||||
|
||||
const affectedIndexes = Array.from(updatedRows)
|
||||
const fieldsMap: { [rowIndex: number]: string[] } = {}
|
||||
affectedIndexes.forEach((idx) => {
|
||||
fieldsMap[idx] = [fieldKey]
|
||||
})
|
||||
|
||||
revalidateRows(affectedIndexes, fieldsMap, snapshot)
|
||||
}, [data, dataIndexByRowId, filteredData, updateRow, revalidateRows])
|
||||
|
||||
// Memoize the rendered validation table
|
||||
const renderValidationTable = useMemo(() => {
|
||||
@@ -926,6 +697,20 @@ const ValidationContainer = <T extends string>({
|
||||
isLoadingSublines
|
||||
]);
|
||||
|
||||
// Show loading state during initialization
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[calc(100vh-10rem)]">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Initializing Validation</h3>
|
||||
<p className="text-sm text-muted-foreground">Processing {data.length} rows...</p>
|
||||
{pendingInitializationTasks.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">Still {pendingInitializationTasks.join(' | ')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden"
|
||||
@@ -998,16 +783,15 @@ const ValidationContainer = <T extends string>({
|
||||
<div className="rounded-md border h-full flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden w-full">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="overflow-auto max-h-[calc(100vh-320px)] w-full"
|
||||
style={{
|
||||
scrollBehavior: 'smooth',
|
||||
willChange: 'transform',
|
||||
position: 'relative',
|
||||
WebkitOverflowScrolling: 'touch', // Improve scroll performance on Safari
|
||||
overscrollBehavior: 'contain', // Prevent scroll chaining
|
||||
contain: 'paint', // Improve performance for sticky elements
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{renderValidationTable}
|
||||
</div>
|
||||
|
||||
@@ -55,8 +55,8 @@ interface ValidationTableProps<T extends string> {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// Create a memoized wrapper for template selects to prevent unnecessary re-renders
|
||||
const MemoizedTemplateSelect = React.memo(({
|
||||
// Simple template select component - let React handle optimization
|
||||
const TemplateSelectWrapper = ({
|
||||
templates,
|
||||
value,
|
||||
onValueChange,
|
||||
@@ -88,84 +88,7 @@ const MemoizedTemplateSelect = React.memo(({
|
||||
defaultBrand={defaultBrand}
|
||||
/>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
return (
|
||||
prev.value === next.value &&
|
||||
prev.templates === next.templates &&
|
||||
prev.defaultBrand === next.defaultBrand &&
|
||||
prev.isLoading === next.isLoading
|
||||
);
|
||||
});
|
||||
|
||||
MemoizedTemplateSelect.displayName = 'MemoizedTemplateSelect';
|
||||
|
||||
// Create a memoized cell component
|
||||
const MemoizedCell = React.memo(({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
isValidating,
|
||||
fieldKey,
|
||||
options,
|
||||
itemNumber,
|
||||
width,
|
||||
rowIndex,
|
||||
copyDown,
|
||||
totalRows,
|
||||
editingCells,
|
||||
setEditingCells
|
||||
}: {
|
||||
field: Field<string>,
|
||||
value: any,
|
||||
onChange: (value: any) => void,
|
||||
errors: ErrorType[],
|
||||
isValidating?: boolean,
|
||||
fieldKey: string,
|
||||
options?: readonly any[],
|
||||
itemNumber?: string,
|
||||
width: number,
|
||||
rowIndex: number,
|
||||
copyDown?: (endRowIndex?: number) => void,
|
||||
totalRows: number,
|
||||
editingCells: Set<string>,
|
||||
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
}) => {
|
||||
return (
|
||||
<ValidationCell
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
errors={errors}
|
||||
isValidating={isValidating}
|
||||
fieldKey={fieldKey}
|
||||
options={options}
|
||||
itemNumber={itemNumber}
|
||||
width={width}
|
||||
rowIndex={rowIndex}
|
||||
copyDown={copyDown}
|
||||
totalRows={totalRows}
|
||||
editingCells={editingCells}
|
||||
setEditingCells={setEditingCells}
|
||||
/>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// For item_number cells, only re-render when itemNumber actually changes
|
||||
if (prev.fieldKey === 'item_number') {
|
||||
return prev.itemNumber === next.itemNumber &&
|
||||
prev.value === next.value &&
|
||||
prev.isValidating === next.isValidating;
|
||||
}
|
||||
|
||||
// Simplified memo comparison - most expensive checks removed
|
||||
// Note: editingCells changes are not checked here as they need immediate re-renders
|
||||
return prev.value === next.value &&
|
||||
prev.isValidating === next.isValidating &&
|
||||
prev.errors === next.errors &&
|
||||
prev.options === next.options;
|
||||
});
|
||||
|
||||
MemoizedCell.displayName = 'MemoizedCell';
|
||||
};
|
||||
|
||||
const ValidationTable = <T extends string>({
|
||||
data,
|
||||
@@ -194,49 +117,83 @@ const ValidationTable = <T extends string>({
|
||||
}: ValidationTableProps<T>) => {
|
||||
const { translations } = useRsi<T>();
|
||||
|
||||
// Add state for copy down selection mode
|
||||
const [isInCopyDownMode, setIsInCopyDownMode] = useState(false);
|
||||
const [sourceRowIndex, setSourceRowIndex] = useState<number | null>(null);
|
||||
const [sourceFieldKey, setSourceFieldKey] = useState<string | null>(null);
|
||||
const [targetRowIndex, setTargetRowIndex] = useState<number | null>(null);
|
||||
// Copy-down state combined into single object
|
||||
type CopyDownState = {
|
||||
sourceRowIndex: number;
|
||||
sourceFieldKey: string;
|
||||
targetRowIndex: number | null;
|
||||
};
|
||||
const [copyDownState, setCopyDownState] = useState<CopyDownState | null>(null);
|
||||
|
||||
// Handle copy down completion
|
||||
const handleCopyDownComplete = useCallback((sourceRowIndex: number, fieldKey: string, targetRowIndex: number) => {
|
||||
// Call the copyDown function with the source row index, field key, and target row index
|
||||
copyDown(sourceRowIndex, fieldKey, targetRowIndex);
|
||||
|
||||
// Reset the copy down selection mode
|
||||
setIsInCopyDownMode(false);
|
||||
setSourceRowIndex(null);
|
||||
setSourceFieldKey(null);
|
||||
setTargetRowIndex(null);
|
||||
setCopyDownState(null);
|
||||
}, [copyDown]);
|
||||
|
||||
// Create copy down context value
|
||||
// Use a ref to track partial state during initialization
|
||||
const partialCopyDownRef = React.useRef<{ rowIndex?: number; fieldKey?: string }>({});
|
||||
|
||||
const copyDownContextValue = useMemo(() => ({
|
||||
isInCopyDownMode,
|
||||
sourceRowIndex,
|
||||
sourceFieldKey,
|
||||
targetRowIndex,
|
||||
setIsInCopyDownMode,
|
||||
setSourceRowIndex,
|
||||
setSourceFieldKey,
|
||||
setTargetRowIndex,
|
||||
isInCopyDownMode: copyDownState !== null,
|
||||
sourceRowIndex: copyDownState?.sourceRowIndex ?? null,
|
||||
sourceFieldKey: copyDownState?.sourceFieldKey ?? null,
|
||||
targetRowIndex: copyDownState?.targetRowIndex ?? null,
|
||||
setIsInCopyDownMode: (value: boolean) => {
|
||||
if (!value) {
|
||||
setCopyDownState(null);
|
||||
partialCopyDownRef.current = {};
|
||||
}
|
||||
},
|
||||
setSourceRowIndex: (rowIndex: number | null) => {
|
||||
if (rowIndex !== null) {
|
||||
partialCopyDownRef.current.rowIndex = rowIndex;
|
||||
// If we have both values, set the full state
|
||||
if (partialCopyDownRef.current.fieldKey !== undefined) {
|
||||
setCopyDownState({
|
||||
sourceRowIndex: rowIndex,
|
||||
sourceFieldKey: partialCopyDownRef.current.fieldKey,
|
||||
targetRowIndex: null
|
||||
});
|
||||
partialCopyDownRef.current = {};
|
||||
}
|
||||
}
|
||||
},
|
||||
setSourceFieldKey: (fieldKey: string | null) => {
|
||||
if (fieldKey !== null) {
|
||||
partialCopyDownRef.current.fieldKey = fieldKey;
|
||||
// If we have both values, set the full state
|
||||
if (partialCopyDownRef.current.rowIndex !== undefined) {
|
||||
setCopyDownState({
|
||||
sourceRowIndex: partialCopyDownRef.current.rowIndex,
|
||||
sourceFieldKey: fieldKey,
|
||||
targetRowIndex: null
|
||||
});
|
||||
partialCopyDownRef.current = {};
|
||||
}
|
||||
}
|
||||
},
|
||||
setTargetRowIndex: (rowIndex: number | null) => {
|
||||
if (copyDownState) {
|
||||
setCopyDownState({
|
||||
...copyDownState,
|
||||
targetRowIndex: rowIndex
|
||||
});
|
||||
}
|
||||
},
|
||||
handleCopyDownComplete
|
||||
}), [
|
||||
isInCopyDownMode,
|
||||
sourceRowIndex,
|
||||
sourceFieldKey,
|
||||
targetRowIndex,
|
||||
handleCopyDownComplete
|
||||
]);
|
||||
}), [copyDownState, handleCopyDownComplete]);
|
||||
|
||||
// Update targetRowIndex when hovering over rows in copy down mode
|
||||
const handleRowMouseEnter = useCallback((rowIndex: number) => {
|
||||
if (isInCopyDownMode && sourceRowIndex !== null && rowIndex > sourceRowIndex) {
|
||||
setTargetRowIndex(rowIndex);
|
||||
if (copyDownState && copyDownState.sourceRowIndex < rowIndex) {
|
||||
setCopyDownState({
|
||||
...copyDownState,
|
||||
targetRowIndex: rowIndex
|
||||
});
|
||||
}
|
||||
}, [isInCopyDownMode, sourceRowIndex]);
|
||||
}, [copyDownState]);
|
||||
|
||||
// Memoize the selection column with stable callback
|
||||
const handleSelectAll = useCallback((value: boolean, table: any) => {
|
||||
@@ -290,7 +247,7 @@ const ValidationTable = <T extends string>({
|
||||
return (
|
||||
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px', maxWidth: '200px', overflow: 'hidden' }}>
|
||||
<div className="w-full overflow-hidden">
|
||||
<MemoizedTemplateSelect
|
||||
<TemplateSelectWrapper
|
||||
templates={templates}
|
||||
value={templateValue || ''}
|
||||
onValueChange={(value) => handleTemplateChange(value, rowIndex)}
|
||||
@@ -465,8 +422,8 @@ const ValidationTable = <T extends string>({
|
||||
: `cell-${row.index}-${fieldKey}`;
|
||||
|
||||
return (
|
||||
<MemoizedCell
|
||||
key={cellKey} // CRITICAL: Add key to force re-render when itemNumber changes
|
||||
<ValidationCell
|
||||
key={cellKey}
|
||||
field={fieldWithType as Field<string>}
|
||||
value={currentValue}
|
||||
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
|
||||
@@ -527,7 +484,7 @@ const ValidationTable = <T extends string>({
|
||||
<CopyDownContext.Provider value={copyDownContextValue}>
|
||||
<div className="min-w-max relative">
|
||||
{/* Add global styles for copy down mode */}
|
||||
{isInCopyDownMode && (
|
||||
{copyDownState && (
|
||||
<style>
|
||||
{`
|
||||
.copy-down-target-row,
|
||||
@@ -543,7 +500,7 @@ const ValidationTable = <T extends string>({
|
||||
`}
|
||||
</style>
|
||||
)}
|
||||
{isInCopyDownMode && sourceRowIndex !== null && sourceFieldKey !== null && (
|
||||
{copyDownState && (
|
||||
<div className="sticky top-0 z-30 h-0 overflow-visible">
|
||||
<div
|
||||
className="absolute w-[240px] top-16 bg-blue-50 border rounded-2xl shadow-lg border-blue-200 p-3 text-sm text-blue-700 flex items-center justify-between"
|
||||
@@ -551,7 +508,7 @@ const ValidationTable = <T extends string>({
|
||||
left: (() => {
|
||||
// Find the column index
|
||||
const colIndex = columns.findIndex(col =>
|
||||
'accessorKey' in col && col.accessorKey === sourceFieldKey
|
||||
'accessorKey' in col && col.accessorKey === copyDownState.sourceFieldKey
|
||||
);
|
||||
|
||||
// If column not found, position at a default location
|
||||
@@ -579,7 +536,7 @@ const ValidationTable = <T extends string>({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsInCopyDownMode(false)}
|
||||
onClick={() => setCopyDownState(null)}
|
||||
className="text-xs h-7 border-blue-200 text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
Cancel
|
||||
@@ -636,15 +593,14 @@ const ValidationTable = <T extends string>({
|
||||
Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0;
|
||||
|
||||
// Precompute copy down target status
|
||||
const isCopyDownTarget = isInCopyDownMode &&
|
||||
sourceRowIndex !== null &&
|
||||
parseInt(row.id) > sourceRowIndex;
|
||||
const isCopyDownTarget = copyDownState !== null &&
|
||||
parseInt(row.id) > copyDownState.sourceRowIndex;
|
||||
|
||||
// Using CSS variables for better performance on hover/state changes
|
||||
const rowStyle = {
|
||||
cursor: isCopyDownTarget ? 'pointer' : undefined,
|
||||
position: 'relative' as const,
|
||||
willChange: isInCopyDownMode ? 'background-color' : 'auto',
|
||||
willChange: copyDownState ? 'background-color' : 'auto',
|
||||
contain: 'layout',
|
||||
transition: 'background-color 100ms ease-in-out'
|
||||
};
|
||||
@@ -677,34 +633,15 @@ const ValidationTable = <T extends string>({
|
||||
);
|
||||
};
|
||||
|
||||
// Optimize memo comparison with more efficient checks
|
||||
// Simplified memo - React 18+ handles most optimizations well
|
||||
// Only check critical props that frequently change
|
||||
const areEqual = (prev: ValidationTableProps<any>, next: ValidationTableProps<any>) => {
|
||||
// Check reference equality for simple props first
|
||||
if (prev.fields !== next.fields) return false;
|
||||
if (prev.templates !== next.templates) return false;
|
||||
if (prev.isLoadingTemplates !== next.isLoadingTemplates) return false;
|
||||
if (prev.filters?.showErrorsOnly !== next.filters?.showErrorsOnly) return false;
|
||||
|
||||
// Fast path: data length change always means re-render
|
||||
if (prev.data.length !== next.data.length) return false;
|
||||
|
||||
// CRITICAL: Check if data content has actually changed
|
||||
// Simple reference equality check - if data array reference changed, re-render
|
||||
if (prev.data !== next.data) return false;
|
||||
|
||||
// Efficiently check row selection changes
|
||||
const prevSelectionKeys = Object.keys(prev.rowSelection);
|
||||
const nextSelectionKeys = Object.keys(next.rowSelection);
|
||||
if (prevSelectionKeys.length !== nextSelectionKeys.length) return false;
|
||||
if (!prevSelectionKeys.every(key => prev.rowSelection[key] === next.rowSelection[key])) return false;
|
||||
|
||||
// Use size for Map comparisons instead of deeper checks
|
||||
if (prev.validationErrors.size !== next.validationErrors.size) return false;
|
||||
if (prev.validatingCells.size !== next.validatingCells.size) return false;
|
||||
if (prev.itemNumbers.size !== next.itemNumbers.size) return false;
|
||||
|
||||
// If values haven't changed, component doesn't need to re-render
|
||||
return true;
|
||||
// Only prevent re-render if absolutely nothing changed (rare case)
|
||||
return (
|
||||
prev.data === next.data &&
|
||||
prev.validationErrors === next.validationErrors &&
|
||||
prev.rowSelection === next.rowSelection
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ValidationTable, areEqual);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getApiUrl, RowData } from './validationTypes';
|
||||
import { Fields } from '../../../types';
|
||||
import { Meta } from '../types';
|
||||
import { addErrorsAndRunHooks } from '../utils/dataMutations';
|
||||
import { prepareDataForAiValidation } from '../utils/aiValidationUtils';
|
||||
import * as Diff from 'diff';
|
||||
|
||||
// Define interfaces for AI validation
|
||||
@@ -296,25 +297,8 @@ export const useAiValidation = <T extends string>(
|
||||
lastProduct: data[data.length - 1]
|
||||
});
|
||||
|
||||
// Build a complete row object including empty cells so API receives all fields
|
||||
const cleanedData = data.map(item => {
|
||||
const { __index, ...rest } = item as any;
|
||||
// Ensure all known field keys are present, even if empty
|
||||
const withAllKeys: Record<string, any> = {};
|
||||
(fields as any[]).forEach((f) => {
|
||||
const k = String(f.key);
|
||||
// Preserve arrays (e.g., multi-select) as empty array if undefined
|
||||
if (Array.isArray(rest[k])) {
|
||||
withAllKeys[k] = rest[k];
|
||||
} else if (rest[k] === undefined) {
|
||||
// Use empty string to represent an empty cell
|
||||
withAllKeys[k] = "";
|
||||
} else {
|
||||
withAllKeys[k] = rest[k];
|
||||
}
|
||||
});
|
||||
return withAllKeys;
|
||||
});
|
||||
// Use utility function to prepare data for AI validation
|
||||
const cleanedData = prepareDataForAiValidation(data, fields);
|
||||
|
||||
console.log('Cleaned data sample:', {
|
||||
length: cleanedData.length,
|
||||
@@ -323,7 +307,7 @@ export const useAiValidation = <T extends string>(
|
||||
});
|
||||
|
||||
// Use POST to send products in request body
|
||||
const response = await fetch(`${await getApiUrl()}/ai-validation/debug`, {
|
||||
const response = await fetch(`${getApiUrl()}/ai-validation/debug`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -435,22 +419,8 @@ export const useAiValidation = <T extends string>(
|
||||
});
|
||||
}, 1000) as unknown as NodeJS.Timeout;
|
||||
|
||||
// Build a complete row object including empty cells so API receives all fields
|
||||
const cleanedData = data.map(item => {
|
||||
const { __index, ...rest } = item as any;
|
||||
const withAllKeys: Record<string, any> = {};
|
||||
(fields as any[]).forEach((f) => {
|
||||
const k = String(f.key);
|
||||
if (Array.isArray(rest[k])) {
|
||||
withAllKeys[k] = rest[k];
|
||||
} else if (rest[k] === undefined) {
|
||||
withAllKeys[k] = "";
|
||||
} else {
|
||||
withAllKeys[k] = rest[k];
|
||||
}
|
||||
});
|
||||
return withAllKeys;
|
||||
});
|
||||
// Use utility function to prepare data for AI validation
|
||||
const cleanedData = prepareDataForAiValidation(data, fields);
|
||||
|
||||
console.log('Cleaned data for validation:', cleanedData);
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { Fields } from '@/components/product-import/types';
|
||||
import type { RowData } from './validationTypes';
|
||||
import { cleanPriceField } from '../utils/priceUtils';
|
||||
|
||||
/**
|
||||
* Custom hook for handling initial data validation
|
||||
*
|
||||
* Performs comprehensive validation on import data including:
|
||||
* - Price field cleaning and formatting
|
||||
* - Required field validation
|
||||
* - Regex pattern validation
|
||||
* - Batch processing for performance
|
||||
*
|
||||
* @param data - Array of row data to validate
|
||||
* @param fields - Field configuration with validation rules
|
||||
* @param setData - Function to update data after cleaning
|
||||
* @param setValidationErrors - Function to set validation errors
|
||||
* @param validateUniqueItemNumbers - Async function to validate uniqueness
|
||||
* @param upcValidationComplete - Flag indicating UPC validation is done
|
||||
* @param onComplete - Callback when validation is complete
|
||||
*/
|
||||
export function useInitialValidation<T extends string>({
|
||||
data,
|
||||
fields,
|
||||
setData,
|
||||
setValidationErrors,
|
||||
validateUniqueItemNumbers,
|
||||
upcValidationComplete,
|
||||
onComplete,
|
||||
}: {
|
||||
data: RowData<T>[];
|
||||
fields: Fields<T>;
|
||||
setData: (data: RowData<T>[]) => void;
|
||||
setValidationErrors: Dispatch<SetStateAction<Map<number, Record<string, any[]>>>>;
|
||||
validateUniqueItemNumbers: () => Promise<void>;
|
||||
upcValidationComplete: boolean;
|
||||
onComplete?: () => void;
|
||||
}) {
|
||||
const hasRunRef = useRef(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only run once
|
||||
if (hasRunRef.current) return;
|
||||
|
||||
// Wait for UPC validation to complete first
|
||||
if (!upcValidationComplete) return;
|
||||
|
||||
// Handle empty dataset immediately
|
||||
if (!data || data.length === 0) {
|
||||
hasRunRef.current = true;
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hasRunRef.current = true;
|
||||
setIsValidating(true);
|
||||
|
||||
const runValidation = async () => {
|
||||
console.log('Running initial validation...');
|
||||
|
||||
// Extract field groups for validation
|
||||
const requiredFields = fields.filter((field) =>
|
||||
field.validations?.some((v) => v.rule === 'required')
|
||||
);
|
||||
const regexFields = fields.filter((field) =>
|
||||
field.validations?.some((v) => v.rule === 'regex')
|
||||
);
|
||||
|
||||
console.log(`Validating ${requiredFields.length} required fields, ${regexFields.length} regex fields`);
|
||||
|
||||
// Dynamic batch size based on dataset size
|
||||
const BATCH_SIZE = data.length <= 50 ? data.length : 25;
|
||||
const totalBatches = Math.ceil(data.length / BATCH_SIZE);
|
||||
|
||||
// Initialize containers
|
||||
const newData = [...data];
|
||||
const validationErrorsTemp = new Map<number, Record<string, any[]>>();
|
||||
|
||||
// Process batches
|
||||
for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
|
||||
const startIdx = batchNum * BATCH_SIZE;
|
||||
const endIdx = Math.min(startIdx + BATCH_SIZE, data.length);
|
||||
|
||||
console.log(`Processing batch ${batchNum + 1}/${totalBatches} (rows ${startIdx}-${endIdx - 1})`);
|
||||
|
||||
// Process all rows in this batch
|
||||
const batchPromises = [];
|
||||
|
||||
for (let rowIndex = startIdx; rowIndex < endIdx; rowIndex++) {
|
||||
batchPromises.push(
|
||||
processRow(
|
||||
rowIndex,
|
||||
data[rowIndex],
|
||||
newData,
|
||||
requiredFields,
|
||||
regexFields,
|
||||
validationErrorsTemp
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(batchPromises);
|
||||
|
||||
// Yield to UI thread for large datasets
|
||||
if (batchNum % 2 === 1 || data.length > 500) {
|
||||
await new Promise((resolve) => setTimeout(resolve, data.length > 1000 ? 10 : 5));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Batch validation complete, applying results...');
|
||||
|
||||
// Apply validation errors
|
||||
setValidationErrors(validationErrorsTemp);
|
||||
|
||||
// Apply data changes (price formatting)
|
||||
if (JSON.stringify(data) !== JSON.stringify(newData)) {
|
||||
setData(newData);
|
||||
}
|
||||
|
||||
// Run uniqueness validation
|
||||
console.log('Running uniqueness validation...');
|
||||
await validateUniqueItemNumbers();
|
||||
|
||||
console.log('Initial validation complete');
|
||||
setIsValidating(false);
|
||||
|
||||
// Notify completion
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
runValidation().catch((error) => {
|
||||
console.error('Error during initial validation:', error);
|
||||
setIsValidating(false);
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
});
|
||||
}, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers, upcValidationComplete, onComplete]);
|
||||
|
||||
return { isValidating };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single row: clean data and validate
|
||||
*/
|
||||
function processRow<T extends string>(
|
||||
rowIndex: number,
|
||||
row: RowData<T>,
|
||||
newData: RowData<T>[],
|
||||
requiredFields: Fields<T>,
|
||||
regexFields: Fields<T>,
|
||||
validationErrorsTemp: Map<number, Record<string, any[]>>
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// Skip empty rows
|
||||
if (!row) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldErrors: Record<string, any[]> = {};
|
||||
let hasErrors = false;
|
||||
const rowAsRecord = row as Record<string, any>;
|
||||
|
||||
// Clean price fields if needed
|
||||
let needsUpdate = false;
|
||||
let cleanedRow = row;
|
||||
|
||||
if (
|
||||
rowAsRecord.msrp &&
|
||||
typeof rowAsRecord.msrp === 'string' &&
|
||||
(rowAsRecord.msrp.includes('$') || rowAsRecord.msrp.includes(','))
|
||||
) {
|
||||
if (!needsUpdate) {
|
||||
cleanedRow = { ...row } as RowData<T>;
|
||||
needsUpdate = true;
|
||||
}
|
||||
(cleanedRow as Record<string, any>).msrp = cleanPriceField(rowAsRecord.msrp);
|
||||
}
|
||||
|
||||
if (
|
||||
rowAsRecord.cost_each &&
|
||||
typeof rowAsRecord.cost_each === 'string' &&
|
||||
(rowAsRecord.cost_each.includes('$') || rowAsRecord.cost_each.includes(','))
|
||||
) {
|
||||
if (!needsUpdate) {
|
||||
cleanedRow = { ...row } as RowData<T>;
|
||||
needsUpdate = true;
|
||||
}
|
||||
(cleanedRow as Record<string, any>).cost_each = cleanPriceField(rowAsRecord.cost_each);
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
newData[rowIndex] = cleanedRow;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
for (const field of requiredFields) {
|
||||
const key = String(field.key);
|
||||
const value = row[key as keyof typeof row];
|
||||
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === 'object' && value !== null && Object.keys(value).length === 0)
|
||||
) {
|
||||
fieldErrors[key] = [
|
||||
{
|
||||
message:
|
||||
field.validations?.find((v) => v.rule === 'required')?.errorMessage ||
|
||||
'This field is required',
|
||||
level: 'error',
|
||||
source: 'row',
|
||||
type: 'required',
|
||||
},
|
||||
];
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate regex fields
|
||||
for (const field of regexFields) {
|
||||
const key = String(field.key);
|
||||
const value = row[key as keyof typeof row];
|
||||
|
||||
// Skip empty values (handled by required validation)
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const regexValidation = field.validations?.find((v) => v.rule === 'regex');
|
||||
if (regexValidation) {
|
||||
try {
|
||||
const regex = new RegExp(regexValidation.value, regexValidation.flags);
|
||||
if (!regex.test(String(value))) {
|
||||
fieldErrors[key] = [
|
||||
{
|
||||
message: regexValidation.errorMessage,
|
||||
level: regexValidation.level || 'error',
|
||||
source: 'row',
|
||||
type: 'regex',
|
||||
},
|
||||
];
|
||||
hasErrors = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Invalid regex in validation:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store errors if any
|
||||
if (hasErrors) {
|
||||
validationErrorsTemp.set(rowIndex, fieldErrors);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
@@ -378,20 +378,22 @@ export const useRowOperations = <T extends string>(
|
||||
const revalidateRows = useCallback(
|
||||
async (
|
||||
rowIndexes: number[],
|
||||
updatedFields?: { [rowIndex: number]: string[] }
|
||||
updatedFields?: { [rowIndex: number]: string[] },
|
||||
dataOverride?: RowData<T>[]
|
||||
) => {
|
||||
// Process all specified rows using a single state update to avoid race conditions
|
||||
setValidationErrors((prev) => {
|
||||
let newErrors = new Map(prev);
|
||||
const workingData = dataOverride ?? data;
|
||||
|
||||
// Track which uniqueness fields need to be revalidated across the dataset
|
||||
const uniqueFieldsToCheck = new Set<string>();
|
||||
|
||||
// Process each row
|
||||
for (const rowIndex of rowIndexes) {
|
||||
if (rowIndex < 0 || rowIndex >= data.length) continue;
|
||||
if (rowIndex < 0 || rowIndex >= workingData.length) continue;
|
||||
|
||||
const row = data[rowIndex];
|
||||
const row = workingData[rowIndex];
|
||||
if (!row) continue;
|
||||
|
||||
// If we have specific fields to update for this row
|
||||
@@ -464,7 +466,7 @@ export const useRowOperations = <T extends string>(
|
||||
|
||||
// Run per-field uniqueness checks and merge results
|
||||
if (uniqueFieldsToCheck.size > 0) {
|
||||
newErrors = mergeUniqueErrorsForFields(newErrors, data, Array.from(uniqueFieldsToCheck));
|
||||
newErrors = mergeUniqueErrorsForFields(newErrors, workingData, Array.from(uniqueFieldsToCheck));
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
|
||||
@@ -28,6 +28,7 @@ export const useUpcValidation = (
|
||||
|
||||
// Cache for UPC validation results
|
||||
const processedUpcMapRef = useRef(new Map<string, string>());
|
||||
const initialUpcValidationStartedRef = useRef(false);
|
||||
const initialUpcValidationDoneRef = useRef(false);
|
||||
|
||||
// Helper to create cell key
|
||||
@@ -317,13 +318,13 @@ export const useUpcValidation = (
|
||||
// Batch validate all UPCs in the data
|
||||
const validateAllUPCs = useCallback(async () => {
|
||||
// Skip if we've already done the initial validation
|
||||
if (initialUpcValidationDoneRef.current) {
|
||||
console.log('Initial UPC validation already done, skipping');
|
||||
if (initialUpcValidationStartedRef.current) {
|
||||
console.log('Initial UPC validation already in progress or complete, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark that we've started the initial validation
|
||||
initialUpcValidationDoneRef.current = true;
|
||||
initialUpcValidationStartedRef.current = true;
|
||||
|
||||
console.log('Starting initial UPC validation...');
|
||||
|
||||
@@ -345,6 +346,7 @@ export const useUpcValidation = (
|
||||
|
||||
if (totalRows === 0) {
|
||||
setIsValidatingUpc(false);
|
||||
initialUpcValidationDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -442,6 +444,7 @@ export const useUpcValidation = (
|
||||
} catch (error) {
|
||||
console.error('Error in batch validation:', error);
|
||||
} finally {
|
||||
initialUpcValidationDoneRef.current = true;
|
||||
// Make sure all validation states are cleared
|
||||
validationStateRef.current.validatingRows.clear();
|
||||
setValidatingRows(new Set());
|
||||
@@ -453,10 +456,8 @@ export const useUpcValidation = (
|
||||
|
||||
// Run initial UPC validation when data changes
|
||||
useEffect(() => {
|
||||
// Skip if there's no data or we've already done the validation
|
||||
if (data.length === 0 || initialUpcValidationDoneRef.current) return;
|
||||
if (initialUpcValidationStartedRef.current) return;
|
||||
|
||||
// Run validation
|
||||
validateAllUPCs();
|
||||
}, [data, validateAllUPCs]);
|
||||
|
||||
|
||||
@@ -11,41 +11,10 @@ import { useTemplateManagement } from "./useTemplateManagement";
|
||||
import { useFilterManagement } from "./useFilterManagement";
|
||||
import { useUniqueItemNumbersValidation } from "./useUniqueItemNumbersValidation";
|
||||
import { useUpcValidation } from "./useUpcValidation";
|
||||
import { useInitialValidation } from "./useInitialValidation";
|
||||
import { Props, RowData } from "./validationTypes";
|
||||
|
||||
// Country normalization helper (common mappings) - function declaration for hoisting
|
||||
function normalizeCountryCode(input: string): string | null {
|
||||
if (!input) return null;
|
||||
const s = input.trim();
|
||||
const upper = s.toUpperCase();
|
||||
if (/^[A-Z]{2}$/.test(upper)) return upper; // already 2-letter
|
||||
const iso3to2: Record<string, string> = {
|
||||
USA: "US", GBR: "GB", UK: "GB", CHN: "CN", DEU: "DE", FRA: "FR", ITA: "IT", ESP: "ES",
|
||||
CAN: "CA", MEX: "MX", AUS: "AU", NZL: "NZ", JPN: "JP", KOR: "KR", PRK: "KP", TWN: "TW",
|
||||
VNM: "VN", THA: "TH", IDN: "ID", IND: "IN", BRA: "BR", ARG: "AR", CHL: "CL", PER: "PE",
|
||||
ZAF: "ZA", RUS: "RU", UKR: "UA", NLD: "NL", BEL: "BE", CHE: "CH", SWE: "SE", NOR: "NO",
|
||||
DNK: "DK", POL: "PL", AUT: "AT", PRT: "PT", GRC: "GR", CZE: "CZ", HUN: "HU", IRL: "IE",
|
||||
ISR: "IL", PAK: "PK", BGD: "BD", PHL: "PH", MYS: "MY", SGP: "SG", HKG: "HK", MAC: "MO"
|
||||
};
|
||||
if (iso3to2[upper]) return iso3to2[upper];
|
||||
const nameMap: Record<string, string> = {
|
||||
"UNITED STATES": "US", "UNITED STATES OF AMERICA": "US", "AMERICA": "US", "U.S.": "US", "U.S.A": "US", "USA": "US",
|
||||
"UNITED KINGDOM": "GB", "UK": "GB", "GREAT BRITAIN": "GB", "ENGLAND": "GB",
|
||||
"CHINA": "CN", "PEOPLE'S REPUBLIC OF CHINA": "CN", "PRC": "CN",
|
||||
"CANADA": "CA", "MEXICO": "MX", "JAPAN": "JP", "SOUTH KOREA": "KR", "KOREA, REPUBLIC OF": "KR",
|
||||
"TAIWAN": "TW", "VIETNAM": "VN", "THAILAND": "TH", "INDONESIA": "ID", "INDIA": "IN",
|
||||
"GERMANY": "DE", "FRANCE": "FR", "ITALY": "IT", "SPAIN": "ES", "NETHERLANDS": "NL", "BELGIUM": "BE",
|
||||
"SWITZERLAND": "CH", "SWEDEN": "SE", "NORWAY": "NO", "DENMARK": "DK", "POLAND": "PL", "AUSTRIA": "AT",
|
||||
"PORTUGAL": "PT", "GREECE": "GR", "CZECH REPUBLIC": "CZ", "CZECHIA": "CZ", "HUNGARY": "HU", "IRELAND": "IE",
|
||||
"RUSSIA": "RU", "UKRAINE": "UA", "AUSTRALIA": "AU", "NEW ZEALAND": "NZ",
|
||||
"BRAZIL": "BR", "ARGENTINA": "AR", "CHILE": "CL", "PERU": "PE", "SOUTH AFRICA": "ZA",
|
||||
"ISRAEL": "IL", "PAKISTAN": "PK", "BANGLADESH": "BD", "PHILIPPINES": "PH", "MALAYSIA": "MY", "SINGAPORE": "SG",
|
||||
"HONG KONG": "HK", "MACAU": "MO"
|
||||
};
|
||||
const normalizedName = s.replace(/\./g, "").trim().toUpperCase();
|
||||
if (nameMap[normalizedName]) return nameMap[normalizedName];
|
||||
return null;
|
||||
}
|
||||
import { normalizeCountryCode } from "../utils/countryUtils";
|
||||
import { cleanPriceField } from "../utils/priceUtils";
|
||||
|
||||
export const useValidationState = <T extends string>({
|
||||
initialData,
|
||||
@@ -69,22 +38,12 @@ export const useValidationState = <T extends string>({
|
||||
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);
|
||||
// Clean price fields using utility
|
||||
if (updatedRow.msrp !== undefined) {
|
||||
updatedRow.msrp = cleanPriceField(updatedRow.msrp);
|
||||
}
|
||||
if (updatedRow.cost_each !== undefined) {
|
||||
updatedRow.cost_each = cleanPriceField(updatedRow.cost_each);
|
||||
}
|
||||
|
||||
// Set default tax category if not already set
|
||||
@@ -126,7 +85,6 @@ export const useValidationState = <T extends string>({
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
// Validation state
|
||||
const [isValidating] = useState(false);
|
||||
const [validationErrors, setValidationErrors] = useState<
|
||||
Map<number, Record<string, any[]>>
|
||||
>(new Map());
|
||||
@@ -141,8 +99,8 @@ export const useValidationState = <T extends string>({
|
||||
const [editingCells, setEditingCells] = useState<Set<string>>(new Set());
|
||||
const hasEditingCells = editingCells.size > 0;
|
||||
|
||||
const initialValidationDoneRef = useRef(false);
|
||||
// isValidatingRef unused; remove to satisfy TS
|
||||
// Track initial validation lifecycle
|
||||
const [initialValidationComplete, setInitialValidationComplete] = useState(false);
|
||||
// Track last seen item_number signature to drive targeted uniqueness checks
|
||||
const lastItemNumberSigRef = useRef<string | null>(null);
|
||||
|
||||
@@ -302,252 +260,33 @@ export const useValidationState = <T extends string>({
|
||||
},
|
||||
[data, onBack, onNext, validationErrors]
|
||||
);
|
||||
const { isValidating: isInitialValidationRunning } = useInitialValidation<T>({
|
||||
data,
|
||||
fields,
|
||||
setData,
|
||||
setValidationErrors,
|
||||
validateUniqueItemNumbers,
|
||||
upcValidationComplete: upcValidation.initialValidationDone,
|
||||
onComplete: () => {
|
||||
setInitialValidationComplete(true);
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize validation once, after initial UPC-based item number generation completes
|
||||
useEffect(() => {
|
||||
if (initialValidationDoneRef.current) return;
|
||||
// Wait for initial UPC validation to finish to avoid double work and ensure
|
||||
// item_number values are in place before uniqueness checks
|
||||
if (!upcValidation.initialValidationDone) return;
|
||||
|
||||
const runCompleteValidation = async () => {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
console.log("Running complete validation...");
|
||||
|
||||
// Get required fields
|
||||
const requiredFields = fields.filter((field) =>
|
||||
field.validations?.some((v) => v.rule === "required")
|
||||
);
|
||||
console.log(`Found ${requiredFields.length} required fields`);
|
||||
|
||||
// Get fields that have regex validation
|
||||
const regexFields = fields.filter((field) =>
|
||||
field.validations?.some((v) => v.rule === "regex")
|
||||
);
|
||||
console.log(`Found ${regexFields.length} fields with regex validation`);
|
||||
|
||||
// Get fields that need uniqueness validation
|
||||
const uniqueFields = fields.filter((field) =>
|
||||
field.validations?.some((v) => v.rule === "unique")
|
||||
);
|
||||
console.log(
|
||||
`Found ${uniqueFields.length} fields requiring uniqueness validation`
|
||||
);
|
||||
|
||||
// Dynamic batch size based on dataset size
|
||||
const BATCH_SIZE = data.length <= 50 ? data.length : 25; // Process all at once for small datasets
|
||||
const totalRows = data.length;
|
||||
|
||||
// Initialize new data for any modifications
|
||||
const newData = [...data];
|
||||
|
||||
// Create a temporary Map to collect all validation errors
|
||||
const validationErrorsTemp = new Map<
|
||||
number,
|
||||
Record<string, any[]>
|
||||
>();
|
||||
|
||||
// Variables for batching
|
||||
let currentBatch = 0;
|
||||
const totalBatches = Math.ceil(totalRows / BATCH_SIZE);
|
||||
|
||||
const processBatch = async () => {
|
||||
// Calculate batch range
|
||||
const startIdx = currentBatch * BATCH_SIZE;
|
||||
const endIdx = Math.min(startIdx + BATCH_SIZE, totalRows);
|
||||
console.log(
|
||||
`Processing batch ${
|
||||
currentBatch + 1
|
||||
}/${totalBatches} (rows ${startIdx} to ${endIdx - 1})`
|
||||
);
|
||||
|
||||
// Process rows in this batch
|
||||
const batchPromises: Promise<void>[] = [];
|
||||
|
||||
for (let rowIndex = startIdx; rowIndex < endIdx; rowIndex++) {
|
||||
batchPromises.push(
|
||||
new Promise<void>((resolve) => {
|
||||
const row = data[rowIndex];
|
||||
|
||||
// Skip if row is empty or undefined
|
||||
if (!row) {
|
||||
resolve();
|
||||
return;
|
||||
if (initialValidationComplete) return;
|
||||
if ((!data || data.length === 0) && upcValidation.initialValidationDone) {
|
||||
setInitialValidationComplete(true);
|
||||
}
|
||||
}, [data, initialValidationComplete, upcValidation.initialValidationDone]);
|
||||
|
||||
// Store field errors for this row
|
||||
const fieldErrors: Record<string, any[]> = {};
|
||||
let hasErrors = false;
|
||||
const hasPendingUpcValidation = upcValidation.validatingRows.size > 0;
|
||||
const isInitializing =
|
||||
!initialValidationComplete ||
|
||||
isInitialValidationRunning ||
|
||||
templateManagement.isLoadingTemplates ||
|
||||
hasPendingUpcValidation;
|
||||
|
||||
// Check if price fields need formatting
|
||||
const rowAsRecord = row as Record<string, any>;
|
||||
let mSrpNeedsProcessing = false;
|
||||
let costEachNeedsProcessing = false;
|
||||
|
||||
if (
|
||||
rowAsRecord.msrp &&
|
||||
typeof rowAsRecord.msrp === "string" &&
|
||||
(rowAsRecord.msrp.includes("$") ||
|
||||
rowAsRecord.msrp.includes(","))
|
||||
) {
|
||||
mSrpNeedsProcessing = true;
|
||||
}
|
||||
|
||||
if (
|
||||
rowAsRecord.cost_each &&
|
||||
typeof rowAsRecord.cost_each === "string" &&
|
||||
(rowAsRecord.cost_each.includes("$") ||
|
||||
rowAsRecord.cost_each.includes(","))
|
||||
) {
|
||||
costEachNeedsProcessing = true;
|
||||
}
|
||||
|
||||
// Process price fields if needed
|
||||
if (mSrpNeedsProcessing || costEachNeedsProcessing) {
|
||||
// Create a clean copy only if needed
|
||||
const cleanedRow = { ...row } as Record<string, any>;
|
||||
|
||||
if (mSrpNeedsProcessing) {
|
||||
const msrpValue = rowAsRecord.msrp.replace(/[$,]/g, "");
|
||||
const numValue = parseFloat(msrpValue);
|
||||
cleanedRow.msrp = !isNaN(numValue)
|
||||
? numValue.toFixed(2)
|
||||
: msrpValue;
|
||||
}
|
||||
|
||||
if (costEachNeedsProcessing) {
|
||||
const costValue = rowAsRecord.cost_each.replace(/[$,]/g, "");
|
||||
const numValue = parseFloat(costValue);
|
||||
cleanedRow.cost_each = !isNaN(numValue)
|
||||
? numValue.toFixed(2)
|
||||
: costValue;
|
||||
}
|
||||
|
||||
newData[rowIndex] = cleanedRow as RowData<T>;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
for (const field of requiredFields) {
|
||||
const key = String(field.key);
|
||||
const value = row[key as keyof typeof row];
|
||||
|
||||
// Skip non-required empty fields
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === "" ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === "object" &&
|
||||
value !== null &&
|
||||
Object.keys(value).length === 0)
|
||||
) {
|
||||
// Add error for empty required fields
|
||||
fieldErrors[key] = [
|
||||
{
|
||||
message:
|
||||
field.validations?.find((v) => v.rule === "required")
|
||||
?.errorMessage || "This field is required",
|
||||
level: "error",
|
||||
source: "row",
|
||||
type: "required",
|
||||
},
|
||||
];
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate regex fields - even if they have data
|
||||
for (const field of regexFields) {
|
||||
const key = String(field.key);
|
||||
const value = row[key as keyof typeof row];
|
||||
|
||||
// Skip empty values as they're handled by required validation
|
||||
if (value === undefined || value === null || value === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find regex validation
|
||||
const regexValidation = field.validations?.find(
|
||||
(v) => v.rule === "regex"
|
||||
);
|
||||
if (regexValidation) {
|
||||
try {
|
||||
// Check if value matches regex
|
||||
const regex = new RegExp(
|
||||
regexValidation.value,
|
||||
regexValidation.flags
|
||||
);
|
||||
if (!regex.test(String(value))) {
|
||||
// Add regex validation error
|
||||
fieldErrors[key] = [
|
||||
{
|
||||
message: regexValidation.errorMessage,
|
||||
level: regexValidation.level || "error",
|
||||
source: "row",
|
||||
type: "regex",
|
||||
},
|
||||
];
|
||||
hasErrors = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Invalid regex in validation:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update validation errors for this row
|
||||
if (hasErrors) {
|
||||
validationErrorsTemp.set(rowIndex, fieldErrors);
|
||||
}
|
||||
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for all row validations to complete
|
||||
await Promise.all(batchPromises);
|
||||
};
|
||||
|
||||
const processAllBatches = async () => {
|
||||
for (let batch = 0; batch < totalBatches; batch++) {
|
||||
currentBatch = batch;
|
||||
await processBatch();
|
||||
|
||||
// Yield to UI thread more frequently for large datasets
|
||||
if (batch % 2 === 1 || totalRows > 500) {
|
||||
await new Promise((resolve) => setTimeout(resolve, totalRows > 1000 ? 10 : 5));
|
||||
}
|
||||
}
|
||||
|
||||
// All batches complete
|
||||
console.log("All initial validation batches complete");
|
||||
|
||||
// Apply collected validation errors all at once
|
||||
setValidationErrors(validationErrorsTemp);
|
||||
|
||||
// Apply any data changes (like price formatting)
|
||||
if (JSON.stringify(data) !== JSON.stringify(newData)) {
|
||||
setData(newData);
|
||||
}
|
||||
|
||||
// Run uniqueness validation after the basic validation
|
||||
validateUniqueItemNumbers();
|
||||
|
||||
// Mark that initial validation is done
|
||||
initialValidationDoneRef.current = true;
|
||||
|
||||
console.log("Initial validation complete");
|
||||
};
|
||||
|
||||
// Start the validation process
|
||||
processAllBatches();
|
||||
};
|
||||
|
||||
// Run the complete validation
|
||||
runCompleteValidation();
|
||||
}, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers, upcValidation.initialValidationDone]);
|
||||
const isValidating = isInitialValidationRunning;
|
||||
|
||||
// Targeted uniqueness revalidation: run only when item_number values change
|
||||
useEffect(() => {
|
||||
@@ -693,6 +432,7 @@ export const useValidationState = <T extends string>({
|
||||
|
||||
// Validation
|
||||
isValidating,
|
||||
isInitializing,
|
||||
validationErrors,
|
||||
rowValidationStatus,
|
||||
validateRow,
|
||||
@@ -730,6 +470,9 @@ export const useValidationState = <T extends string>({
|
||||
getTemplateDisplayText,
|
||||
refreshTemplates: templateManagement.refreshTemplates,
|
||||
|
||||
// UPC validation
|
||||
upcValidation,
|
||||
|
||||
// Filters
|
||||
filters: filterManagement.filters,
|
||||
filterFields: filterManagement.filterFields,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* AI Validation utility functions
|
||||
*
|
||||
* Helper functions for processing AI validation data and managing progress
|
||||
*/
|
||||
|
||||
import type { Fields } from '@/components/product-import/types';
|
||||
|
||||
/**
|
||||
* Clean data for AI validation by including all fields
|
||||
*
|
||||
* Ensures every field is present in the data sent to the API,
|
||||
* converting undefined values to empty strings
|
||||
*/
|
||||
export function prepareDataForAiValidation<T extends string>(
|
||||
data: any[],
|
||||
fields: Fields<T>
|
||||
): Record<string, any>[] {
|
||||
return data.map(item => {
|
||||
const { __index, ...rest } = item;
|
||||
const withAllKeys: Record<string, any> = {};
|
||||
|
||||
fields.forEach((f) => {
|
||||
const k = String(f.key);
|
||||
if (Array.isArray(rest[k])) {
|
||||
withAllKeys[k] = rest[k];
|
||||
} else if (rest[k] === undefined) {
|
||||
withAllKeys[k] = "";
|
||||
} else {
|
||||
withAllKeys[k] = rest[k];
|
||||
}
|
||||
});
|
||||
|
||||
return withAllKeys;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process AI-corrected data to handle multi-select and select fields
|
||||
*
|
||||
* Converts comma-separated strings to arrays for multi-select fields
|
||||
* and handles label-to-value conversions for select fields
|
||||
*/
|
||||
export function processAiCorrectedData<T extends string>(
|
||||
correctedData: any[],
|
||||
originalData: any[],
|
||||
fields: Fields<T>
|
||||
): any[] {
|
||||
return correctedData.map((corrected: any, index: number) => {
|
||||
// Start with original data to preserve metadata like __index
|
||||
const original = originalData[index] || {};
|
||||
const processed = { ...original, ...corrected };
|
||||
|
||||
// Process each field according to its type
|
||||
Object.keys(processed).forEach(key => {
|
||||
if (key.startsWith('__')) return; // Skip metadata fields
|
||||
|
||||
const fieldConfig = fields.find(f => String(f.key) === key);
|
||||
if (!fieldConfig) return;
|
||||
|
||||
// Handle multi-select fields (comma-separated values → array)
|
||||
if (fieldConfig?.fieldType.type === 'multi-select' && typeof processed[key] === 'string') {
|
||||
processed[key] = processed[key]
|
||||
.split(',')
|
||||
.map((v: string) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
});
|
||||
|
||||
return processed;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress percentage based on elapsed time and estimates
|
||||
*
|
||||
* @param step - Current step number (1-5)
|
||||
* @param elapsedSeconds - Time elapsed since start
|
||||
* @param estimatedSeconds - Estimated total time (optional)
|
||||
* @returns Progress percentage (0-95, never reaches 100 until complete)
|
||||
*/
|
||||
export function calculateProgressPercent(
|
||||
step: number,
|
||||
elapsedSeconds: number,
|
||||
estimatedSeconds?: number
|
||||
): number {
|
||||
if (estimatedSeconds && estimatedSeconds > 0) {
|
||||
// Time-based progress
|
||||
return Math.min(95, (elapsedSeconds / estimatedSeconds) * 100);
|
||||
}
|
||||
|
||||
// Step-based progress with time adjustment
|
||||
const baseProgress = (step / 5) * 100;
|
||||
const timeAdjustment = step === 1 ? Math.min(20, elapsedSeconds * 0.5) : 0;
|
||||
return Math.min(95, baseProgress + timeAdjustment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base status message by removing time information
|
||||
*
|
||||
* Removes patterns like "(5s remaining)" or "(1m 30s elapsed)"
|
||||
*/
|
||||
export function extractBaseStatus(status: string): string {
|
||||
return status
|
||||
.replace(/\s\(\d+[ms].+\)$/, '')
|
||||
.replace(/\s\(\d+m \d+s.+\)$/, '');
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Country code normalization utilities
|
||||
*
|
||||
* Converts various country code formats and country names to ISO 3166-1 alpha-2 codes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalizes country codes and names to ISO 3166-1 alpha-2 format (2-letter codes)
|
||||
*
|
||||
* Supports:
|
||||
* - ISO 3166-1 alpha-2 codes (e.g., "US", "GB")
|
||||
* - ISO 3166-1 alpha-3 codes (e.g., "USA", "GBR")
|
||||
* - Common country names (e.g., "United States", "China")
|
||||
*
|
||||
* @param input - Country code or name to normalize
|
||||
* @returns ISO 3166-1 alpha-2 code or null if not recognized
|
||||
*
|
||||
* @example
|
||||
* normalizeCountryCode("USA") // "US"
|
||||
* normalizeCountryCode("United States") // "US"
|
||||
* normalizeCountryCode("US") // "US"
|
||||
* normalizeCountryCode("invalid") // null
|
||||
*/
|
||||
export function normalizeCountryCode(input: string): string | null {
|
||||
if (!input) return null;
|
||||
|
||||
const s = input.trim();
|
||||
const upper = s.toUpperCase();
|
||||
|
||||
// Already in ISO 3166-1 alpha-2 format
|
||||
if (/^[A-Z]{2}$/.test(upper)) return upper;
|
||||
|
||||
// ISO 3166-1 alpha-3 to alpha-2 mapping
|
||||
const iso3to2: Record<string, string> = {
|
||||
USA: "US", GBR: "GB", UK: "GB", CHN: "CN", DEU: "DE", FRA: "FR", ITA: "IT", ESP: "ES",
|
||||
CAN: "CA", MEX: "MX", AUS: "AU", NZL: "NZ", JPN: "JP", KOR: "KR", PRK: "KP", TWN: "TW",
|
||||
VNM: "VN", THA: "TH", IDN: "ID", IND: "IN", BRA: "BR", ARG: "AR", CHL: "CL", PER: "PE",
|
||||
ZAF: "ZA", RUS: "RU", UKR: "UA", NLD: "NL", BEL: "BE", CHE: "CH", SWE: "SE", NOR: "NO",
|
||||
DNK: "DK", POL: "PL", AUT: "AT", PRT: "PT", GRC: "GR", CZE: "CZ", HUN: "HU", IRL: "IE",
|
||||
ISR: "IL", PAK: "PK", BGD: "BD", PHL: "PH", MYS: "MY", SGP: "SG", HKG: "HK", MAC: "MO"
|
||||
};
|
||||
|
||||
if (iso3to2[upper]) return iso3to2[upper];
|
||||
|
||||
// Country name to ISO 3166-1 alpha-2 mapping
|
||||
const nameMap: Record<string, string> = {
|
||||
"UNITED STATES": "US", "UNITED STATES OF AMERICA": "US", "AMERICA": "US", "U.S.": "US", "U.S.A": "US", "USA": "US",
|
||||
"UNITED KINGDOM": "GB", "UK": "GB", "GREAT BRITAIN": "GB", "ENGLAND": "GB",
|
||||
"CHINA": "CN", "PEOPLE'S REPUBLIC OF CHINA": "CN", "PRC": "CN",
|
||||
"CANADA": "CA", "MEXICO": "MX", "JAPAN": "JP", "SOUTH KOREA": "KR", "KOREA, REPUBLIC OF": "KR",
|
||||
"TAIWAN": "TW", "VIETNAM": "VN", "THAILAND": "TH", "INDONESIA": "ID", "INDIA": "IN",
|
||||
"GERMANY": "DE", "FRANCE": "FR", "ITALY": "IT", "SPAIN": "ES", "NETHERLANDS": "NL", "BELGIUM": "BE",
|
||||
"SWITZERLAND": "CH", "SWEDEN": "SE", "NORWAY": "NO", "DENMARK": "DK", "POLAND": "PL", "AUSTRIA": "AT",
|
||||
"PORTUGAL": "PT", "GREECE": "GR", "CZECH REPUBLIC": "CZ", "CZECHIA": "CZ", "HUNGARY": "HU", "IRELAND": "IE",
|
||||
"RUSSIA": "RU", "UKRAINE": "UA", "AUSTRALIA": "AU", "NEW ZEALAND": "NZ",
|
||||
"BRAZIL": "BR", "ARGENTINA": "AR", "CHILE": "CL", "PERU": "PE", "SOUTH AFRICA": "ZA",
|
||||
"ISRAEL": "IL", "PAKISTAN": "PK", "BANGLADESH": "BD", "PHILIPPINES": "PH", "MALAYSIA": "MY", "SINGAPORE": "SG",
|
||||
"HONG KONG": "HK", "MACAU": "MO"
|
||||
};
|
||||
|
||||
// Normalize input: remove dots, trim, uppercase
|
||||
const normalizedName = s.replace(/\./g, "").trim().toUpperCase();
|
||||
if (nameMap[normalizedName]) return nameMap[normalizedName];
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Price field cleaning and formatting utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cleans a price field by removing currency symbols and formatting to 2 decimal places
|
||||
*
|
||||
* - Removes dollar signs ($) and commas (,)
|
||||
* - Converts to number and formats with 2 decimal places
|
||||
* - Returns original value if conversion fails
|
||||
*
|
||||
* @param value - Price value to clean (string or number)
|
||||
* @returns Cleaned price string formatted to 2 decimals, or original value if invalid
|
||||
*
|
||||
* @example
|
||||
* cleanPriceField("$1,234.56") // "1234.56"
|
||||
* cleanPriceField("$99.9") // "99.90"
|
||||
* cleanPriceField(123.456) // "123.46"
|
||||
* cleanPriceField("invalid") // "invalid"
|
||||
*/
|
||||
export function cleanPriceField(value: string | number): string {
|
||||
if (typeof value === "string") {
|
||||
const cleaned = value.replace(/[$,]/g, "");
|
||||
const numValue = parseFloat(cleaned);
|
||||
if (!isNaN(numValue)) {
|
||||
return numValue.toFixed(2);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans multiple price fields in a data object
|
||||
*
|
||||
* @param data - Object containing price fields
|
||||
* @param priceFields - Array of field keys to clean
|
||||
* @returns New object with cleaned price fields
|
||||
*
|
||||
* @example
|
||||
* cleanPriceFields({ msrp: "$99.99", cost_each: "$50.00" }, ["msrp", "cost_each"])
|
||||
* // { msrp: "99.99", cost_each: "50.00" }
|
||||
*/
|
||||
export function cleanPriceFields<T extends Record<string, any>>(
|
||||
data: T,
|
||||
priceFields: (keyof T)[]
|
||||
): T {
|
||||
const cleaned = { ...data };
|
||||
|
||||
for (const field of priceFields) {
|
||||
if (cleaned[field] !== undefined && cleaned[field] !== null) {
|
||||
cleaned[field] = cleanPriceField(cleaned[field]) as any;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ReactSpreadsheetImport, ErrorLevel, StepType } from "@/components/product-import";
|
||||
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Code } from "@/components/ui/code";
|
||||
@@ -9,342 +9,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import config from "@/config";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { DataValue, FieldType, Result } from "@/components/product-import/types";
|
||||
// Define base fields without dynamic options
|
||||
const BASE_IMPORT_FIELDS = [
|
||||
{
|
||||
label: "Supplier",
|
||||
key: "supplier",
|
||||
description: "Primary supplier/manufacturer of the product",
|
||||
fieldType: {
|
||||
type: "select" as const,
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 220,
|
||||
validations: [{ rule: "required" as const, errorMessage: "Required", level: "error" as ErrorLevel }],
|
||||
},
|
||||
{
|
||||
label: "Company",
|
||||
key: "company",
|
||||
description: "Company/Brand name",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 220,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "Line",
|
||||
key: "line",
|
||||
description: "Product line",
|
||||
alternateMatches: ["collection"],
|
||||
fieldType: {
|
||||
type: "select" as const,
|
||||
options: [], // Will be populated dynamically based on company selection
|
||||
},
|
||||
width: 220,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "Sub Line",
|
||||
key: "subline",
|
||||
description: "Product sub-line",
|
||||
fieldType: {
|
||||
type: "select" as const,
|
||||
options: [], // Will be populated dynamically based on line selection
|
||||
},
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
label: "UPC",
|
||||
key: "upc",
|
||||
description: "Universal Product Code/Barcode",
|
||||
alternateMatches: ["barcode", "bar code", "jan", "ean", "upc code"],
|
||||
fieldType: { type: "input" },
|
||||
width: 145,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Item Number",
|
||||
key: "item_number",
|
||||
description: "Internal item reference number",
|
||||
fieldType: { type: "input" },
|
||||
width: 130,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Supplier #",
|
||||
key: "supplier_no",
|
||||
description: "Supplier's product identifier",
|
||||
alternateMatches: ["sku", "item#", "mfg item #", "item", "supplier #"],
|
||||
fieldType: { type: "input" },
|
||||
width: 130,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Notions #",
|
||||
key: "notions_no",
|
||||
description: "Internal notions number",
|
||||
alternateMatches: ["notions #","nmc"],
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Name",
|
||||
key: "name",
|
||||
description: "Product name/title",
|
||||
alternateMatches: ["sku description","product name"],
|
||||
fieldType: { type: "input" },
|
||||
width: 500,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "MSRP",
|
||||
key: "msrp",
|
||||
description: "Manufacturer's Suggested Retail Price",
|
||||
alternateMatches: ["retail", "retail price", "sugg retail", "price", "sugg. retail","default price"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
price: true
|
||||
},
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Min Qty",
|
||||
key: "qty_per_unit",
|
||||
description: "Quantity of items per individual unit",
|
||||
alternateMatches: ["inner pack", "inner", "min qty", "unit qty", "min. order qty", "supplier qty/unit"],
|
||||
fieldType: { type: "input" },
|
||||
width: 80,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Cost Each",
|
||||
key: "cost_each",
|
||||
description: "Wholesale cost per unit",
|
||||
alternateMatches: ["wholesale", "wholesale price", "supplier cost each", "cost each"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
price: true
|
||||
},
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Case Pack",
|
||||
key: "case_qty",
|
||||
description: "Number of units per case",
|
||||
alternateMatches: ["mc qty","case qty","case pack","box ct"],
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Tax Category",
|
||||
key: "tax_cat",
|
||||
description: "Product tax category",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 200,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "Artist",
|
||||
key: "artist",
|
||||
description: "Artist/Designer name",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
label: "ETA Date",
|
||||
key: "eta",
|
||||
description: "Estimated arrival date",
|
||||
alternateMatches: ["shipping month"],
|
||||
fieldType: { type: "input" },
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
label: "Weight",
|
||||
key: "weight",
|
||||
description: "Product weight (in lbs)",
|
||||
alternateMatches: ["weight (lbs.)"],
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Length",
|
||||
key: "length",
|
||||
description: "Product length (in inches)",
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Width",
|
||||
key: "width",
|
||||
description: "Product width (in inches)",
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Height",
|
||||
key: "height",
|
||||
description: "Product height (in inches)",
|
||||
fieldType: { type: "input" },
|
||||
width: 100,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Shipping Restrictions",
|
||||
key: "ship_restrictions",
|
||||
description: "Product shipping restrictions",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 190,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "COO",
|
||||
key: "coo",
|
||||
description: "2-letter country code (ISO)",
|
||||
alternateMatches: ["coo", "country of origin"],
|
||||
fieldType: { type: "input" },
|
||||
width: 70,
|
||||
validations: [
|
||||
{ rule: "regex", value: "^[A-Z]{2}$", errorMessage: "Must be 2 letters", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "HTS Code",
|
||||
key: "hts_code",
|
||||
description: "Harmonized Tariff Schedule code",
|
||||
alternateMatches: ["taric","hts"],
|
||||
fieldType: { type: "input" },
|
||||
width: 130,
|
||||
validations: [
|
||||
{ rule: "regex", value: "^[0-9.]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Size Category",
|
||||
key: "size_cat",
|
||||
description: "Product size category",
|
||||
fieldType: {
|
||||
type: "select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
label: "Description",
|
||||
key: "description",
|
||||
description: "Detailed product description",
|
||||
alternateMatches: ["details/description"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
multiline: true
|
||||
},
|
||||
width: 500,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "Private Notes",
|
||||
key: "priv_notes",
|
||||
description: "Internal notes about the product",
|
||||
fieldType: {
|
||||
type: "input",
|
||||
multiline: true
|
||||
},
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
label: "Categories",
|
||||
key: "categories",
|
||||
description: "Product categories",
|
||||
fieldType: {
|
||||
type: "multi-select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 350,
|
||||
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
|
||||
},
|
||||
{
|
||||
label: "Themes",
|
||||
key: "themes",
|
||||
description: "Product themes/styles",
|
||||
fieldType: {
|
||||
type: "multi-select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
label: "Colors",
|
||||
key: "colors",
|
||||
description: "Product colors",
|
||||
fieldType: {
|
||||
type: "multi-select",
|
||||
options: [], // Will be populated from API
|
||||
},
|
||||
width: 200,
|
||||
},
|
||||
] as const;
|
||||
|
||||
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
||||
|
||||
export function Import() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
type ImportFieldKey = (typeof BASE_IMPORT_FIELDS)[number]["key"];
|
||||
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>;
|
||||
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user