From ff17b290aab71870ba77197af49757de297c6f1c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 30 Sep 2025 20:39:55 -0400 Subject: [PATCH] Clean up/optimize validation step --- .claude/CLAUDE.md | 172 ++++ .../src/components/product-import/config.ts | 340 ++++++++ .../ValidationStepNew/ValidationStepNew.tsx | 80 -- .../components/ValidationContainer.tsx | 740 +++++++----------- .../components/ValidationTable.tsx | 257 +++--- .../hooks/useAiValidation.tsx | 42 +- .../hooks/useInitialValidation.tsx | 268 +++++++ .../hooks/useRowOperations.tsx | 10 +- .../hooks/useUpcValidation.tsx | 21 +- .../hooks/useValidationState.tsx | 337 +------- .../utils/aiValidationUtils.ts | 107 +++ .../ValidationStepNew/utils/countryUtils.ts | 66 ++ .../ValidationStepNew/utils/priceUtils.ts | 62 ++ inventory/src/pages/Import.tsx | 336 +------- inventory/tsconfig.tsbuildinfo | 2 +- 15 files changed, 1440 insertions(+), 1400 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 inventory/src/components/product-import/config.ts delete mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/ValidationStepNew.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/hooks/useInitialValidation.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/utils/aiValidationUtils.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/utils/countryUtils.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStepNew/utils/priceUtils.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..054de61 --- /dev/null +++ b/.claude/CLAUDE.md @@ -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 `` 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 `` 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/` \ No newline at end of file diff --git a/inventory/src/components/product-import/config.ts b/inventory/src/components/product-import/config.ts new file mode 100644 index 0000000..bf0ba1a --- /dev/null +++ b/inventory/src/components/product-import/config.ts @@ -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"]; \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/ValidationStepNew.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/ValidationStepNew.tsx deleted file mode 100644 index 6f0a9bd..0000000 --- a/inventory/src/components/product-import/steps/ValidationStepNew/ValidationStepNew.tsx +++ /dev/null @@ -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({ - isOpen: false, - status: 'idle', - step: 0 - }); - - const [aiValidationDetails, setAiValidationDetails] = useState({ - changes: [], - warnings: [], - changeDetails: [], - isOpen: false - }); - - const [currentPrompt, setCurrentPrompt] = useState({ - isOpen: false, - prompt: '', - isLoading: true, - }); - - // Track reversion state (for internal use) - const [reversionState, setReversionState] = useState>({}); - - const [fieldData] = useState([]); - - // 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 ( -
- -
- ); -}; - -export default ValidationStepNew; \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx index 750ac51..3e41ce7 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx @@ -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 * @@ -44,8 +45,8 @@ const ValidationContainer = ({ }) const { - data, - filteredData, + data, + filteredData, validationErrors, rowSelection, setRowSelection, @@ -59,12 +60,28 @@ const ValidationContainer = ({ loadTemplates, setData, fields, + upcValidation, isLoadingTemplates, + isValidating, + isInitializing, validatingCells, setValidatingCells, editingCells, - setEditingCells + setEditingCells, + updateRow, + revalidateRows } = validationState + + const dataIndexByRowId = useMemo(() => { + const map = new Map() + data.forEach((row, index) => { + const rowId = (row as Record).__index + if (rowId !== undefined && rowId !== null) { + map.set(rowId, index) + } + }) + return map + }, [data]) // Use product lines fetching hook const { @@ -76,9 +93,6 @@ const ValidationContainer = ({ 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; @@ -108,18 +122,23 @@ const ValidationContainer = ({ ); const { translations } = useRsi() - + // State for product search dialog const [isProductSearchDialogOpen, setIsProductSearchDialogOpen] = useState(false) // Add new state for template form dialog const [isTemplateFormOpen, setIsTemplateFormOpen] = useState(false) const [templateFormInitialData, setTemplateFormInitialData] = useState(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(null) // Track fields that need revalidation due to value changes - const [fieldsToRevalidate, setFieldsToRevalidate] = useState>(new Set()); - const [fieldsToRevalidateMap, setFieldsToRevalidateMap] = useState<{[rowIndex: number]: string[]}>({}); + // Combined state: Map - if empty array, revalidate all fields + const [fieldsToRevalidate, setFieldsToRevalidate] = useState>(new Map()); // Function to mark a row for revalidation const markRowForRevalidation = useCallback((rowIndex: number, fieldKey?: string) => { @@ -138,47 +157,38 @@ const ValidationContainer = ({ })(); 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]; - } - return newMap; - }); - } + 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 - + // Trigger revalidation only for specifically marked fields useEffect(() => { if (fieldsToRevalidate.size === 0) return; - - // Revalidate the marked rows - const rowsToRevalidate = Array.from(fieldsToRevalidate); - - // Clear the revalidation set - setFieldsToRevalidate(new Set()); - - // Get the fields map for revalidation - const fieldsMap = { ...fieldsToRevalidateMap }; - - // Clear the fields map - setFieldsToRevalidateMap({}); - + + // 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 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,473 +405,234 @@ const ValidationContainer = ({ [setRowSelection] ); - // Add scroll container ref at the container level - const scrollContainerRef = useRef(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 | 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, ''); - - // Also ensure it's a valid number - const numValue = parseFloat(processedValue); - if (!isNaN(numValue)) { - processedValue = numValue.toFixed(2); + + // Clean price fields + if ((key === 'msrp' || key === 'cost_each') && value !== undefined && value !== null) { + processedValue = cleanPriceField(value); + } + + // 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(); + } } } - + + return processedValue; + }, []); + + // 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); + if (idx >= 0) { + newData[idx] = { + ...newData[idx], + line: undefined, + subline: undefined + }; + } + return newData; + }); + + // Fetch product lines + setValidatingCells(prev => new Set(prev).add(`${rowIndex}-line`)); + + setTimeout(() => { + fetchProductLines(rowId, companyId) + .catch(err => { + console.error(`Error fetching product lines for company ${companyId}:`, err); + toast.error("Failed to load product lines"); + }) + .finally(() => { + setValidatingCells(prev => { + const newSet = new Set(prev); + newSet.delete(`${rowIndex}-line`); + return newSet; + }); + }); + }, 100); + }, [setData, fetchProductLines]); + + // 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); + if (idx >= 0) { + newData[idx] = { + ...newData[idx], + subline: undefined + }; + } else { + console.warn(`Could not find row with ID ${rowId} to clear subline values`); + } + return newData; + }); + + // Fetch sublines + setValidatingCells(prev => new Set(prev).add(`${rowIndex}-subline`)); + + fetchSublines(rowId, lineId) + .catch(err => { + console.error(`Error fetching sublines for line ${lineId}:`, err); + toast.error("Failed to load sublines"); + }) + .finally(() => { + setValidatingCells(prev => { + const newSet = new Set(prev); + newSet.delete(`${rowIndex}-subline`); + return newSet; + }); + }); + }, [setData, fetchSublines]); + + // Helper: Handle UPC validation + const handleUpcValidation = useCallback((rowIndex: number, supplier: string, upc: string) => { + const cellKey = `${rowIndex}-item_number`; + setValidatingCells(prev => new Set(prev).add(cellKey)); + + upcValidation.validateUpc(rowIndex, supplier, upc) + .then(result => { + if (result.success) { + upcValidation.applyItemNumbersToData(); + setTimeout(() => markRowForRevalidation(rowIndex, 'item_number'), 50); + } + }) + .catch(err => console.error("Error validating UPC:", err)) + .finally(() => { + setValidatingCells(prev => { + const newSet = new Set(prev); + newSet.delete(cellKey); + return newSet; + }); + }); + }, [upcValidation, markRowForRevalidation]); + + // 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); - - // 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 + + // Update the row with validation + updateRow(idx, key as unknown as any, processedValue); + + // Handle secondary effects asynchronously requestAnimationFrame(() => { - // Handle company change - clear line/subline and fetch product lines if (key === 'company' && value) { - // Clear any existing line/subline values immediately - setData(prevData => { - const newData = [...prevData]; - const idx = newData.findIndex(item => item.__index === rowId); - if (idx >= 0) { - newData[idx] = { - ...newData[idx], - line: undefined, - subline: undefined - }; - } - return newData; - }); - - // Fetch product lines for the new company with debouncing - if (rowId && value !== undefined) { - const companyId = value.toString(); - - // 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 => { - console.error(`Error fetching product lines for company ${companyId}:`, err); - 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 - } + handleCompanyChange(rowIndex, rowId, value.toString()); } - - // 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 - setData(prevData => { - const newData = [...prevData]; - const idx = newData.findIndex(item => item.__index === rowId); - if (idx >= 0) { - newData[idx] = { - ...newData[idx], - subline: undefined - }; - } else { - console.warn(`Could not find row with ID ${rowId} to clear subline values`); - } - 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; - }); - - 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; - }); - }); + 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()); } } - - // 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 - const cellKey = `${rowIndex}-item_number`; - setValidatingCells(prev => { - const newSet = new Set(prev); - newSet.add(cellKey); - return newSet; - }); - - // 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; - }); - }); + handleUpcValidation(rowIndex, supplier.toString(), value.toString()); } } - }); // Using requestAnimationFrame to defer execution until after the UI update - }, [data, filteredData, setData, fetchProductLines, fetchSublines, upcValidation, markRowForRevalidation]); + }); + }, [data, filteredData, updateRow, processFieldValue, handleCompanyChange, handleLineChange, handleUpcValidation]); - // Fix the missing loading indicator clear code + // Copy-down that keeps validations in sync for all affected rows const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => { - // Get the value to copy from the source row - const sourceRow = data[rowIndex]; + const getActualIndex = (filteredIndex: number) => { + const filteredRow = filteredData[filteredIndex] as Record | 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 | undefined if (!sourceRow) { - console.error(`Source row ${rowIndex} not found for copyDown`); - return; + console.error(`Source row ${sourceActualIndex} 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(); - rowsToUpdate.forEach(targetRowIndex => { - updatingCells.add(`${targetRowIndex}-${fieldKey}`); - }); - - setValidatingCells(prev => { - const newSet = new Set(prev); - updatingCells.forEach(cell => newSet.add(cell)); - return newSet; - }); + const baseValue = sourceRow[fieldKey] + const cloneValue = (value: any) => { + if (Array.isArray(value)) return [...value] + if (value && typeof value === 'object') return { ...value } + return value + } - // 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); - }); - } - } - return { rowIndex, success: result.success }; - }) - .catch(err => { - console.error(`Error validating UPC for row ${rowIndex}:`, err); - return { rowIndex, success: false }; - }) - .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); - }); - } - }); - }; - - // Start processing the first batch - processBatch(0); + const maxFilteredIndex = filteredData.length - 1 + const lastFilteredIndex = endRowIndex !== undefined ? Math.min(endRowIndex, maxFilteredIndex) : maxFilteredIndex + if (lastFilteredIndex <= rowIndex) return + + const updatedRows = new Set() + updatedRows.add(sourceActualIndex) + + const snapshot = data.map((row) => ({ ...row })) as RowData[] + + 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)) } - }, [data, filteredData, setData, setValidatingCells, upcValidation, markRowForRevalidation]); + + 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 = ({ isLoadingSublines ]); + // Show loading state during initialization + if (isInitializing) { + return ( +
+ +

Initializing Validation

+

Processing {data.length} rows...

+ {pendingInitializationTasks.length > 0 && ( +

Still {pendingInitializationTasks.join(' | ')}

+ )} +
+ ); + } + return (
({
-
{renderValidationTable}
diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx index 73caad1..74993fe 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx @@ -55,8 +55,8 @@ interface ValidationTableProps { [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, - 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, - setEditingCells: React.Dispatch>> -}) => { - return ( - - ); -}, (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 = ({ data, @@ -194,49 +117,83 @@ const ValidationTable = ({ }: ValidationTableProps) => { const { translations } = useRsi(); - // Add state for copy down selection mode - const [isInCopyDownMode, setIsInCopyDownMode] = useState(false); - const [sourceRowIndex, setSourceRowIndex] = useState(null); - const [sourceFieldKey, setSourceFieldKey] = useState(null); - const [targetRowIndex, setTargetRowIndex] = useState(null); + // Copy-down state combined into single object + type CopyDownState = { + sourceRowIndex: number; + sourceFieldKey: string; + targetRowIndex: number | null; + }; + const [copyDownState, setCopyDownState] = useState(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 = ({ return (
- handleTemplateChange(value, rowIndex)} @@ -465,8 +422,8 @@ const ValidationTable = ({ : `cell-${row.index}-${fieldKey}`; return ( - } value={currentValue} onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)} @@ -527,7 +484,7 @@ const ValidationTable = ({
{/* Add global styles for copy down mode */} - {isInCopyDownMode && ( + {copyDownState && ( )} - {isInCopyDownMode && sourceRowIndex !== null && sourceFieldKey !== null && ( + {copyDownState && (
{ // Find the column index - const colIndex = columns.findIndex(col => - 'accessorKey' in col && col.accessorKey === sourceFieldKey + const colIndex = columns.findIndex(col => + 'accessorKey' in col && col.accessorKey === copyDownState.sourceFieldKey ); - + // If column not found, position at a default location if (colIndex === -1) return '50px'; - + // Calculate position based on column widths let position = 0; for (let i = 0; i < colIndex; i++) { position += columns[i].size || 0; } - + // Add half of the current column width to center it position += (columns[colIndex].size || 0) / 2; - + // Adjust to center the notification position -= 120; // Half of the notification width - + return `${Math.max(50, position)}px`; })() }} @@ -576,10 +533,10 @@ const ValidationTable = ({
Click on the last row you want to copy to
-