Compare commits
2 Commits
98e3b89d46
...
e21da8330e
| Author | SHA1 | Date | |
|---|---|---|---|
| e21da8330e | |||
| 56c3f0534d |
396
inventory/docs/ValidationStep-Refactoring-Plan.md
Normal file
396
inventory/docs/ValidationStep-Refactoring-Plan.md
Normal file
@@ -0,0 +1,396 @@
|
||||
# ValidationStep Component Refactoring Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines a comprehensive plan to refactor the current ValidationStep component (4000+ lines) into a more maintainable, modular structure. The new implementation will be developed alongside the existing component without modifying the original code. Once completed, the previous step in the workflow will offer the option to continue to either the original ValidationStep or the new implementation.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Current Component Analysis](#current-component-analysis)
|
||||
2. [New Architecture Design](#new-architecture-design)
|
||||
3. [Component Structure](#component-structure)
|
||||
4. [State Management](#state-management)
|
||||
5. [Key Features Implementation](#key-features-implementation)
|
||||
6. [Integration Plan](#integration-plan)
|
||||
7. [Testing Strategy](#testing-strategy)
|
||||
8. [Project Timeline](#project-timeline)
|
||||
9. [Design Principles](#design-principles)
|
||||
10. [Appendix: Function Reference](#appendix-function-reference)
|
||||
|
||||
## Current Component Analysis
|
||||
|
||||
The current ValidationStep component has several issues:
|
||||
|
||||
- **Size**: At over 4000 lines, it's difficult to maintain and understand
|
||||
- **Multiple responsibilities**: Handles validation, UI rendering, template management, and more
|
||||
- **Special cases**: Contains numerous special case handlers and exceptions
|
||||
- **Complex state management**: State is distributed across multiple useState calls
|
||||
- **Tightly coupled concerns**: UI, validation logic, and business rules are intertwined
|
||||
|
||||
### Key Features to Preserve
|
||||
|
||||
1. **Data Validation**
|
||||
- Field-level validation (required, regex, unique)
|
||||
- Row-level validation (supplier, company fields)
|
||||
- UPC validation with API integration
|
||||
- AI-assisted validation
|
||||
|
||||
2. **Template Management**
|
||||
- Saving, loading, and applying templates
|
||||
- Template-based validation
|
||||
|
||||
3. **UI Components**
|
||||
- Editable table with specialized cell renderers
|
||||
- Error display and management
|
||||
- Filtering and sorting capabilities
|
||||
- Status indicators and progress tracking
|
||||
|
||||
4. **Special Field Handling**
|
||||
- Input fields with price formatting
|
||||
- Multi-input fields with separator configuration
|
||||
- Select fields with dropdown options
|
||||
- Checkbox fields with boolean value mapping
|
||||
- UPC fields with specialized validation
|
||||
|
||||
5. **User Interaction Flows**
|
||||
- Tab and keyboard navigation
|
||||
- Bulk operations (select all, apply template)
|
||||
- Row validation on value change
|
||||
- Error reporting and display
|
||||
|
||||
## New Architecture Design
|
||||
|
||||
The new architecture will follow these principles:
|
||||
|
||||
1. **Separation of Concerns**
|
||||
- UI rendering separate from business logic
|
||||
- Validation logic isolated from state management
|
||||
- Clear interfaces between components
|
||||
|
||||
2. **Composable Components**
|
||||
- Small, focused components with single responsibilities
|
||||
- Reusable pattern for different field types
|
||||
|
||||
3. **Centralized State Management**
|
||||
- Custom hooks for state management
|
||||
- Clear data flow patterns
|
||||
- Reduced prop drilling
|
||||
|
||||
4. **Consistent Error Handling**
|
||||
- Standardized error structure
|
||||
- Predictable error propagation
|
||||
- User-friendly error display
|
||||
|
||||
5. **Performance Optimization**
|
||||
- Virtualized table rendering
|
||||
- Memoization of expensive computations
|
||||
- Deferred validation for better user experience
|
||||
|
||||
## Component Structure
|
||||
|
||||
The new ValidationStepNew folder has the following structure:
|
||||
|
||||
```
|
||||
ValidationStepNew/
|
||||
├── index.tsx # Main entry point that composes all pieces
|
||||
├── components/ # UI Components
|
||||
│ ├── ValidationContainer.tsx # Main wrapper component
|
||||
│ ├── ValidationTable.tsx # Table implementation
|
||||
│ ├── ValidationCell.tsx # Cell component
|
||||
│ ├── ValidationSidebar.tsx # Sidebar with controls
|
||||
│ ├── ValidationToolbar.tsx # Top toolbar (removed as unnecessary)
|
||||
│ ├── TemplateManager.tsx # Template management
|
||||
│ ├── FilterPanel.tsx # Filtering interface (integrated into Container)
|
||||
│ └── cells/ # Specialized cell renderers
|
||||
│ ├── InputCell.tsx
|
||||
│ ├── SelectCell.tsx
|
||||
│ ├── MultiInputCell.tsx
|
||||
│ └── CheckboxCell.tsx
|
||||
├── hooks/ # Custom hooks
|
||||
│ ├── useValidationState.tsx # Main state management
|
||||
│ ├── useTemplates.tsx # Template-related logic (integrated into ValidationState)
|
||||
│ ├── useFilters.tsx # Filtering logic (integrated into ValidationState)
|
||||
│ └── useUpcValidation.tsx # UPC-specific validation
|
||||
└── utils/ # Utility functions
|
||||
├── validationUtils.ts # Validation helper functions
|
||||
├── formatters.ts # Value formatting utilities
|
||||
└── constants.ts # Constant values and configuration
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
#### ValidationContainer
|
||||
- Main container component
|
||||
- Coordinates between subcomponents
|
||||
- Manages global state
|
||||
- Handles navigation events (next, back)
|
||||
- Contains filter controls
|
||||
|
||||
#### ValidationTable
|
||||
- Displays the data in tabular form
|
||||
- Manages selection state
|
||||
- Handles keyboard navigation
|
||||
- Integrates with TanStack Table
|
||||
- Displays properly styled rows and cells
|
||||
|
||||
#### ValidationCell
|
||||
- Factory component that renders appropriate cell type
|
||||
- Manages cell-level state
|
||||
- Handles validation errors display
|
||||
- Manages edit mode
|
||||
- Shows consistent error indicators
|
||||
|
||||
#### TemplateManager
|
||||
- Handles template selection UI
|
||||
- Provides template save/load functionality
|
||||
- Manages template application to rows
|
||||
|
||||
#### Cell Components
|
||||
- **InputCell**: Handles text input with multiline and price support
|
||||
- **MultiInputCell**: Handles multiple values with separator configuration
|
||||
- **SelectCell**: Command/popover component for single selection
|
||||
- **CheckboxCell**: Boolean value selection with mapping support
|
||||
|
||||
## State Management
|
||||
|
||||
### Core State Interface
|
||||
|
||||
```typescript
|
||||
interface ValidationState<T extends string> {
|
||||
// Core data
|
||||
data: RowData<T>[];
|
||||
filteredData: RowData<T>[];
|
||||
|
||||
// Validation state
|
||||
isValidating: boolean;
|
||||
validationErrors: Map<number, Record<string, Error[]>>;
|
||||
rowValidationStatus: Map<number, 'pending' | 'validating' | 'validated' | 'error'>;
|
||||
|
||||
// Selection state
|
||||
rowSelection: RowSelectionState;
|
||||
|
||||
// Template state
|
||||
templates: Template[];
|
||||
selectedTemplateId: string | null;
|
||||
|
||||
// Filter state
|
||||
filters: FilterState;
|
||||
|
||||
// Methods
|
||||
updateRow: (rowIndex: number, key: T, value: any) => void;
|
||||
validateRow: (rowIndex: number) => Promise<void>;
|
||||
validateUpc: (rowIndex: number, upcValue: string) => Promise<void>;
|
||||
applyTemplate: (templateId: string, rowIndexes: number[]) => void;
|
||||
saveTemplate: (name: string, type: string) => void;
|
||||
setFilters: (newFilters: Partial<FilterState>) => void;
|
||||
// Additional methods...
|
||||
}
|
||||
```
|
||||
|
||||
### useValidationState Hook
|
||||
|
||||
The main state management hook handles:
|
||||
|
||||
- Data manipulation (update, sort, filter)
|
||||
- Selection management
|
||||
- Validation coordination
|
||||
- Integration with validation utilities
|
||||
- Template management
|
||||
- Filtering and sorting
|
||||
|
||||
## Key Features Implementation
|
||||
|
||||
### 1. Field Type Handling
|
||||
|
||||
Implemented a strategy pattern for different field types:
|
||||
|
||||
```typescript
|
||||
// In ValidationCell
|
||||
const renderCellContent = () => {
|
||||
const fieldType = field.fieldType.type
|
||||
|
||||
switch (fieldType) {
|
||||
case 'input':
|
||||
return <InputCell<T> field={field} value={value} onChange={onChange} ... />
|
||||
case 'multi-input':
|
||||
return <MultiInputCell<T> field={field} value={value} onChange={onChange} ... />
|
||||
case 'select':
|
||||
return <SelectCell<T> field={field} value={value} onChange={onChange} ... />
|
||||
// etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Validation Logic
|
||||
|
||||
Validation is broken down into clear steps:
|
||||
|
||||
1. **Field Validation**: Apply field-level validations (required, regex, etc.)
|
||||
2. **Row Validation**: Apply row-level validations and rowHook
|
||||
3. **Table Validation**: Apply table-level validations (unique) and tableHook
|
||||
|
||||
Validation now happens automatically without explicit buttons, with immediate feedback on field blur.
|
||||
|
||||
### 3. UI Components
|
||||
|
||||
UI components follow these principles:
|
||||
|
||||
1. **Consistent Styling**: All components use shadcn UI for consistent look and feel
|
||||
2. **Visual Feedback**: Errors are clearly indicated with icons and border styling
|
||||
3. **Intuitive Editing**: Fields show outlines even when not in focus, and edit on click
|
||||
4. **Proper Command Pattern**: Select and multi-select fields use command/popover pattern for better UX
|
||||
5. **Focus Management**: Fields close when clicking away and perform validation on blur
|
||||
|
||||
## Design Principles
|
||||
|
||||
Based on user preferences and best practices, the following design principles guide this refactoring:
|
||||
|
||||
1. **Automatic Validation**
|
||||
- Validation should happen automatically without explicit buttons
|
||||
- All validation should run on initial data load
|
||||
- Fields should validate on blur (when user clicks away)
|
||||
|
||||
2. **Modern UI Patterns**
|
||||
- Command/popover components for all selects and multi-selects
|
||||
- Consistent field outlines and borders even when not in focus
|
||||
- Badge patterns for multi-select items
|
||||
- Clear visual indicators for errors
|
||||
|
||||
3. **Reduced Complexity**
|
||||
- Remove unnecessary UI elements like "validate all" buttons
|
||||
- Eliminate redundant state and toast notifications
|
||||
- Simplify component hierarchy where possible
|
||||
- Find root causes rather than adding special cases
|
||||
|
||||
4. **Consistent Component Behavior**
|
||||
- Fields should close when clicking away
|
||||
- All inputs should follow the same editing pattern
|
||||
- Error handling should be consistent across all field types
|
||||
- Multi-select fields should allow selecting multiple items with clear visual feedback
|
||||
|
||||
## Integration Plan
|
||||
|
||||
### 1. Creating the New Component Structure
|
||||
|
||||
Folder structure has been created without modifying the existing code:
|
||||
|
||||
```bash
|
||||
mkdir -p inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/{components,hooks,utils}
|
||||
mkdir -p inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells
|
||||
```
|
||||
|
||||
### 2. Implementing Basic Components
|
||||
|
||||
Core components have been implemented:
|
||||
|
||||
1. Created index.tsx as the main entry point
|
||||
2. Implemented ValidationContainer with basic state management
|
||||
3. Created ValidationTable for data display
|
||||
4. Implemented basic cell rendering with specialized cell types
|
||||
|
||||
### 3. Implementing State Management
|
||||
|
||||
State management has been implemented:
|
||||
|
||||
1. Created useValidationState hook
|
||||
2. Implemented data transformation utilities
|
||||
3. Added validation logic
|
||||
|
||||
### 4. Integrating with Previous Step
|
||||
|
||||
The previous step component allows choosing between validation implementations, enabling gradual testing and adoption.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit Tests**
|
||||
- Test individual utility functions
|
||||
- Test hooks in isolation
|
||||
- Test individual UI components
|
||||
|
||||
2. **Integration Tests**
|
||||
- Test component interactions
|
||||
- Test state management flow
|
||||
- Test validation logic integration
|
||||
|
||||
3. **Comparison Tests**
|
||||
- Compare output of new component with original
|
||||
- Verify that all functionality works the same
|
||||
|
||||
4. **Performance Tests**
|
||||
- Measure render times
|
||||
- Evaluate memory usage
|
||||
- Compare against original component
|
||||
|
||||
## Project Timeline
|
||||
|
||||
1. **Phase 1: Initial Structure (Completed)**
|
||||
- Set up folder structure
|
||||
- Implement basic components
|
||||
- Create core state management
|
||||
|
||||
2. **Phase 2: Core Functionality (In Progress)**
|
||||
- Implement validation logic (completed)
|
||||
- Create cell renderers (completed)
|
||||
- Add template management (in progress)
|
||||
|
||||
3. **Phase 3: Special Features (Upcoming)**
|
||||
- Implement UPC validation
|
||||
- Add AI validation
|
||||
- Handle special cases
|
||||
|
||||
4. **Phase 4: UI Refinement (Ongoing)**
|
||||
- Improve error display (completed)
|
||||
- Enhance user interactions (completed)
|
||||
- Optimize performance (in progress)
|
||||
|
||||
5. **Phase 5: Testing and Integration (Upcoming)**
|
||||
- Write tests
|
||||
- Fix bugs
|
||||
- Integrate with previous step
|
||||
|
||||
## Appendix: Function Reference
|
||||
|
||||
This section documents the core functions from the original ValidationStep that need to be preserved in the new implementation.
|
||||
|
||||
### Validation Functions
|
||||
|
||||
1. **validateRegex** - Validates values against regex patterns
|
||||
2. **getValidationError** - Determines field-level validation errors
|
||||
3. **validateAndCommit** - Validates and commits new values
|
||||
4. **validateData** - Validates all data rows
|
||||
5. **validateUpcAndGenerateItemNumbers** - Validates UPC codes and generates item numbers
|
||||
|
||||
### Formatting Functions
|
||||
|
||||
1. **formatPrice** - Formats price values
|
||||
2. **getDisplayValue** - Gets formatted display value based on field type
|
||||
3. **isMultiInputType** - Checks if field is multi-input type
|
||||
4. **getMultiInputSeparator** - Gets separator for multi-input fields
|
||||
5. **isPriceField** - Checks if field should be formatted as price
|
||||
|
||||
### Template Functions
|
||||
|
||||
1. **loadTemplates** - Loads templates from storage
|
||||
2. **saveTemplate** - Saves a new template
|
||||
3. **applyTemplate** - Applies a template to selected rows
|
||||
4. **getTemplateDisplayText** - Gets display text for a template
|
||||
|
||||
### AI Validation Functions
|
||||
|
||||
1. **handleAiValidation** - Triggers AI validation
|
||||
2. **showCurrentPrompt** - Shows current AI prompt
|
||||
3. **getFieldDisplayValue** - Gets display value for a field
|
||||
4. **highlightDifferences** - Highlights differences between original and corrected values
|
||||
5. **getFieldDisplayValueWithHighlight** - Gets display value with highlighted differences
|
||||
6. **revertAiChange** - Reverts an AI-suggested change
|
||||
7. **isChangeReverted** - Checks if an AI change has been reverted
|
||||
|
||||
### Event Handlers
|
||||
|
||||
1. **handleUpcValueUpdate** - Handles UPC value updates
|
||||
2. **handleBlur** - Handles input blur events
|
||||
3. **handleWheel** - Handles wheel events for navigation
|
||||
4. **copyValueDown** - Copies a value to cells below
|
||||
5. **handleSkuGeneration** - Generates SKUs
|
||||
|
||||
By following this refactoring plan, we continue to transform the monolithic ValidationStep component into a modular, maintainable set of components while preserving all existing functionality and aligning with user preferences for design and behavior.
|
||||
137
inventory/docs/ValidationStepNew-Implementation-Status.md
Normal file
137
inventory/docs/ValidationStepNew-Implementation-Status.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# ValidationStepNew Implementation Status
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the current status of the ValidationStepNew implementation, a refactored version of the original ValidationStep component. The goal is to create a more maintainable, modular component that preserves all functionality of the original while eliminating technical debt and implementing modern UI patterns.
|
||||
|
||||
## Design Principles
|
||||
|
||||
Based on the user's preferences, we're following these core design principles:
|
||||
|
||||
1. **Automatic Validation**
|
||||
- ✅ Validation runs automatically on data load
|
||||
- ✅ No explicit "validate all" button needed
|
||||
- ✅ Fields validate on blur when user clicks away
|
||||
- ✅ Immediate visual feedback for validation errors
|
||||
|
||||
2. **Modern UI Patterns**
|
||||
- ✅ Command/popover components for selects and multi-selects
|
||||
- ✅ Consistent field outlines and borders even when not in focus
|
||||
- ✅ Badge pattern for multi-select field items
|
||||
- ✅ Visual indicators for errors with appropriate styling
|
||||
|
||||
3. **Reduced Complexity**
|
||||
- ✅ Removed unnecessary UI elements like "validate all" button
|
||||
- ✅ Eliminated redundant toast notifications
|
||||
- ✅ Simplified component hierarchy
|
||||
- ✅ Fixed root causes rather than adding special cases
|
||||
|
||||
4. **Consistent Behavior**
|
||||
- ✅ Fields close when clicking away
|
||||
- ✅ All inputs follow the same editing pattern
|
||||
- ✅ Error handling is consistent across field types
|
||||
- ✅ Multi-select fields allow selecting multiple items
|
||||
|
||||
## Completed Components
|
||||
|
||||
### Core Structure
|
||||
- ✅ Main component structure
|
||||
- ✅ Directory organization
|
||||
- ✅ TypeScript interfaces
|
||||
- ✅ Props definition and passing
|
||||
|
||||
### State Management
|
||||
- ✅ `useValidationState` hook for centralized state
|
||||
- ✅ Data validation logic
|
||||
- ✅ Integration with rowHook and tableHook
|
||||
- ✅ Error tracking and management
|
||||
- ✅ Row selection
|
||||
- ✅ Automatic validation on data load
|
||||
|
||||
### UI Components
|
||||
- ✅ ValidationContainer with appropriate layout
|
||||
- ✅ ValidationTable with shadcn UI components
|
||||
- ✅ ValidationCell factory component
|
||||
- ✅ Row select/deselect functionality
|
||||
- ✅ Error display and indicators
|
||||
- ✅ Selection action bar
|
||||
|
||||
### Cell Components
|
||||
- ✅ InputCell with price and multiline support
|
||||
- ✅ MultiInputCell with separator configuration
|
||||
- ✅ SelectCell using command/popover pattern
|
||||
- ✅ CheckboxCell with boolean mapping
|
||||
- ✅ Consistent styling across all field types
|
||||
- ✅ Proper edit/view state management
|
||||
- ✅ Outlined borders in both edit and view modes
|
||||
|
||||
### Utility Functions
|
||||
- ✅ Value formatting for display
|
||||
- ✅ Field type detection
|
||||
- ✅ Error creation and management
|
||||
- ✅ Price formatting
|
||||
|
||||
### UI Improvements
|
||||
- ✅ Consistent borders and field outlines
|
||||
- ✅ Fields that properly close when clicking away
|
||||
- ✅ Multi-select with badge UI pattern
|
||||
- ✅ Command pattern for searchable select menus
|
||||
- ✅ Better visual error indication
|
||||
|
||||
## Pending Tasks
|
||||
|
||||
### Enhanced Validation
|
||||
- ⏳ AI validation system
|
||||
- ⏳ Custom validation hooks
|
||||
- ⏳ Enhanced UPC validation with API integration
|
||||
- ⏳ Validation visualizations
|
||||
|
||||
### Advanced UI Features
|
||||
- ⏳ Table virtualization for performance
|
||||
- ⏳ Drag-and-drop reordering
|
||||
- ⏳ Bulk operations (copy down, fill all, etc.)
|
||||
- ⏳ Keyboard navigation improvements
|
||||
- ⏳ Template dialogs and management UI
|
||||
|
||||
### Special Features
|
||||
- ⏳ Image preview integration
|
||||
- ⏳ SKU generation system
|
||||
- ⏳ Item number generation
|
||||
- ⏳ Dependent dropdown values
|
||||
|
||||
### Testing
|
||||
- ⏳ Unit tests for utility functions
|
||||
- ⏳ Component tests
|
||||
- ⏳ Integration tests
|
||||
- ⏳ Performance benchmarks
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. TypeScript error for `validationDisabled` property in ValidationCell.tsx
|
||||
2. Some type casting is needed due to complex generic types
|
||||
3. Need to address edge cases for multi-select fields validation
|
||||
4. Proper error handling for API calls needs implementation
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Fix TypeScript errors in ValidationCell and related components
|
||||
2. Complete template management functionality
|
||||
3. Implement UPC validation with API integration
|
||||
4. Make multi-select field validation more robust
|
||||
5. Add comprehensive tests
|
||||
|
||||
## Performance Improvements
|
||||
|
||||
We've already implemented several performance optimizations:
|
||||
|
||||
1. ✅ More efficient state updates by removing unnecessary re-renders
|
||||
2. ✅ Better error handling to prevent cascading validations
|
||||
3. ✅ Improved component isolation to prevent unnecessary re-renders
|
||||
4. ✅ Automatic validation that doesn't block the UI
|
||||
|
||||
Additional planned improvements:
|
||||
|
||||
1. Virtualized table rendering for large datasets
|
||||
2. Memoization of expensive calculations
|
||||
3. Optimized state updates to minimize re-renders
|
||||
4. Batched API calls for validation
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export type MatchColumnsProps<T extends string> = {
|
||||
data: RawData[]
|
||||
@@ -48,6 +50,7 @@ export type GlobalSelections = {
|
||||
company?: string
|
||||
line?: string
|
||||
subline?: string
|
||||
useNewValidation?: boolean
|
||||
}
|
||||
|
||||
export enum ColumnType {
|
||||
@@ -1698,11 +1701,25 @@ export const MatchColumnsStep = React.memo(<T extends string>({
|
||||
|
||||
<div className="border-t bg-muted px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
{onBack && (
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
{translations.matchColumnsStep.backButtonTitle}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<Switch
|
||||
id="use-new-validation"
|
||||
checked={!!globalSelections.useNewValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setGlobalSelections(prev => ({ ...prev, useNewValidation: checked }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="use-new-validation">Use new validation component</Label>
|
||||
</div>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
disabled={isLoading}
|
||||
@@ -1713,5 +1730,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
|
||||
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
|
||||
import { mapWorkbook } from "../utils/mapWorkbook"
|
||||
import { ValidationStep } from "./ValidationStep/ValidationStep"
|
||||
import { ValidationStepNew } from "./ValidationStepNew"
|
||||
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
|
||||
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
||||
import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep"
|
||||
@@ -172,10 +173,23 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
try {
|
||||
const data = await matchColumnsStepHook(values, rawData, columns)
|
||||
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
|
||||
|
||||
// Apply global selections to each row of data if they exist
|
||||
const dataWithGlobalSelections = globalSelections
|
||||
? dataWithMeta.map(row => {
|
||||
const newRow = { ...row };
|
||||
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
|
||||
if (globalSelections.company) newRow.company = globalSelections.company;
|
||||
if (globalSelections.line) newRow.line = globalSelections.line;
|
||||
if (globalSelections.subline) newRow.subline = globalSelections.subline;
|
||||
return newRow;
|
||||
})
|
||||
: dataWithMeta;
|
||||
|
||||
setPersistedGlobalSelections(globalSelections)
|
||||
onNext({
|
||||
type: StepType.validateData,
|
||||
data: dataWithMeta,
|
||||
data: dataWithGlobalSelections,
|
||||
globalSelections,
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -186,6 +200,35 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
/>
|
||||
)
|
||||
case StepType.validateData:
|
||||
// Check if new validation component should be used
|
||||
if (state.globalSelections?.useNewValidation) {
|
||||
// Use the new ValidationStepNew component
|
||||
return (
|
||||
<ValidationStepNew
|
||||
initialData={state.data}
|
||||
file={uploadedFile!}
|
||||
onBack={() => {
|
||||
if (onBack) {
|
||||
// When going back, preserve the global selections
|
||||
setPersistedGlobalSelections(state.globalSelections)
|
||||
onBack()
|
||||
}
|
||||
}}
|
||||
onNext={(validatedData) => {
|
||||
// Go to image upload step with the validated data
|
||||
onNext({
|
||||
type: StepType.imageUpload,
|
||||
data: validatedData,
|
||||
file: uploadedFile!,
|
||||
globalSelections: state.globalSelections
|
||||
});
|
||||
}}
|
||||
isFromScratch={state.isFromScratch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, use the original ValidationStep component
|
||||
return (
|
||||
<ValidationStep
|
||||
initialData={state.data}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
# ValidationStepNew Component
|
||||
|
||||
This is a refactored version of the original ValidationStep component with improved architecture, performance, and maintainability.
|
||||
|
||||
## Overview
|
||||
|
||||
The ValidationStepNew component is designed to replace the original ValidationStep component in the React Spreadsheet Import library. It provides the same functionality but with a more modular and maintainable codebase.
|
||||
|
||||
## Features
|
||||
|
||||
- Field-level validation (required, regex, unique)
|
||||
- Row-level validation (supplier, company fields)
|
||||
- UPC validation with API integration
|
||||
- Template management (saving, loading, applying)
|
||||
- Filtering and sorting capabilities
|
||||
- Error display and management
|
||||
- Special field handling (input, multi-input, select, checkbox)
|
||||
|
||||
## Usage
|
||||
|
||||
To use the new ValidationStepNew component, select the "Use new validation component" checkbox in the MatchColumnsStep. This will route you to the new implementation instead of the original one.
|
||||
|
||||
## Architecture
|
||||
|
||||
The component is structured as follows:
|
||||
|
||||
- **ValidationContainer**: Main container component that coordinates all subcomponents
|
||||
- **ValidationTable**: Handles data display, filtering, and column configuration
|
||||
- **ValidationCell**: Cell-level component with specialized rendering based on field type
|
||||
- **ValidationToolbar**: Top toolbar with actions and statistics
|
||||
- **ValidationSidebar**: Contains filters, actions, and other UI controls
|
||||
|
||||
## State Management
|
||||
|
||||
State is managed through custom hooks:
|
||||
|
||||
- **useValidationState**: Main state management hook
|
||||
- **useValidation**: Validation logic
|
||||
- **useTemplates**: Template management
|
||||
- **useFilters**: Filtering logic
|
||||
- **useUpcValidation**: UPC validation
|
||||
|
||||
## Development
|
||||
|
||||
This component is still under development. The goal is to eventually replace the original ValidationStep component completely once all functionality is implemented and tested.
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Some TypeScript errors in the UploadFlow component when integrating with the new component
|
||||
- Not all features from the original component are fully implemented yet
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. Complete implementation of all features from the original component
|
||||
2. Add comprehensive tests
|
||||
3. Improve performance with virtualization for large datasets
|
||||
4. Add more customization options
|
||||
5. Replace the original component completely
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface SaveTemplateDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (company: string, productType: string) => void
|
||||
}
|
||||
|
||||
const SaveTemplateDialog: React.FC<SaveTemplateDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const [company, setCompany] = useState("")
|
||||
const [productType, setProductType] = useState("")
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
setCompany("")
|
||||
setProductType("")
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save as Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the company and product type for this template.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="company" className="text-sm font-medium">
|
||||
Company
|
||||
</label>
|
||||
<Input
|
||||
id="company"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
placeholder="Enter company name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="productType" className="text-sm font-medium">
|
||||
Product Type
|
||||
</label>
|
||||
<Input
|
||||
id="productType"
|
||||
value={productType}
|
||||
onChange={(e) => setProductType(e.target.value)}
|
||||
placeholder="Enter product type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSave(company, productType)
|
||||
onClose()
|
||||
setCompany("")
|
||||
setProductType("")
|
||||
}}
|
||||
disabled={!company || !productType}
|
||||
>
|
||||
Save Template
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SaveTemplateDialog
|
||||
@@ -0,0 +1,331 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Template } from '../hooks/useValidationState'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
interface SearchableTemplateSelectProps {
|
||||
templates: Template[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
getTemplateDisplayText: (templateId: string | null) => string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
defaultBrand?: string;
|
||||
}
|
||||
|
||||
const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
||||
templates,
|
||||
value,
|
||||
onValueChange,
|
||||
getTemplateDisplayText,
|
||||
placeholder = "Select template",
|
||||
className,
|
||||
triggerClassName,
|
||||
defaultBrand,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Set default brand when component mounts or defaultBrand changes
|
||||
useEffect(() => {
|
||||
if (defaultBrand) {
|
||||
setSelectedBrand(defaultBrand);
|
||||
}
|
||||
}, [defaultBrand]);
|
||||
|
||||
// Handle wheel events for scrolling
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
const scrollArea = e.currentTarget;
|
||||
scrollArea.scrollTop += e.deltaY;
|
||||
};
|
||||
|
||||
// Extract unique brands from templates
|
||||
const brands = useMemo(() => {
|
||||
try {
|
||||
if (!templates || templates.length === 0) {
|
||||
console.log('No templates available for brand extraction');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('Extracting brands from templates:', templates);
|
||||
const brandSet = new Set<string>();
|
||||
const brandNames: {id: string, name: string}[] = [];
|
||||
|
||||
templates.forEach(template => {
|
||||
if (!template || !template.company) return;
|
||||
|
||||
const companyId = template.company;
|
||||
if (!brandSet.has(companyId)) {
|
||||
brandSet.add(companyId);
|
||||
|
||||
// Try to get the company name from the template display text
|
||||
try {
|
||||
// Extract company name from the template display text
|
||||
const displayText = getTemplateDisplayText(template.id.toString());
|
||||
const companyName = displayText.split(' - ')[0];
|
||||
brandNames.push({ id: companyId, name: companyName || companyId });
|
||||
} catch (err) {
|
||||
console.error("Error extracting company name:", err);
|
||||
brandNames.push({ id: companyId, name: companyId });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Extracted brands:', brandNames);
|
||||
return brandNames.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch (err) {
|
||||
console.error("Error extracting brands:", err);
|
||||
return [];
|
||||
}
|
||||
}, [templates, getTemplateDisplayText]);
|
||||
|
||||
// Group templates by company for better organization
|
||||
const groupedTemplates = useMemo(() => {
|
||||
try {
|
||||
if (!templates || templates.length === 0) return {};
|
||||
|
||||
const groups: Record<string, Template[]> = {};
|
||||
|
||||
templates.forEach(template => {
|
||||
if (!template) return;
|
||||
|
||||
const companyId = template.company;
|
||||
if (!groups[companyId]) {
|
||||
groups[companyId] = [];
|
||||
}
|
||||
groups[companyId].push(template);
|
||||
});
|
||||
|
||||
return groups;
|
||||
} catch (err) {
|
||||
console.error("Error grouping templates:", err);
|
||||
return {};
|
||||
}
|
||||
}, [templates]);
|
||||
|
||||
// Filter templates based on selected brand and search term
|
||||
const filteredTemplates = useMemo(() => {
|
||||
try {
|
||||
if (!templates || templates.length === 0) return [];
|
||||
|
||||
// First filter by brand if selected
|
||||
let brandFiltered = templates;
|
||||
if (selectedBrand) {
|
||||
brandFiltered = templates.filter(t => t && t.company === selectedBrand);
|
||||
}
|
||||
|
||||
// Then filter by search term if provided
|
||||
if (!searchTerm.trim()) return brandFiltered;
|
||||
|
||||
const lowerSearchTerm = searchTerm.toLowerCase();
|
||||
return brandFiltered.filter(template => {
|
||||
if (!template) return false;
|
||||
try {
|
||||
const displayText = getTemplateDisplayText(template.id.toString());
|
||||
const productType = template.product_type?.toLowerCase() || '';
|
||||
|
||||
// Search in both the display text and product type
|
||||
return displayText.toLowerCase().includes(lowerSearchTerm) ||
|
||||
productType.includes(lowerSearchTerm);
|
||||
} catch (error) {
|
||||
console.error("Error filtering template:", error, template);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error in filteredTemplates:", err);
|
||||
setError("Error filtering templates");
|
||||
return [];
|
||||
}
|
||||
}, [templates, selectedBrand, searchTerm, getTemplateDisplayText]);
|
||||
|
||||
// Handle errors gracefully
|
||||
const getDisplayText = useCallback(() => {
|
||||
try {
|
||||
if (!value) return placeholder;
|
||||
return getTemplateDisplayText(value);
|
||||
} catch (err) {
|
||||
console.error("Error getting template display text:", err);
|
||||
setError("Error displaying template");
|
||||
return placeholder;
|
||||
}
|
||||
}, [getTemplateDisplayText, placeholder, value]);
|
||||
|
||||
// Handle errors in the component
|
||||
if (error) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("w-full justify-between text-destructive", triggerClassName)}
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Error: {error}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Safe render function for CommandItem
|
||||
const renderCommandItem = useCallback((template: Template) => {
|
||||
try {
|
||||
// Get the display text for the template
|
||||
const displayText = getTemplateDisplayText(template.id.toString());
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={template.id}
|
||||
value={template.id.toString()}
|
||||
onSelect={(currentValue) => {
|
||||
try {
|
||||
onValueChange(currentValue);
|
||||
setOpen(false);
|
||||
setSearchTerm("");
|
||||
// Don't reset the brand filter when selecting a template
|
||||
// This allows users to keep filtering by brand
|
||||
} catch (err) {
|
||||
console.error("Error in onSelect:", err);
|
||||
setError("Error selecting template");
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{displayText}</span>
|
||||
{value === template.id.toString() && <Check className="h-4 w-4 ml-2" />}
|
||||
</CommandItem>
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error rendering CommandItem:", err);
|
||||
return null;
|
||||
}
|
||||
}, [onValueChange, value, getTemplateDisplayText]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between", triggerClassName)}
|
||||
>
|
||||
{getDisplayText()}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={cn("w-[300px] p-0", className)}>
|
||||
<Command>
|
||||
<div className="flex flex-col p-2 gap-2">
|
||||
{/* Brand filter dropdown */}
|
||||
{brands.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedBrand || "all"}
|
||||
onValueChange={(value) => {
|
||||
setSelectedBrand(value === "all" ? null : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="All Brands" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Brands</SelectItem>
|
||||
{brands && brands.length > 0 && brands.map(brand => (
|
||||
<SelectItem key={brand.id} value={brand.id}>
|
||||
{brand.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandInput
|
||||
placeholder="Search by product type..."
|
||||
value={searchTerm}
|
||||
onValueChange={(value) => {
|
||||
try {
|
||||
setSearchTerm(value);
|
||||
} catch (err) {
|
||||
console.error("Error in onValueChange:", err);
|
||||
setError("Error searching templates");
|
||||
}
|
||||
}}
|
||||
className="h-8 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<CommandEmpty>
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">No templates found.</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandList>
|
||||
<ScrollArea className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
|
||||
{!searchTerm ? (
|
||||
// When no search term is applied, show templates grouped by company
|
||||
// If a brand is selected, only show that brand's templates
|
||||
selectedBrand ? (
|
||||
<CommandGroup heading={brands.find(b => b.id === selectedBrand)?.name || selectedBrand}>
|
||||
{groupedTemplates[selectedBrand]?.map(template => renderCommandItem(template))}
|
||||
</CommandGroup>
|
||||
) : (
|
||||
// Show all brands and their templates
|
||||
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
|
||||
// Get company name from the brands array
|
||||
const brand = brands.find(b => b.id === companyId);
|
||||
const companyName = brand ? brand.name : companyId;
|
||||
|
||||
return (
|
||||
<CommandGroup key={companyId} heading={companyName}>
|
||||
{companyTemplates.map(template => renderCommandItem(template))}
|
||||
</CommandGroup>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : (
|
||||
// When search term is applied, show filtered results
|
||||
<CommandGroup>
|
||||
{filteredTemplates.map(template => template ? renderCommandItem(template) : null)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchableTemplateSelect;
|
||||
@@ -0,0 +1,212 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import { ChevronsUpDown, Check, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Template } from '../hooks/useValidationState'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface TemplateManagerProps {
|
||||
templates: Template[]
|
||||
selectedTemplateId: string | null
|
||||
onSelectTemplate: (templateId: string | null) => void
|
||||
onSaveTemplate: (name: string, type: string) => void
|
||||
onApplyTemplate: (templateId: string) => void
|
||||
showDialog: boolean
|
||||
onCloseDialog: () => void
|
||||
selectedCount: number
|
||||
}
|
||||
|
||||
const TemplateManager: React.FC<TemplateManagerProps> = ({
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
onSelectTemplate,
|
||||
onSaveTemplate,
|
||||
onApplyTemplate,
|
||||
showDialog,
|
||||
onCloseDialog,
|
||||
selectedCount,
|
||||
}) => {
|
||||
const [templateName, setTemplateName] = useState('')
|
||||
const [templateType, setTemplateType] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Filter templates based on search
|
||||
const filteredTemplates = searchQuery
|
||||
? templates.filter(template =>
|
||||
template.product_type.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.company.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: templates
|
||||
|
||||
const handleSaveTemplate = () => {
|
||||
if (!templateName.trim()) {
|
||||
toast.error('Please enter a template name')
|
||||
return
|
||||
}
|
||||
|
||||
if (!templateType.trim()) {
|
||||
toast.error('Please select a template type')
|
||||
return
|
||||
}
|
||||
|
||||
onSaveTemplate(templateName, templateType)
|
||||
setTemplateName('')
|
||||
setTemplateType('')
|
||||
}
|
||||
|
||||
// Get display text for template
|
||||
const getTemplateDisplayText = (template: Template) => {
|
||||
return `${template.product_type} - ${template.company}`
|
||||
}
|
||||
|
||||
// Find the currently selected template
|
||||
const selectedTemplate = templates.find(t => t.id.toString() === selectedTemplateId)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<label className="text-sm font-medium">Template</label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between w-full"
|
||||
>
|
||||
{selectedTemplate
|
||||
? getTemplateDisplayText(selectedTemplate)
|
||||
: "Select a template"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[350px]">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search templates..."
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No templates found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredTemplates.map((template) => (
|
||||
<CommandItem
|
||||
key={template.id}
|
||||
value={template.id.toString()}
|
||||
onSelect={(value) => {
|
||||
onSelectTemplate(value)
|
||||
setOpen(false)
|
||||
setSearchQuery('')
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedTemplateId === template.id.toString()
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{getTemplateDisplayText(template)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => selectedTemplateId && onApplyTemplate(selectedTemplateId)}
|
||||
disabled={!selectedTemplateId || selectedCount === 0}
|
||||
className="flex-1"
|
||||
>
|
||||
Apply Template
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCloseDialog}
|
||||
disabled={selectedCount === 0}
|
||||
className="flex-1"
|
||||
>
|
||||
Save as Template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template Save Dialog */}
|
||||
<Dialog open={showDialog} onOpenChange={onCloseDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a template from the selected row.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<label className="text-sm font-medium">Company</label>
|
||||
<Input
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
placeholder="Enter company name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<label className="text-sm font-medium">Product Type</label>
|
||||
<Input
|
||||
value={templateType}
|
||||
onChange={(e) => setTemplateType(e.target.value)}
|
||||
placeholder="Enter product type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCloseDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={!templateName.trim() || !templateType.trim()}
|
||||
>
|
||||
Save Template
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplateManager
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { Field } from '../../../types'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AlertCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
import InputCell from './cells/InputCell'
|
||||
import MultiInputCell from './cells/MultiInputCell'
|
||||
import SelectCell from './cells/SelectCell'
|
||||
import CheckboxCell from './cells/CheckboxCell'
|
||||
|
||||
// Define an error object type
|
||||
type ErrorObject = {
|
||||
message: string;
|
||||
level: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ValidationIcon - Renders an appropriate icon based on error level
|
||||
*/
|
||||
const ValidationIcon = ({ error }: { error: ErrorObject }) => {
|
||||
const iconClasses = "h-4 w-4"
|
||||
|
||||
switch(error.level) {
|
||||
case 'error':
|
||||
return <AlertCircle className={cn(iconClasses, "text-destructive")} />
|
||||
case 'warning':
|
||||
return <AlertTriangle className={cn(iconClasses, "text-amber-500")} />
|
||||
case 'info':
|
||||
return <Info className={cn(iconClasses, "text-blue-500")} />
|
||||
default:
|
||||
return <AlertCircle className={iconClasses} />
|
||||
}
|
||||
}
|
||||
|
||||
export interface ValidationCellProps<T extends string> {
|
||||
rowIndex: number
|
||||
field: Field<T>
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
errors: ErrorObject[]
|
||||
isValidatingUpc?: boolean
|
||||
}
|
||||
|
||||
const ValidationCell = <T extends string>({
|
||||
rowIndex,
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
isValidatingUpc = false
|
||||
}: ValidationCellProps<T>) => {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
// Get the most severe error
|
||||
const currentError = errors.length > 0 ? errors[0] : null
|
||||
|
||||
// Determine if field is disabled
|
||||
const isFieldDisabled = field.disabled || false
|
||||
|
||||
// Check if this is a UPC field for validation
|
||||
const isUpcField = field.key === 'upc' ||
|
||||
(field.fieldType as any)?.upcField ||
|
||||
field.key.toString().toLowerCase().includes('upc')
|
||||
|
||||
// Render cell contents based on field type
|
||||
const renderCellContent = () => {
|
||||
// If we're validating UPC, show a spinner
|
||||
if (isValidatingUpc) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full min-h-[32px]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldType = field.fieldType.type
|
||||
|
||||
// Handle different field types
|
||||
switch (fieldType) {
|
||||
case 'input':
|
||||
return (
|
||||
<InputCell<T>
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={() => setIsEditing(true)}
|
||||
onEndEdit={() => setIsEditing(false)}
|
||||
hasErrors={errors.length > 0}
|
||||
isMultiline={(field.fieldType as any).multiline}
|
||||
isPrice={(field.fieldType as any).price}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'multi-input':
|
||||
return (
|
||||
<MultiInputCell<T>
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={() => setIsEditing(true)}
|
||||
onEndEdit={() => setIsEditing(false)}
|
||||
hasErrors={errors.length > 0}
|
||||
separator={(field.fieldType as any).separator || ','}
|
||||
isMultiline={(field.fieldType as any).multiline}
|
||||
isPrice={(field.fieldType as any).price}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<SelectCell<T>
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={() => setIsEditing(true)}
|
||||
onEndEdit={() => setIsEditing(false)}
|
||||
hasErrors={errors.length > 0}
|
||||
options={field.fieldType.type === 'select' ? field.fieldType.options : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'multi-select':
|
||||
return (
|
||||
<MultiInputCell<T>
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={() => setIsEditing(true)}
|
||||
onEndEdit={() => setIsEditing(false)}
|
||||
hasErrors={errors.length > 0}
|
||||
separator={(field.fieldType as any).separator || ','}
|
||||
options={field.fieldType.type === 'multi-select' ? field.fieldType.options : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<CheckboxCell<T>
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasErrors={errors.length > 0}
|
||||
booleanMatches={(field.fieldType as any).booleanMatches}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-2">
|
||||
{String(value || '')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Main cell rendering
|
||||
return (
|
||||
<div className={cn(
|
||||
"relative w-full",
|
||||
isFieldDisabled ? "opacity-70 pointer-events-none" : ""
|
||||
)}>
|
||||
{renderCellContent()}
|
||||
|
||||
{/* Show error icon if there are errors and we're not editing */}
|
||||
{currentError && !isEditing && (
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2">
|
||||
<ValidationIcon error={currentError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ValidationCell
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useValidationState, Props } from '../hooks/useValidationState'
|
||||
import ValidationTable from './ValidationTable'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, X, Plus, Edit3 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useRsi } from '../../../hooks/useRsi'
|
||||
import { ProductSearchDialog } from '@/components/products/ProductSearchDialog'
|
||||
import SearchableTemplateSelect from './SearchableTemplateSelect'
|
||||
|
||||
/**
|
||||
* ValidationContainer component - the main wrapper for the validation step
|
||||
*
|
||||
* This component is responsible for:
|
||||
* - Managing global state using hooks
|
||||
* - Coordinating between subcomponents
|
||||
* - Handling navigation events (next, back)
|
||||
*/
|
||||
const ValidationContainer = <T extends string>({
|
||||
initialData,
|
||||
file,
|
||||
onBack,
|
||||
onNext,
|
||||
isFromScratch
|
||||
}: Props<T>) => {
|
||||
// Use our main validation state hook
|
||||
const validationState = useValidationState<T>({
|
||||
initialData,
|
||||
file,
|
||||
onBack,
|
||||
onNext,
|
||||
isFromScratch
|
||||
})
|
||||
|
||||
const {
|
||||
data,
|
||||
filteredData,
|
||||
isValidating,
|
||||
validationErrors,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
updateRow,
|
||||
hasErrors,
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
applyTemplate,
|
||||
applyTemplateToSelected,
|
||||
getTemplateDisplayText,
|
||||
filters,
|
||||
updateFilters,
|
||||
setTemplateState,
|
||||
templateState,
|
||||
saveTemplate,
|
||||
loadTemplates,
|
||||
setData
|
||||
} = validationState
|
||||
|
||||
const { translations } = useRsi<T>()
|
||||
|
||||
// State for product search dialog
|
||||
const [isProductSearchDialogOpen, setIsProductSearchDialogOpen] = useState(false)
|
||||
|
||||
const handleNext = () => {
|
||||
// Call the onNext callback with the validated data
|
||||
onNext?.(data)
|
||||
}
|
||||
|
||||
// Delete selected rows
|
||||
const deleteSelectedRows = () => {
|
||||
const selectedRowIndexes = Object.keys(rowSelection).map(Number);
|
||||
const newData = data.filter((_, index) => !selectedRowIndexes.includes(index));
|
||||
setData(newData);
|
||||
setRowSelection({});
|
||||
toast.success(
|
||||
selectedRowIndexes.length === 1
|
||||
? "Row deleted"
|
||||
: `${selectedRowIndexes.length} rows deleted`
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex h-[calc(100vh-9.5rem)] flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header section */}
|
||||
<div className="px-8 pt-6">
|
||||
<div className="mb-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
{translations.validationStep.title || "Validate Data"}
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{isFromScratch && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Create a new empty row
|
||||
const newRow = {} as any;
|
||||
// Add __index property with a unique string id
|
||||
newRow.__index = `row-${Date.now()}`;
|
||||
// Add the new row and validate
|
||||
const newData = [...data, newRow];
|
||||
setData(newData);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Row
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsProductSearchDialogOpen(true)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
Create New Template
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={filters.showErrorsOnly}
|
||||
onCheckedChange={(checked) => updateFilters({ showErrorsOnly: checked })}
|
||||
id="filter-errors"
|
||||
/>
|
||||
<label htmlFor="filter-errors" className="text-sm text-muted-foreground">
|
||||
{translations.validationStep.filterSwitchTitle || "Show only rows with errors"}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main table section */}
|
||||
<div className="px-8 pb-6 flex-1 min-h-0">
|
||||
<div className="rounded-md border h-full flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ValidationTable<T>
|
||||
data={filteredData}
|
||||
fields={validationState.fields}
|
||||
updateRow={updateRow}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
validationErrors={validationErrors}
|
||||
isValidatingUpc={validationState.isValidatingUpc}
|
||||
validatingUpcRows={validationState.validatingUpcRows}
|
||||
filters={filters}
|
||||
templates={templates}
|
||||
applyTemplate={applyTemplate}
|
||||
getTemplateDisplayText={getTemplateDisplayText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selection Action Bar - only shown when items are selected */}
|
||||
{Object.keys(rowSelection).length > 0 && (
|
||||
<div className="fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-5 duration-300">
|
||||
<div className="bg-card shadow-xl rounded-lg border border-muted px-4 py-3 flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mr-2 bg-muted items-center flex text-primary pl-2 pr-7 h-[32px] flex-shrink-0 rounded-md text-xs font-medium border border-primary">
|
||||
{Object.keys(rowSelection).length} selected
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRowSelection({})}
|
||||
className="h-8 w-8 -ml-[48px] hover:bg-transparent hover:text-muted-foreground"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
{templates && templates.length > 0 ? (
|
||||
<SearchableTemplateSelect
|
||||
templates={templates}
|
||||
value={selectedTemplateId || ""}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
applyTemplateToSelected(value);
|
||||
}
|
||||
}}
|
||||
getTemplateDisplayText={getTemplateDisplayText}
|
||||
placeholder="Apply template to selected"
|
||||
triggerClassName="w-[220px]"
|
||||
/>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full justify-between" disabled>
|
||||
Loading templates...
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.keys(rowSelection).length === 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsProductSearchDialogOpen(true)}
|
||||
>
|
||||
Save as Template
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={isFromScratch ? "destructive" : "outline"}
|
||||
size="sm"
|
||||
onClick={deleteSelectedRows}
|
||||
>
|
||||
{isFromScratch ? "Delete Row" : translations.validationStep.discardButtonTitle || "Discard Row"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with navigation buttons */}
|
||||
<div className="border-t bg-muted px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{onBack && (
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
{translations.validationStep.backButtonTitle || "Back"}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={hasErrors}
|
||||
onClick={handleNext}
|
||||
>
|
||||
{translations.validationStep.nextButtonTitle || "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Search Dialog */}
|
||||
<ProductSearchDialog
|
||||
isOpen={isProductSearchDialogOpen}
|
||||
onClose={() => setIsProductSearchDialogOpen(false)}
|
||||
onTemplateCreated={loadTemplates}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ValidationContainer
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
createColumnHelper,
|
||||
RowSelectionState
|
||||
} from '@tanstack/react-table'
|
||||
import { Fields, Field } from '../../../types'
|
||||
import { RowData, Template } from '../hooks/useValidationState'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import ValidationCell from './ValidationCell'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { useRsi } from '../../../hooks/useRsi'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import SearchableTemplateSelect from './SearchableTemplateSelect'
|
||||
|
||||
// Define a simple Error type locally to avoid import issues
|
||||
type ErrorType = {
|
||||
message: string;
|
||||
level: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface ValidationTableProps<T extends string> {
|
||||
data: RowData<T>[]
|
||||
fields: Fields<T>
|
||||
rowSelection: RowSelectionState
|
||||
setRowSelection: React.Dispatch<React.SetStateAction<RowSelectionState>>
|
||||
updateRow: (rowIndex: number, key: T, value: any) => void
|
||||
validationErrors: Map<number, Record<string, ErrorType[]>>
|
||||
isValidatingUpc: (rowIndex: number) => boolean
|
||||
validatingUpcRows: number[]
|
||||
filters?: { showErrorsOnly?: boolean }
|
||||
templates: Template[]
|
||||
applyTemplate: (templateId: string, rowIndexes: number[]) => void
|
||||
getTemplateDisplayText: (templateId: string | null) => string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const ValidationTable = <T extends string>({
|
||||
data,
|
||||
fields,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
updateRow,
|
||||
validationErrors,
|
||||
isValidatingUpc,
|
||||
validatingUpcRows,
|
||||
filters,
|
||||
templates,
|
||||
applyTemplate,
|
||||
getTemplateDisplayText,
|
||||
...props
|
||||
}: ValidationTableProps<T>) => {
|
||||
const { translations } = useRsi<T>()
|
||||
const columnHelper = createColumnHelper<RowData<T>>()
|
||||
|
||||
// Build table columns
|
||||
const columns = useMemo(() => {
|
||||
const selectionColumn = columnHelper.display({
|
||||
id: 'selection',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
|
||||
aria-label="Select all rows"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
size: 40,
|
||||
})
|
||||
|
||||
// Add template column
|
||||
const templateColumn = columnHelper.display({
|
||||
id: 'template',
|
||||
header: "Template",
|
||||
cell: ({ row }) => {
|
||||
try {
|
||||
// Only render the component if templates are available
|
||||
if (!templates || templates.length === 0) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full justify-between" disabled>
|
||||
Loading templates...
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchableTemplateSelect
|
||||
templates={templates}
|
||||
value={row.original.__template || ""}
|
||||
onValueChange={(value: string) => {
|
||||
try {
|
||||
// Apply template to this row
|
||||
applyTemplate(value, [row.index]);
|
||||
} catch (error) {
|
||||
console.error("Error applying template in cell:", error);
|
||||
}
|
||||
}}
|
||||
getTemplateDisplayText={getTemplateDisplayText}
|
||||
defaultBrand={row.original.company as string || ""}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error rendering template cell:", error);
|
||||
return (
|
||||
<Button variant="outline" className="w-full text-destructive">
|
||||
Error loading templates
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
size: 200,
|
||||
});
|
||||
|
||||
// Create columns for each field
|
||||
const fieldColumns = fields.map(field => {
|
||||
return columnHelper.accessor(
|
||||
(row: RowData<T>) => row[field.key as keyof typeof row] as any,
|
||||
{
|
||||
id: String(field.key),
|
||||
header: field.label,
|
||||
cell: ({ row, column, getValue }) => {
|
||||
const rowIndex = row.index
|
||||
const key = column.id as T
|
||||
const value = getValue()
|
||||
const errors = validationErrors.get(rowIndex)?.[key] || []
|
||||
|
||||
// Create a properly typed field object
|
||||
const typedField: Field<T> = {
|
||||
label: field.label,
|
||||
key: field.key as T,
|
||||
alternateMatches: field.alternateMatches as string[] | undefined,
|
||||
validations: field.validations as any[] | undefined,
|
||||
fieldType: field.fieldType,
|
||||
example: field.example,
|
||||
width: field.width,
|
||||
disabled: field.disabled,
|
||||
onChange: field.onChange
|
||||
}
|
||||
|
||||
return (
|
||||
<ValidationCell<T>
|
||||
rowIndex={rowIndex}
|
||||
field={typedField}
|
||||
value={value}
|
||||
onChange={(newValue) => updateRow(rowIndex, key, newValue)}
|
||||
errors={errors}
|
||||
isValidatingUpc={isValidatingUpc(rowIndex)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: (field as any).width || (
|
||||
field.fieldType.type === "checkbox" ? 80 :
|
||||
field.fieldType.type === "select" ? 150 :
|
||||
200
|
||||
),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return [selectionColumn, templateColumn, ...fieldColumns]
|
||||
}, [columnHelper, fields, updateRow, validationErrors, isValidatingUpc, templates, applyTemplate, getTemplateDisplayText])
|
||||
|
||||
// Initialize table
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
minWidth: header.getSize(),
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={validatingUpcRows.includes(row.index) ? "opacity-70" : ""}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="p-2 align-top"
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
minWidth: cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{filters?.showErrorsOnly
|
||||
? translations.validationStep.noRowsMessageWhenFiltered || "No rows with errors found."
|
||||
: translations.validationStep.noRowsMessage || "No rows found."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export default ValidationTable
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Field } from '../../../../types'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CheckboxCellProps<T extends string> {
|
||||
field: Field<T>
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
hasErrors?: boolean
|
||||
booleanMatches?: Record<string, boolean>
|
||||
}
|
||||
|
||||
const CheckboxCell = <T extends string>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
hasErrors,
|
||||
booleanMatches = {}
|
||||
}: CheckboxCellProps<T>) => {
|
||||
const [checked, setChecked] = useState(false)
|
||||
|
||||
// Initialize checkbox state
|
||||
useEffect(() => {
|
||||
if (value === undefined || value === null) {
|
||||
setChecked(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
setChecked(value)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle string values using booleanMatches
|
||||
if (typeof value === 'string') {
|
||||
// First try the field's booleanMatches
|
||||
const fieldBooleanMatches = field.fieldType.type === 'checkbox'
|
||||
? field.fieldType.booleanMatches || {}
|
||||
: {}
|
||||
|
||||
// Merge with the provided booleanMatches, with the provided ones taking precedence
|
||||
const allMatches = { ...fieldBooleanMatches, ...booleanMatches }
|
||||
|
||||
// Try to find the value in the matches
|
||||
const matchEntry = Object.entries(allMatches).find(([k]) =>
|
||||
k.toLowerCase() === value.toLowerCase())
|
||||
|
||||
if (matchEntry) {
|
||||
setChecked(matchEntry[1])
|
||||
return
|
||||
}
|
||||
|
||||
// If no match found, use common true/false strings
|
||||
const trueStrings = ['yes', 'true', '1', 'y']
|
||||
const falseStrings = ['no', 'false', '0', 'n']
|
||||
|
||||
if (trueStrings.includes(value.toLowerCase())) {
|
||||
setChecked(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (falseStrings.includes(value.toLowerCase())) {
|
||||
setChecked(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For any other values, try to convert to boolean
|
||||
setChecked(!!value)
|
||||
}, [value, field.fieldType, booleanMatches])
|
||||
|
||||
// Handle checkbox change
|
||||
const handleChange = useCallback((checked: boolean) => {
|
||||
setChecked(checked)
|
||||
onChange(checked)
|
||||
}, [onChange])
|
||||
|
||||
// Add outline even when not in focus
|
||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center justify-center h-10 px-2 py-1 rounded-md",
|
||||
outlineClass,
|
||||
hasErrors ? "bg-red-50 border-destructive" : "border-input"
|
||||
)}>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
className={cn(
|
||||
hasErrors ? "border-destructive" : ""
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CheckboxCell
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Field } from '../../../../types'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface InputCellProps<T extends string> {
|
||||
field: Field<T>
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
onStartEdit?: () => void
|
||||
onEndEdit?: () => void
|
||||
hasErrors?: boolean
|
||||
isMultiline?: boolean
|
||||
isPrice?: boolean
|
||||
}
|
||||
|
||||
const InputCell = <T extends string>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
onStartEdit,
|
||||
onEndEdit,
|
||||
hasErrors,
|
||||
isMultiline = false,
|
||||
isPrice = false
|
||||
}: InputCellProps<T>) => {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
// Initialize input value
|
||||
useEffect(() => {
|
||||
if (value !== undefined && value !== null) {
|
||||
setInputValue(String(value))
|
||||
} else {
|
||||
setInputValue('')
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Handle focus event
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsEditing(true)
|
||||
onStartEdit?.()
|
||||
}, [onStartEdit])
|
||||
|
||||
// Handle blur event
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
|
||||
// Format the value for storage (remove formatting like $ for price)
|
||||
let processedValue = inputValue
|
||||
|
||||
if (isPrice) {
|
||||
// Remove any non-numeric characters except decimal point
|
||||
processedValue = inputValue.replace(/[^\d.]/g, '')
|
||||
|
||||
// Parse as float and format to 2 decimal places to ensure valid number
|
||||
const numValue = parseFloat(processedValue)
|
||||
if (!isNaN(numValue)) {
|
||||
processedValue = numValue.toFixed(2)
|
||||
}
|
||||
}
|
||||
|
||||
onChange(processedValue)
|
||||
onEndEdit?.()
|
||||
}, [inputValue, onChange, onEndEdit, isPrice])
|
||||
|
||||
// Format price value for display
|
||||
const getDisplayValue = useCallback(() => {
|
||||
if (!isPrice || !inputValue) return inputValue
|
||||
|
||||
// Extract numeric part
|
||||
const numericValue = inputValue.replace(/[^\d.]/g, '')
|
||||
|
||||
// Parse as float and format with dollar sign
|
||||
const numValue = parseFloat(numericValue)
|
||||
if (isNaN(numValue)) return inputValue
|
||||
|
||||
return `$${numValue.toFixed(2)}`
|
||||
}, [inputValue, isPrice])
|
||||
|
||||
// Add outline even when not in focus
|
||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{isMultiline ? (
|
||||
<Textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={field.description}
|
||||
className={cn(
|
||||
"min-h-[80px] resize-none",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : ""
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
isEditing ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={field.description}
|
||||
autoFocus
|
||||
className={cn(
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : ""
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleFocus}
|
||||
className={cn(
|
||||
"px-3 py-2 h-10 rounded-md text-sm w-full cursor-text flex items-center",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "border-input"
|
||||
)}
|
||||
>
|
||||
{isPrice ? getDisplayValue() : (inputValue || <span className="text-muted-foreground">{field.description}</span>)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputCell
|
||||
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Field } from '../../../../types'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, ChevronsUpDown, X } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface MultiInputCellProps<T extends string> {
|
||||
field: Field<T>
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
onStartEdit?: () => void
|
||||
onEndEdit?: () => void
|
||||
hasErrors?: boolean
|
||||
separator?: string
|
||||
isMultiline?: boolean
|
||||
isPrice?: boolean
|
||||
options?: readonly { label: string; value: string }[]
|
||||
}
|
||||
|
||||
const MultiInputCell = <T extends string>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
onStartEdit,
|
||||
onEndEdit,
|
||||
hasErrors,
|
||||
separator = ',',
|
||||
isMultiline = false,
|
||||
isPrice = false,
|
||||
options: providedOptions
|
||||
}: MultiInputCellProps<T>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([])
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
// Check if field is a multi-select field
|
||||
const isMultiSelect = field.fieldType.type === 'multi-select'
|
||||
const fieldOptions = isMultiSelect && field.fieldType.options
|
||||
? field.fieldType.options
|
||||
: undefined
|
||||
|
||||
// Convert options to a regular array to avoid issues with readonly arrays
|
||||
const options = providedOptions
|
||||
? Array.from(providedOptions)
|
||||
: (fieldOptions ? Array.from(fieldOptions) : [])
|
||||
|
||||
// Initialize input value and selected values
|
||||
useEffect(() => {
|
||||
if (value !== undefined && value !== null) {
|
||||
setInputValue(String(value))
|
||||
|
||||
// Split the value based on separator for display
|
||||
const valueArray = String(value).split(separator).map(v => v.trim()).filter(Boolean)
|
||||
setSelectedValues(valueArray)
|
||||
} else {
|
||||
setInputValue('')
|
||||
setSelectedValues([])
|
||||
}
|
||||
}, [value, separator])
|
||||
|
||||
// Handle focus
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsEditing(true)
|
||||
onStartEdit?.()
|
||||
}, [onStartEdit])
|
||||
|
||||
// Handle blur
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
|
||||
// Format all values and join with separator
|
||||
let processedValues = selectedValues
|
||||
|
||||
if (isPrice) {
|
||||
// Format all values as prices
|
||||
processedValues = selectedValues.map(val => {
|
||||
const numValue = parseFloat(val.replace(/[^\d.]/g, ''))
|
||||
return !isNaN(numValue) ? numValue.toFixed(2) : val
|
||||
})
|
||||
}
|
||||
|
||||
// Join all values with the separator
|
||||
const joinedValue = processedValues.join(separator + ' ')
|
||||
onChange(joinedValue)
|
||||
onEndEdit?.()
|
||||
}, [selectedValues, onChange, onEndEdit, isPrice, separator])
|
||||
|
||||
// Toggle a value selection
|
||||
const toggleValue = useCallback((value: string) => {
|
||||
setSelectedValues(current => {
|
||||
const isSelected = current.includes(value)
|
||||
|
||||
if (isSelected) {
|
||||
return current.filter(v => v !== value)
|
||||
} else {
|
||||
return [...current, value]
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Remove a selected value
|
||||
const removeValue = useCallback((value: string) => {
|
||||
setSelectedValues(current => current.filter(v => v !== value))
|
||||
}, [])
|
||||
|
||||
// Format display values for price
|
||||
const getDisplayValues = useCallback(() => {
|
||||
if (!isPrice) return selectedValues
|
||||
|
||||
return selectedValues.map(val => {
|
||||
const numValue = parseFloat(val.replace(/[^\d.]/g, ''))
|
||||
return !isNaN(numValue) ? `$${numValue.toFixed(2)}` : val
|
||||
})
|
||||
}, [selectedValues, isPrice])
|
||||
|
||||
// Add outline even when not in focus
|
||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
|
||||
// If we have a multi-select field with options, use command UI
|
||||
if (isMultiSelect && options.length > 0) {
|
||||
// Ensure all options have the expected shape
|
||||
const safeOptions = options.map(option => ({
|
||||
label: option.label || String(option.value),
|
||||
value: String(option.value)
|
||||
}));
|
||||
|
||||
// Make sure selectedValues are all strings
|
||||
const safeSelectedValues = selectedValues.map(String);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal min-h-10",
|
||||
outlineClass,
|
||||
"text-left",
|
||||
hasErrors ? "border-destructive" : ""
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpen(!open)
|
||||
handleFocus()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 max-w-[90%]">
|
||||
{safeSelectedValues.length > 0 ? (
|
||||
safeSelectedValues.map(value => {
|
||||
const option = safeOptions.find(opt => opt.value === value);
|
||||
return (
|
||||
<Badge
|
||||
key={value}
|
||||
variant="secondary"
|
||||
className="mr-1 mb-1"
|
||||
>
|
||||
{option ? option.label : value}
|
||||
<button
|
||||
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
removeValue(value)
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<span className="text-muted-foreground">{field.description || "Select options..."}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search options..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No options found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{safeOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
toggleValue(option.value);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
safeSelectedValues.includes(option.value)
|
||||
? "bg-primary border-primary"
|
||||
: "opacity-50"
|
||||
)}>
|
||||
{safeSelectedValues.includes(option.value) && (
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{option.label}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For standard multi-input without options, use text input
|
||||
return (
|
||||
<div className="w-full">
|
||||
{isMultiline ? (
|
||||
<Textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={`Enter values separated by ${separator}`}
|
||||
className={cn(
|
||||
"min-h-[80px] resize-none",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : ""
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
isEditing ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={`Enter values separated by ${separator}`}
|
||||
autoFocus
|
||||
className={cn(
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : ""
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleFocus}
|
||||
className={cn(
|
||||
"px-3 py-2 h-10 rounded-md text-sm w-full cursor-text flex items-center overflow-hidden",
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "border-input"
|
||||
)}
|
||||
>
|
||||
{inputValue ?
|
||||
<div className="truncate">
|
||||
{isPrice ?
|
||||
getDisplayValues().join(separator + ' ') :
|
||||
inputValue
|
||||
}
|
||||
</div> :
|
||||
<span className="text-muted-foreground">{`Enter values separated by ${separator}`}</span>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MultiInputCell
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Field } from '../../../../types'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type SelectOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface SelectCellProps<T extends string> {
|
||||
field: Field<T>
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
onStartEdit?: () => void
|
||||
onEndEdit?: () => void
|
||||
hasErrors?: boolean
|
||||
options?: readonly SelectOption[]
|
||||
}
|
||||
|
||||
const SelectCell = <T extends string>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
onStartEdit,
|
||||
onEndEdit,
|
||||
hasErrors,
|
||||
options
|
||||
}: SelectCellProps<T>) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Ensure we always have an array of options with the correct shape
|
||||
const fieldType = field.fieldType;
|
||||
const fieldOptions = fieldType &&
|
||||
(fieldType.type === 'select' || fieldType.type === 'multi-select') &&
|
||||
fieldType.options ?
|
||||
fieldType.options :
|
||||
[];
|
||||
|
||||
// Always ensure selectOptions is a valid array with at least a default option
|
||||
const selectOptions = (options || fieldOptions || []).map(option => ({
|
||||
label: option.label || String(option.value),
|
||||
value: String(option.value)
|
||||
}));
|
||||
|
||||
if (selectOptions.length === 0) {
|
||||
// Add a default empty option if we have none
|
||||
selectOptions.push({ label: 'No options available', value: '' });
|
||||
}
|
||||
|
||||
// Get current display value
|
||||
const displayValue = value ?
|
||||
selectOptions.find(option => String(option.value) === String(value))?.label || String(value) :
|
||||
field.description || 'Select...';
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onChange(selectedValue);
|
||||
setOpen(false);
|
||||
if (onEndEdit) onEndEdit();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"border",
|
||||
!value && "text-muted-foreground",
|
||||
hasErrors ? "border-destructive" : ""
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpen(!open)
|
||||
if (onStartEdit) onStartEdit()
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{selectOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
{String(option.value) === String(value) && (
|
||||
<Check className="ml-auto h-4 w-4" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectCell
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import type { Fields } from '../../../types'
|
||||
import { RowData } from './useValidationState'
|
||||
|
||||
export interface FilterState {
|
||||
searchText: string
|
||||
showErrorsOnly: boolean
|
||||
filterField: string | null
|
||||
filterValue: string | null
|
||||
}
|
||||
|
||||
export const useFilters = <T extends string>(
|
||||
data: RowData<T>[],
|
||||
fields: Fields<T>
|
||||
) => {
|
||||
// Filter state
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
searchText: '',
|
||||
showErrorsOnly: false,
|
||||
filterField: null,
|
||||
filterValue: null
|
||||
})
|
||||
|
||||
// Get available filter fields
|
||||
const filterFields = useMemo(() => {
|
||||
return fields.map(field => ({
|
||||
key: field.key,
|
||||
label: field.label
|
||||
}))
|
||||
}, [fields])
|
||||
|
||||
// Get available filter values for the selected field
|
||||
const filterValues = useMemo(() => {
|
||||
if (!filters.filterField) return []
|
||||
|
||||
// Get unique values for the selected field
|
||||
const uniqueValues = new Set<string>()
|
||||
|
||||
data.forEach(row => {
|
||||
const value = row[filters.filterField as keyof typeof row]
|
||||
if (value !== undefined && value !== null) {
|
||||
uniqueValues.add(String(value))
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(uniqueValues).map(value => ({
|
||||
value,
|
||||
label: value
|
||||
}))
|
||||
}, [data, filters.filterField])
|
||||
|
||||
// Update filters
|
||||
const updateFilters = useCallback((newFilters: Partial<FilterState>) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
...newFilters
|
||||
}))
|
||||
}, [])
|
||||
|
||||
// Apply filters to data
|
||||
const applyFilters = useCallback((dataToFilter: RowData<T>[]) => {
|
||||
return dataToFilter.filter(row => {
|
||||
// Filter by search text
|
||||
if (filters.searchText) {
|
||||
const lowerSearchText = filters.searchText.toLowerCase()
|
||||
const matchesSearch = Object.entries(row).some(([key, value]) => {
|
||||
// Skip metadata fields
|
||||
if (key.startsWith('__')) return false
|
||||
|
||||
// Check if the value contains the search text
|
||||
return value !== undefined &&
|
||||
value !== null &&
|
||||
String(value).toLowerCase().includes(lowerSearchText)
|
||||
})
|
||||
|
||||
if (!matchesSearch) return false
|
||||
}
|
||||
|
||||
// Filter by errors
|
||||
if (filters.showErrorsOnly) {
|
||||
const hasErrors = row.__errors && Object.keys(row.__errors).length > 0
|
||||
if (!hasErrors) return false
|
||||
}
|
||||
|
||||
// Filter by field value
|
||||
if (filters.filterField && filters.filterValue) {
|
||||
const fieldValue = row[filters.filterField as keyof typeof row]
|
||||
return fieldValue !== undefined &&
|
||||
fieldValue !== null &&
|
||||
String(fieldValue) === filters.filterValue
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [filters])
|
||||
|
||||
// Reset all filters
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilters({
|
||||
searchText: '',
|
||||
showErrorsOnly: false,
|
||||
filterField: null,
|
||||
filterValue: null
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
filters,
|
||||
filterFields,
|
||||
filterValues,
|
||||
updateFilters,
|
||||
applyFilters,
|
||||
resetFilters
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Data } from '../../../types'
|
||||
import { RowSelectionState } from '@tanstack/react-table'
|
||||
import { Template, RowData, getApiUrl } from './useValidationState'
|
||||
|
||||
interface TemplateState {
|
||||
selectedTemplateId: string | null
|
||||
showSaveTemplateDialog: boolean
|
||||
newTemplateName: string
|
||||
newTemplateType: string
|
||||
}
|
||||
|
||||
export const useTemplates = <T extends string>(
|
||||
data: RowData<T>[],
|
||||
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
|
||||
toast: any,
|
||||
rowSelection: RowSelectionState
|
||||
) => {
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [templateState, setTemplateState] = useState<TemplateState>({
|
||||
selectedTemplateId: null,
|
||||
showSaveTemplateDialog: false,
|
||||
newTemplateName: '',
|
||||
newTemplateType: '',
|
||||
})
|
||||
|
||||
// Load templates from API
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
console.log('Fetching templates...');
|
||||
const response = await fetch(`${getApiUrl()}/templates`)
|
||||
console.log('Templates response status:', response.status);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch templates')
|
||||
|
||||
const templateData = await response.json()
|
||||
console.log('Templates fetched successfully:', templateData);
|
||||
|
||||
// Validate template data
|
||||
const validTemplates = templateData.filter((t: any) =>
|
||||
t && typeof t === 'object' && t.id && t.company && t.product_type
|
||||
);
|
||||
|
||||
if (validTemplates.length !== templateData.length) {
|
||||
console.warn('Some templates were filtered out due to invalid data', {
|
||||
original: templateData.length,
|
||||
valid: validTemplates.length
|
||||
});
|
||||
}
|
||||
|
||||
setTemplates(validTemplates)
|
||||
} catch (error) {
|
||||
console.error('Error loading templates:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load templates',
|
||||
})
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
// Save a new template based on selected rows
|
||||
const saveTemplate = useCallback(async (name: string, type: string) => {
|
||||
try {
|
||||
// Get selected rows
|
||||
const selectedRows = Object.keys(rowSelection)
|
||||
.map(index => data[parseInt(index)])
|
||||
.filter(Boolean)
|
||||
|
||||
if (selectedRows.length === 0) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please select at least one row to create a template',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create template based on selected rows
|
||||
const template: Template = {
|
||||
id: Date.now(), // Temporary ID, will be replaced by server
|
||||
company: selectedRows[0].company as string || '',
|
||||
product_type: type,
|
||||
...selectedRows[0], // Copy all fields from the first selected row
|
||||
}
|
||||
|
||||
// Remove metadata fields
|
||||
delete (template as any).__errors
|
||||
delete (template as any).__meta
|
||||
delete (template as any).__template
|
||||
delete (template as any).__original
|
||||
delete (template as any).__corrected
|
||||
delete (template as any).__changes
|
||||
delete (template as any).__index
|
||||
|
||||
// Send to API
|
||||
const response = await fetch(`${getApiUrl()}/templates`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
company: template.company,
|
||||
product_type: type,
|
||||
...template
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save template')
|
||||
}
|
||||
|
||||
// Reload templates to get the server-generated ID
|
||||
await loadTemplates()
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Template "${name}" saved successfully`,
|
||||
})
|
||||
|
||||
// Reset dialog state
|
||||
setTemplateState(prev => ({
|
||||
...prev,
|
||||
showSaveTemplateDialog: false,
|
||||
newTemplateName: '',
|
||||
newTemplateType: '',
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error saving template:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to save template',
|
||||
})
|
||||
}
|
||||
}, [data, rowSelection, toast, loadTemplates])
|
||||
|
||||
// Apply a template to selected rows
|
||||
const applyTemplate = useCallback((templateId: string, rowIndexes: number[]) => {
|
||||
const template = templates.find(t => t.id.toString() === templateId)
|
||||
|
||||
if (!template) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Template not found',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setData(prevData => {
|
||||
const newData = [...prevData]
|
||||
|
||||
rowIndexes.forEach(index => {
|
||||
if (index >= 0 && index < newData.length) {
|
||||
// Create a new row with template values
|
||||
const updatedRow = { ...newData[index] }
|
||||
|
||||
// Apply template fields (excluding metadata and ID fields)
|
||||
Object.entries(template).forEach(([key, value]) => {
|
||||
if (!['id', 'company', 'product_type', 'created_at', 'updated_at'].includes(key)) {
|
||||
// Handle numeric values that might be stored as strings
|
||||
if (typeof value === 'string' && /^\d+(\.\d+)?$/.test(value)) {
|
||||
// If it's a price field, add the dollar sign
|
||||
if (['msrp', 'cost_each'].includes(key)) {
|
||||
updatedRow[key as keyof typeof updatedRow] = `$${value}` as any;
|
||||
} else {
|
||||
updatedRow[key as keyof typeof updatedRow] = value as any;
|
||||
}
|
||||
}
|
||||
// Special handling for array fields like categories and ship_restrictions
|
||||
else if (key === 'categories' || key === 'ship_restrictions') {
|
||||
if (Array.isArray(value)) {
|
||||
updatedRow[key as keyof typeof updatedRow] = value as any;
|
||||
} else if (typeof value === 'string') {
|
||||
try {
|
||||
// Try to parse as JSON if it's a JSON string
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
const parsed = JSON.parse(value);
|
||||
updatedRow[key as keyof typeof updatedRow] = parsed as any;
|
||||
}
|
||||
// Otherwise, it might be a PostgreSQL array format like {val1,val2}
|
||||
else if (value.startsWith('{') && value.endsWith('}')) {
|
||||
const parsed = value.slice(1, -1).split(',');
|
||||
updatedRow[key as keyof typeof updatedRow] = parsed as any;
|
||||
}
|
||||
// If it's a single value, wrap it in an array
|
||||
else {
|
||||
updatedRow[key as keyof typeof updatedRow] = [value] as any;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing ${key}:`, error);
|
||||
// If parsing fails, use as-is
|
||||
updatedRow[key as keyof typeof updatedRow] = value as any;
|
||||
}
|
||||
} else {
|
||||
updatedRow[key as keyof typeof updatedRow] = value as any;
|
||||
}
|
||||
} else {
|
||||
updatedRow[key as keyof typeof updatedRow] = value as any;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Mark the row as using this template
|
||||
updatedRow.__template = templateId
|
||||
|
||||
// Update the row in the data array
|
||||
newData[index] = updatedRow
|
||||
}
|
||||
})
|
||||
|
||||
return newData
|
||||
})
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Template applied to ${rowIndexes.length} row(s)`,
|
||||
})
|
||||
}, [templates, setData, toast])
|
||||
|
||||
// Get display text for a template
|
||||
const getTemplateDisplayText = useCallback((templateId: string | null) => {
|
||||
if (!templateId) return 'Select a template'
|
||||
|
||||
const template = templates.find(t => t.id.toString() === templateId)
|
||||
return template
|
||||
? `${template.company} - ${template.product_type}`
|
||||
: 'Unknown template'
|
||||
}, [templates])
|
||||
|
||||
// Load templates on component mount and set up refresh event listener
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
|
||||
// Add event listener for template refresh
|
||||
const handleRefreshTemplates = () => {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
window.addEventListener('refresh-templates', handleRefreshTemplates)
|
||||
|
||||
// Clean up event listener
|
||||
return () => {
|
||||
window.removeEventListener('refresh-templates', handleRefreshTemplates)
|
||||
}
|
||||
}, [loadTemplates])
|
||||
|
||||
return {
|
||||
templates,
|
||||
selectedTemplateId: templateState.selectedTemplateId,
|
||||
showSaveTemplateDialog: templateState.showSaveTemplateDialog,
|
||||
newTemplateName: templateState.newTemplateName,
|
||||
newTemplateType: templateState.newTemplateType,
|
||||
setTemplateState,
|
||||
loadTemplates,
|
||||
saveTemplate,
|
||||
applyTemplate,
|
||||
getTemplateDisplayText,
|
||||
// Helper method to apply to selected rows
|
||||
applyTemplateToSelected: (templateId: string) => {
|
||||
const selectedIndexes = Object.keys(rowSelection).map(i => parseInt(i))
|
||||
applyTemplate(templateId, selectedIndexes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
interface UpcValidationResult {
|
||||
error?: boolean
|
||||
message?: string
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
export const useUpcValidation = () => {
|
||||
const [validatingUpcRows, setValidatingUpcRows] = useState<number[]>([])
|
||||
|
||||
// Mock API call for UPC validation
|
||||
// In a real implementation, you would call an actual API
|
||||
const mockUpcValidationApi = async (upcValue: string): Promise<UpcValidationResult> => {
|
||||
// Simulate API call delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Validate UPC format
|
||||
if (!/^\d{12,14}$/.test(upcValue)) {
|
||||
return {
|
||||
error: true,
|
||||
message: 'Invalid UPC format. UPC should be 12-14 digits.'
|
||||
}
|
||||
}
|
||||
|
||||
// Mock successful validation
|
||||
// In a real implementation, this would return data from the API
|
||||
return {
|
||||
error: false,
|
||||
data: {
|
||||
item_number: `ITEM-${upcValue.substring(0, 6)}`,
|
||||
// Add any other fields that would be returned by the UPC validation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate a UPC value
|
||||
const validateUpc = useCallback(async (upcValue: string, rowIndex: number): Promise<UpcValidationResult> => {
|
||||
// Add row to validating state
|
||||
setValidatingUpcRows(prev => [...prev, rowIndex])
|
||||
|
||||
try {
|
||||
// Call the UPC validation API (mock for now)
|
||||
const result = await mockUpcValidationApi(upcValue)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Error validating UPC:', error)
|
||||
return {
|
||||
error: true,
|
||||
message: 'Failed to validate UPC'
|
||||
}
|
||||
} finally {
|
||||
// Remove row from validating state
|
||||
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check if a row is currently being validated
|
||||
const isValidatingUpc = useCallback((rowIndex: number): boolean => {
|
||||
return validatingUpcRows.includes(rowIndex)
|
||||
}, [validatingUpcRows])
|
||||
|
||||
return {
|
||||
validateUpc,
|
||||
isValidatingUpc,
|
||||
validatingUpcRows
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { Data, Field, Fields, RowHook, TableHook } from '../../../types'
|
||||
import type { Meta } from '../../ValidationStep/types'
|
||||
import { ErrorSources } from '../../../types'
|
||||
import { addErrorsAndRunHooks } from '../../ValidationStep/utils/dataMutations'
|
||||
import { RowData } from './useValidationState'
|
||||
|
||||
interface ValidationError {
|
||||
message: string
|
||||
level: 'info' | 'warning' | 'error'
|
||||
}
|
||||
|
||||
export const useValidation = <T extends string>(
|
||||
fields: Fields<T>,
|
||||
rowHook?: RowHook<T>,
|
||||
tableHook?: TableHook<T>
|
||||
) => {
|
||||
// Validate a single field
|
||||
const validateField = useCallback((
|
||||
value: any,
|
||||
field: Field<T>
|
||||
): ValidationError[] => {
|
||||
const errors: ValidationError[] = []
|
||||
|
||||
if (!field.validations) return errors
|
||||
|
||||
field.validations.forEach(validation => {
|
||||
switch (validation.rule) {
|
||||
case 'required':
|
||||
if (value === undefined || value === null || value === '') {
|
||||
errors.push({
|
||||
message: validation.errorMessage || 'This field is required',
|
||||
level: validation.level || 'error'
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'unique':
|
||||
// Unique validation happens at table level, not here
|
||||
break
|
||||
|
||||
case 'regex':
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
try {
|
||||
const regex = new RegExp(validation.value, validation.flags)
|
||||
if (!regex.test(String(value))) {
|
||||
errors.push({
|
||||
message: validation.errorMessage,
|
||||
level: validation.level || 'error'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Invalid regex in validation:', error)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
return errors
|
||||
}, [])
|
||||
|
||||
// Validate a single row
|
||||
const validateRow = useCallback(async (
|
||||
row: Data<T>,
|
||||
rowIndex: number,
|
||||
allRows: Data<T>[]
|
||||
): Promise<Meta> => {
|
||||
// Run field-level validations
|
||||
const fieldErrors: Record<string, ValidationError[]> = {}
|
||||
|
||||
fields.forEach(field => {
|
||||
const value = row[field.key]
|
||||
const errors = validateField(value, field)
|
||||
|
||||
if (errors.length > 0) {
|
||||
fieldErrors[field.key] = errors
|
||||
}
|
||||
})
|
||||
|
||||
// Special validation for supplier and company fields
|
||||
if (fields.some(field => field.key === 'supplier' as any) && !row.supplier) {
|
||||
fieldErrors['supplier'] = [{
|
||||
message: 'Supplier is required',
|
||||
level: 'error'
|
||||
}]
|
||||
}
|
||||
|
||||
if (fields.some(field => field.key === 'company' as any) && !row.company) {
|
||||
fieldErrors['company'] = [{
|
||||
message: 'Company is required',
|
||||
level: 'error'
|
||||
}]
|
||||
}
|
||||
|
||||
// Run row hook if provided
|
||||
let rowHookResult: Meta = { __errors: {} }
|
||||
if (rowHook) {
|
||||
try {
|
||||
rowHookResult = await rowHook(row, rowIndex, allRows)
|
||||
} catch (error) {
|
||||
console.error('Error in row hook:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge field errors and row hook errors
|
||||
const mergedErrors: Record<string, ValidationError[]> = { ...fieldErrors }
|
||||
|
||||
if (rowHookResult.__errors) {
|
||||
Object.entries(rowHookResult.__errors).forEach(([key, errors]) => {
|
||||
const errorArray = Array.isArray(errors) ? errors : [errors]
|
||||
mergedErrors[key] = [
|
||||
...(mergedErrors[key] || []),
|
||||
...errorArray
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
__errors: mergedErrors
|
||||
}
|
||||
}, [fields, validateField, rowHook])
|
||||
|
||||
// Validate all data at the table level
|
||||
const validateTable = useCallback(async (data: RowData<T>[]): Promise<Meta[]> => {
|
||||
if (!tableHook) return data.map(() => ({ __errors: {} }))
|
||||
|
||||
try {
|
||||
const tableResults = await tableHook(data)
|
||||
|
||||
// Process table validation results
|
||||
return tableResults.map(result => {
|
||||
// Ensure errors are properly formatted
|
||||
const formattedErrors: Record<string, ValidationError[]> = {}
|
||||
|
||||
if (result.__errors) {
|
||||
Object.entries(result.__errors).forEach(([key, errors]) => {
|
||||
formattedErrors[key] = Array.isArray(errors) ? errors : [errors]
|
||||
})
|
||||
}
|
||||
|
||||
return { __errors: formattedErrors }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in table hook:', error)
|
||||
return data.map(() => ({ __errors: {} }))
|
||||
}
|
||||
}, [tableHook])
|
||||
|
||||
// Validate unique fields across the table
|
||||
const validateUnique = useCallback((data: RowData<T>[]) => {
|
||||
const uniqueErrors: Meta[] = data.map(() => ({ __errors: {} }))
|
||||
|
||||
// Find fields with unique validation
|
||||
const uniqueFields = fields.filter(field =>
|
||||
field.validations?.some(v => v.rule === 'unique')
|
||||
)
|
||||
|
||||
if (uniqueFields.length === 0) {
|
||||
return uniqueErrors
|
||||
}
|
||||
|
||||
// Check each unique field
|
||||
uniqueFields.forEach(field => {
|
||||
const { key } = field
|
||||
const validation = field.validations?.find(v => v.rule === 'unique')
|
||||
const allowEmpty = validation?.allowEmpty ?? false
|
||||
const errorMessage = validation?.errorMessage || `${field.label} must be unique`
|
||||
const level = validation?.level || 'error'
|
||||
|
||||
// Track values for uniqueness check
|
||||
const valueMap = new Map<string, number[]>()
|
||||
|
||||
// Build value map
|
||||
data.forEach((row, rowIndex) => {
|
||||
const value = String(row[key] || '')
|
||||
|
||||
// Skip empty values if allowed
|
||||
if (allowEmpty && (value === '' || value === undefined || value === null)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!valueMap.has(value)) {
|
||||
valueMap.set(value, [rowIndex])
|
||||
} else {
|
||||
valueMap.get(value)?.push(rowIndex)
|
||||
}
|
||||
})
|
||||
|
||||
// Add errors for duplicate values
|
||||
valueMap.forEach((rowIndexes, value) => {
|
||||
if (rowIndexes.length > 1) {
|
||||
// Add error to all duplicate rows
|
||||
rowIndexes.forEach(rowIndex => {
|
||||
const rowErrors = uniqueErrors[rowIndex].__errors || {}
|
||||
|
||||
rowErrors[String(key)] = [
|
||||
...(rowErrors[String(key)] || []),
|
||||
{
|
||||
message: errorMessage,
|
||||
level,
|
||||
source: ErrorSources.Table
|
||||
}
|
||||
]
|
||||
|
||||
uniqueErrors[rowIndex].__errors = rowErrors
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return uniqueErrors
|
||||
}, [fields])
|
||||
|
||||
// Run complete validation
|
||||
const validateData = useCallback(async (data: RowData<T>[]) => {
|
||||
// Step 1: Run field and row validation
|
||||
const rowValidations = await Promise.all(
|
||||
data.map((row, index) => validateRow(row, index, data))
|
||||
)
|
||||
|
||||
// Step 2: Run unique validations
|
||||
const uniqueValidations = validateUnique(data)
|
||||
|
||||
// Step 3: Run table hook
|
||||
const tableValidations = await validateTable(data)
|
||||
|
||||
// Merge all validation results
|
||||
return data.map((row, index) => {
|
||||
const rowValidation = rowValidations[index]
|
||||
const uniqueValidation = uniqueValidations[index]
|
||||
const tableValidation = tableValidations[index]
|
||||
|
||||
// Start with the original data
|
||||
const newRow = { ...row }
|
||||
|
||||
// Combine all errors
|
||||
const combinedErrors = {
|
||||
...(rowValidation.__errors || {}),
|
||||
...(uniqueValidation.__errors || {}),
|
||||
...(tableValidation.__errors || {})
|
||||
}
|
||||
|
||||
newRow.__errors = combinedErrors
|
||||
|
||||
return newRow
|
||||
})
|
||||
}, [validateRow, validateUnique, validateTable])
|
||||
|
||||
return {
|
||||
validateField,
|
||||
validateRow,
|
||||
validateTable,
|
||||
validateUnique,
|
||||
validateData
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useRsi } from '../../../hooks/useRsi'
|
||||
import type { Data, Field, Fields, ErrorSources } from '../../../types'
|
||||
import { RowSelectionState } from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Define ErrorType directly in file to avoid import issues
|
||||
type ErrorType = {
|
||||
message: string;
|
||||
level: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// Define Meta type directly to avoid import issues
|
||||
type Meta = {
|
||||
__index?: string;
|
||||
__errors?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Define the Props interface for ValidationStepNew
|
||||
export interface Props<T extends string> {
|
||||
initialData: RowData<T>[]
|
||||
file: File
|
||||
onBack?: () => void
|
||||
onNext?: (data: RowData<T>[]) => void
|
||||
isFromScratch?: boolean
|
||||
}
|
||||
|
||||
// Extended Data type with meta information
|
||||
export type RowData<T extends string> = Data<T> & {
|
||||
__index?: string;
|
||||
__errors?: Record<string, ErrorType[] | ErrorType>;
|
||||
__template?: string;
|
||||
__original?: Record<string, any>;
|
||||
__corrected?: Record<string, any>;
|
||||
__changes?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// Template interface
|
||||
export interface Template {
|
||||
id: number;
|
||||
company: string;
|
||||
product_type: string;
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
// Props for the useValidationState hook
|
||||
export interface ValidationStateProps<T extends string> extends Props<T> {}
|
||||
|
||||
// Interface for validation results
|
||||
export interface ValidationResult {
|
||||
error?: boolean;
|
||||
message?: string;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Filter state interface
|
||||
export interface FilterState {
|
||||
searchText: string;
|
||||
showErrorsOnly: boolean;
|
||||
filterField: string | null;
|
||||
filterValue: string | null;
|
||||
}
|
||||
|
||||
// Add config at the top of the file
|
||||
// Import the config or access it through window
|
||||
declare global {
|
||||
interface Window {
|
||||
config?: {
|
||||
apiUrl: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use a helper to get API URL consistently
|
||||
export const getApiUrl = () => window.config?.apiUrl || '/api';
|
||||
|
||||
// Main validation state hook
|
||||
export const useValidationState = <T extends string>({
|
||||
initialData,
|
||||
file,
|
||||
onBack,
|
||||
onNext,
|
||||
isFromScratch
|
||||
}: ValidationStateProps<T>) => {
|
||||
const { fields, rowHook, tableHook } = useRsi<T>();
|
||||
// Core data state
|
||||
const [data, setData] = useState<RowData<T>[]>(initialData)
|
||||
|
||||
// Row selection state
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
// Validation state
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
const [validationErrors, setValidationErrors] = useState<Map<number, Record<string, ErrorType[]>>>(new Map())
|
||||
const [rowValidationStatus, setRowValidationStatus] = useState<Map<number, 'pending' | 'validating' | 'validated' | 'error'>>(new Map())
|
||||
|
||||
// Template state
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [templateState, setTemplateState] = useState({
|
||||
selectedTemplateId: null as string | null,
|
||||
showSaveTemplateDialog: false,
|
||||
newTemplateName: '',
|
||||
newTemplateType: '',
|
||||
})
|
||||
|
||||
// Field options state for company names
|
||||
const [fieldOptions, setFieldOptions] = useState<any>(null)
|
||||
|
||||
// Filter state
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
searchText: '',
|
||||
showErrorsOnly: false,
|
||||
filterField: null,
|
||||
filterValue: null
|
||||
})
|
||||
|
||||
// UPC validation state
|
||||
const [validatingUpcRows, setValidatingUpcRows] = useState<number[]>([])
|
||||
|
||||
// Compute filtered data based on current filters
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter(row => {
|
||||
// Filter by search text
|
||||
if (filters.searchText) {
|
||||
const lowerSearchText = filters.searchText.toLowerCase()
|
||||
const matchesSearch = Object.entries(row).some(([key, value]) => {
|
||||
// Skip metadata fields
|
||||
if (key.startsWith('__')) return false
|
||||
|
||||
// Check if the value contains the search text
|
||||
return value !== undefined &&
|
||||
value !== null &&
|
||||
String(value).toLowerCase().includes(lowerSearchText)
|
||||
})
|
||||
|
||||
if (!matchesSearch) return false
|
||||
}
|
||||
|
||||
// Filter by errors
|
||||
if (filters.showErrorsOnly) {
|
||||
const hasErrors = row.__errors && Object.keys(row.__errors).length > 0
|
||||
if (!hasErrors) return false
|
||||
}
|
||||
|
||||
// Filter by field value
|
||||
if (filters.filterField && filters.filterValue) {
|
||||
const fieldValue = row[filters.filterField as keyof typeof row]
|
||||
return fieldValue !== undefined &&
|
||||
fieldValue !== null &&
|
||||
String(fieldValue) === filters.filterValue
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [data, filters])
|
||||
|
||||
// Get filter fields
|
||||
const filterFields = useMemo(() => {
|
||||
return fields.map(field => ({
|
||||
key: String(field.key),
|
||||
label: field.label
|
||||
}))
|
||||
}, [fields])
|
||||
|
||||
// Get filter values for the selected field
|
||||
const filterValues = useMemo(() => {
|
||||
if (!filters.filterField) return []
|
||||
|
||||
// Get unique values for the selected field
|
||||
const uniqueValues = new Set<string>()
|
||||
|
||||
data.forEach(row => {
|
||||
const value = row[filters.filterField as keyof typeof row]
|
||||
if (value !== undefined && value !== null) {
|
||||
uniqueValues.add(String(value))
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(uniqueValues).map(value => ({
|
||||
value,
|
||||
label: value
|
||||
}))
|
||||
}, [data, filters.filterField])
|
||||
|
||||
// Update filters
|
||||
const updateFilters = useCallback((newFilters: Partial<FilterState>) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
...newFilters
|
||||
}))
|
||||
}, [])
|
||||
|
||||
// Reset filters
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilters({
|
||||
searchText: '',
|
||||
showErrorsOnly: false,
|
||||
filterField: null,
|
||||
filterValue: null
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Validate a field
|
||||
const validateField = useCallback((value: any, field: Field<T>): ErrorType[] => {
|
||||
const errors: ErrorType[] = []
|
||||
|
||||
if (!field.validations) return errors
|
||||
|
||||
// Type casting to handle readonly fields
|
||||
const validations = field.validations as Array<{ rule: string; value?: string; flags?: string; errorMessage?: string; level?: string; }>
|
||||
|
||||
for (const validation of validations) {
|
||||
if (validation.rule === 'required') {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
errors.push({
|
||||
message: validation.errorMessage || 'This field is required',
|
||||
level: validation.level || 'error',
|
||||
source: 'row'
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (validation.rule === 'regex') {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
try {
|
||||
const regex = new RegExp(validation.value || '', validation.flags);
|
||||
if (!regex.test(String(value))) {
|
||||
errors.push({
|
||||
message: validation.errorMessage || 'Invalid format',
|
||||
level: validation.level || 'error',
|
||||
source: 'row'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Invalid regex in validation:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add other validation types as needed
|
||||
}
|
||||
|
||||
return errors;
|
||||
}, []);
|
||||
|
||||
// Update a single row's data
|
||||
const updateRow = useCallback((rowIndex: number, key: T, value: any) => {
|
||||
setData(prevData => {
|
||||
const newData = [...prevData]
|
||||
const row = { ...newData[rowIndex] }
|
||||
row[key] = value
|
||||
|
||||
// Mark row as needing validation
|
||||
setRowValidationStatus(prev => {
|
||||
const updated = new Map(prev)
|
||||
updated.set(rowIndex, 'pending')
|
||||
return updated
|
||||
})
|
||||
|
||||
newData[rowIndex] = row as RowData<T>
|
||||
return newData
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Validate a single row - optimized version
|
||||
const validateRow = useCallback(async (rowIndex: number) => {
|
||||
if (!data[rowIndex]) return;
|
||||
|
||||
// Update row status to validating
|
||||
setRowValidationStatus(prev => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(rowIndex, 'validating');
|
||||
return updated;
|
||||
});
|
||||
|
||||
try {
|
||||
// Collect field errors
|
||||
const fieldErrors: Record<string, ErrorType[]> = {};
|
||||
|
||||
// Basic field validations
|
||||
for (let j = 0; j < fields.length; j++) {
|
||||
const field = fields[j];
|
||||
const key = String(field.key);
|
||||
const value = data[rowIndex][key as keyof typeof data[typeof rowIndex]];
|
||||
|
||||
// Skip validation for disabled fields
|
||||
if (field.disabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use type assertion to address readonly fields issue
|
||||
const errors = validateField(value, field as unknown as Field<T>);
|
||||
if (errors.length > 0) {
|
||||
fieldErrors[key] = errors;
|
||||
}
|
||||
}
|
||||
|
||||
// Special validation for supplier and company
|
||||
if (!data[rowIndex].supplier) {
|
||||
fieldErrors['supplier'] = [{
|
||||
message: 'Supplier is required',
|
||||
level: 'error',
|
||||
source: 'row'
|
||||
}];
|
||||
}
|
||||
|
||||
if (!data[rowIndex].company) {
|
||||
fieldErrors['company'] = [{
|
||||
message: 'Company is required',
|
||||
level: 'error',
|
||||
source: 'row'
|
||||
}];
|
||||
}
|
||||
|
||||
// Batch update all state at once to minimize re-renders
|
||||
const newStatus = new Map(rowValidationStatus);
|
||||
newStatus.set(rowIndex, Object.keys(fieldErrors).length > 0 ? 'error' : 'validated');
|
||||
|
||||
const newErrors = new Map(validationErrors);
|
||||
newErrors.set(rowIndex, fieldErrors);
|
||||
|
||||
// Use functional updates to ensure we have the latest state
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
__errors: fieldErrors
|
||||
};
|
||||
return newData;
|
||||
});
|
||||
|
||||
setValidationErrors(newErrors);
|
||||
setRowValidationStatus(newStatus);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error validating row:', error);
|
||||
|
||||
// Update row status to error
|
||||
setRowValidationStatus(prev => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(rowIndex, 'error');
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}, [data, fields, validateField, rowValidationStatus, validationErrors]);
|
||||
|
||||
// Validate UPC for a row
|
||||
const validateUpc = useCallback(async (rowIndex: number, upcValue: string) => {
|
||||
if (!data[rowIndex]) return
|
||||
|
||||
// Add row to validating state
|
||||
setValidatingUpcRows(prev => [...prev, rowIndex])
|
||||
|
||||
try {
|
||||
// Mock UPC validation (replace with actual API call in production)
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Validate UPC format
|
||||
if (!/^\d{12,14}$/.test(upcValue)) {
|
||||
toast.error('Invalid UPC format. UPC should be 12-14 digits.')
|
||||
return
|
||||
}
|
||||
|
||||
// Mock successful validation result
|
||||
const result: ValidationResult = {
|
||||
data: {
|
||||
item_number: `ITEM-${upcValue.substring(0, 6)}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Update row with UPC validation results
|
||||
if (result && !result.error) {
|
||||
setData(prevData => {
|
||||
const newData = [...prevData]
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
...result.data
|
||||
}
|
||||
return newData
|
||||
})
|
||||
|
||||
// Validate the row after UPC update
|
||||
validateRow(rowIndex)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error validating UPC:', error)
|
||||
toast.error('Failed to validate UPC')
|
||||
} finally {
|
||||
// Remove row from validating state
|
||||
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex))
|
||||
}
|
||||
}, [data, validateRow])
|
||||
|
||||
// Validate all rows - optimized version without batching
|
||||
const validateAllRows = useCallback(async () => {
|
||||
if (isValidating) return; // Prevent multiple validation calls
|
||||
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
// Process all rows in a single pass
|
||||
const updatedData = [...data];
|
||||
const updatedErrors = new Map(validationErrors);
|
||||
const updatedStatus = new Map(rowValidationStatus);
|
||||
|
||||
// Mark all rows as validating first (single state update)
|
||||
data.forEach((_, index) => {
|
||||
updatedStatus.set(index, 'validating');
|
||||
});
|
||||
setRowValidationStatus(updatedStatus);
|
||||
|
||||
// Process each row
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const rowErrors: Record<string, ErrorType[]> = {};
|
||||
|
||||
// Basic field validations - use type assertion to fix TypeScript error
|
||||
for (let j = 0; j < fields.length; j++) {
|
||||
const field = fields[j];
|
||||
// Type assertion to handle readonly fields
|
||||
const fieldKey = String(field.key);
|
||||
const value = data[i][fieldKey as keyof typeof data[typeof i]];
|
||||
|
||||
// Skip validation for disabled fields
|
||||
if (field.disabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use type assertion to address readonly fields issue
|
||||
const errors = validateField(value, field as unknown as Field<T>);
|
||||
if (errors.length > 0) {
|
||||
rowErrors[fieldKey] = errors;
|
||||
}
|
||||
}
|
||||
|
||||
// Special validation for supplier and company
|
||||
if (!data[i].supplier) {
|
||||
rowErrors['supplier'] = [{
|
||||
message: 'Supplier is required',
|
||||
level: 'error',
|
||||
source: 'row'
|
||||
}];
|
||||
}
|
||||
|
||||
if (!data[i].company) {
|
||||
rowErrors['company'] = [{
|
||||
message: 'Company is required',
|
||||
level: 'error',
|
||||
source: 'row'
|
||||
}];
|
||||
}
|
||||
|
||||
// Store errors for this row
|
||||
updatedErrors.set(i, rowErrors);
|
||||
updatedStatus.set(i, Object.keys(rowErrors).length > 0 ? 'error' : 'validated');
|
||||
|
||||
// Update row with errors
|
||||
updatedData[i] = {
|
||||
...updatedData[i],
|
||||
__errors: rowErrors
|
||||
};
|
||||
}
|
||||
|
||||
// Batch state updates at the end to avoid multiple re-renders
|
||||
setValidationErrors(updatedErrors);
|
||||
setRowValidationStatus(updatedStatus);
|
||||
setData(updatedData);
|
||||
|
||||
toast.success('Validation complete');
|
||||
} catch (error) {
|
||||
console.error('Error validating all rows:', error);
|
||||
toast.error('Error during validation');
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [data, fields, isValidating, validateField, validationErrors, rowValidationStatus]);
|
||||
|
||||
// Load templates
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
console.log('Fetching templates...');
|
||||
// Fetch templates from the API
|
||||
const response = await fetch(`${getApiUrl()}/templates`)
|
||||
console.log('Templates response status:', response.status);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch templates')
|
||||
|
||||
const templateData = await response.json()
|
||||
console.log('Templates fetched successfully:', templateData);
|
||||
|
||||
// Validate template data
|
||||
const validTemplates = templateData.filter((t: any) =>
|
||||
t && typeof t === 'object' && t.id && t.company && t.product_type
|
||||
);
|
||||
|
||||
if (validTemplates.length !== templateData.length) {
|
||||
console.warn('Some templates were filtered out due to invalid data', {
|
||||
original: templateData.length,
|
||||
valid: validTemplates.length
|
||||
});
|
||||
}
|
||||
|
||||
setTemplates(validTemplates)
|
||||
|
||||
// Fetch field options if not already loaded
|
||||
if (!fieldOptions) {
|
||||
try {
|
||||
const optionsResponse = await fetch(`${getApiUrl()}/import/field-options`);
|
||||
if (optionsResponse.ok) {
|
||||
const optionsData = await optionsResponse.json();
|
||||
console.log('Field options fetched successfully:', optionsData);
|
||||
setFieldOptions(optionsData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching field options:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching templates:', error)
|
||||
toast.error('Failed to load templates')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save a new template
|
||||
const saveTemplate = useCallback(async (name: string, type: string) => {
|
||||
try {
|
||||
// Get selected rows
|
||||
const selectedRowIndex = Number(Object.keys(rowSelection)[0]);
|
||||
const selectedRow = data[selectedRowIndex];
|
||||
|
||||
if (!selectedRow) {
|
||||
toast.error('Please select a row to create a template')
|
||||
return
|
||||
}
|
||||
|
||||
// Extract data for template, removing metadata fields
|
||||
const { __index, __errors, __template, __original, __corrected, __changes, ...templateData } = selectedRow as any;
|
||||
|
||||
// Clean numeric values (remove $ from price fields)
|
||||
const cleanedData: Record<string, any> = {};
|
||||
|
||||
// Process each key-value pair
|
||||
Object.entries(templateData).forEach(([key, value]) => {
|
||||
// Handle numeric values with dollar signs
|
||||
if (typeof value === 'string' && value.includes('$')) {
|
||||
cleanedData[key] = value.replace(/[$,\s]/g, '').trim();
|
||||
}
|
||||
// Handle array values (like categories or ship_restrictions)
|
||||
else if (Array.isArray(value)) {
|
||||
cleanedData[key] = value;
|
||||
}
|
||||
// Handle other values
|
||||
else {
|
||||
cleanedData[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Send the template to the API
|
||||
const response = await fetch(`${getApiUrl()}/templates`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...cleanedData,
|
||||
company: name,
|
||||
product_type: type,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || errorData.details || "Failed to save template");
|
||||
}
|
||||
|
||||
// Get the new template from the response
|
||||
const newTemplate = await response.json();
|
||||
|
||||
// Update the templates list with the new template
|
||||
setTemplates(prev => [...prev, newTemplate]);
|
||||
|
||||
// Update the row to show it's using this template
|
||||
setData(prev => {
|
||||
const newData = [...prev];
|
||||
if (newData[selectedRowIndex]) {
|
||||
newData[selectedRowIndex] = {
|
||||
...newData[selectedRowIndex],
|
||||
__template: newTemplate.id.toString()
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
toast.success(`Template "${name}" saved successfully`)
|
||||
|
||||
// Reset dialog state
|
||||
setTemplateState(prev => ({
|
||||
...prev,
|
||||
showSaveTemplateDialog: false,
|
||||
newTemplateName: '',
|
||||
newTemplateType: '',
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error saving template:', error)
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save template')
|
||||
}
|
||||
}, [data, rowSelection, setData]);
|
||||
|
||||
// Apply a template to selected rows
|
||||
const applyTemplate = useCallback((templateId: string, rowIndexes: number[]) => {
|
||||
const template = templates.find(t => t.id.toString() === templateId)
|
||||
|
||||
if (!template) {
|
||||
toast.error('Template not found')
|
||||
return
|
||||
}
|
||||
|
||||
setData(prevData => {
|
||||
const newData = [...prevData]
|
||||
|
||||
rowIndexes.forEach(index => {
|
||||
if (index >= 0 && index < newData.length) {
|
||||
// Create a new row with template values
|
||||
const updatedRow = { ...newData[index] }
|
||||
|
||||
// Apply template fields (excluding metadata and ID fields)
|
||||
Object.entries(template).forEach(([key, value]) => {
|
||||
if (!['id', '__errors', '__meta', '__template', '__original', '__corrected', '__changes'].includes(key)) {
|
||||
(updatedRow as any)[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
// Mark the row as using this template
|
||||
updatedRow.__template = templateId
|
||||
|
||||
// Update the row in the data array
|
||||
newData[index] = updatedRow
|
||||
}
|
||||
})
|
||||
|
||||
return newData
|
||||
})
|
||||
|
||||
toast.success(`Template applied to ${rowIndexes.length} row(s)`)
|
||||
|
||||
// Validate updated rows
|
||||
rowIndexes.forEach(index => {
|
||||
validateRow(index)
|
||||
})
|
||||
}, [templates, validateRow])
|
||||
|
||||
// Apply a template to selected rows
|
||||
const applyTemplateToSelected = useCallback((templateId: string) => {
|
||||
if (!templateId) return;
|
||||
|
||||
// Update the selected template ID
|
||||
setTemplateState(prev => ({
|
||||
...prev,
|
||||
selectedTemplateId: templateId
|
||||
}));
|
||||
|
||||
// Get selected row indexes
|
||||
const selectedIndexes = Object.keys(rowSelection).map(i => parseInt(i));
|
||||
|
||||
// Apply template to selected rows
|
||||
applyTemplate(templateId, selectedIndexes);
|
||||
}, [rowSelection, applyTemplate]);
|
||||
|
||||
// Helper function to get company name from ID
|
||||
const getCompanyName = useCallback((companyId: string) => {
|
||||
if (!fieldOptions || !fieldOptions.companies) return companyId;
|
||||
try {
|
||||
const company = fieldOptions.companies.find((c: { value: string; label: string }) => c.value === companyId);
|
||||
return company ? company.label : companyId;
|
||||
} catch (error) {
|
||||
console.error("Error getting company name:", error);
|
||||
return companyId;
|
||||
}
|
||||
}, [fieldOptions]);
|
||||
|
||||
// Get display text for a template
|
||||
const getTemplateDisplayText = useCallback((templateId: string | null) => {
|
||||
if (!templateId) return 'Select a template'
|
||||
|
||||
const template = templates.find(t => t.id.toString() === templateId)
|
||||
if (!template) return 'Unknown template'
|
||||
|
||||
try {
|
||||
const companyId = template.company || "";
|
||||
const productType = template.product_type || "Unknown Type";
|
||||
const companyName = getCompanyName(companyId);
|
||||
return `${companyName} - ${productType}`;
|
||||
} catch (error) {
|
||||
console.error("Error formatting template display text:", error, template);
|
||||
return "Error displaying template";
|
||||
}
|
||||
}, [templates, getCompanyName])
|
||||
|
||||
// Check if there are any errors
|
||||
const hasErrors = useMemo(() => {
|
||||
for (const [_, status] of rowValidationStatus.entries()) {
|
||||
if (status === 'error') return true
|
||||
}
|
||||
return false
|
||||
}, [rowValidationStatus])
|
||||
|
||||
// Check if a row is currently being validated for UPC
|
||||
const isValidatingUpc = useCallback((rowIndex: number): boolean => {
|
||||
return validatingUpcRows.includes(rowIndex)
|
||||
}, [validatingUpcRows])
|
||||
|
||||
// Initialize data with better performance
|
||||
useEffect(() => {
|
||||
// Only run this once on mount
|
||||
const initializeData = () => {
|
||||
// Initialize row validation status map in a single operation
|
||||
const initialStatus = new Map();
|
||||
data.forEach((_, index) => {
|
||||
initialStatus.set(index, 'pending');
|
||||
});
|
||||
setRowValidationStatus(initialStatus);
|
||||
|
||||
// Validate all rows automatically
|
||||
data.forEach((_, index) => {
|
||||
validateRow(index);
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize data and load templates
|
||||
initializeData();
|
||||
loadTemplates();
|
||||
}, [loadTemplates]); // Add loadTemplates to dependencies
|
||||
|
||||
// Add a refreshTemplates function
|
||||
const refreshTemplates = useCallback(() => {
|
||||
loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
return {
|
||||
// Data
|
||||
data,
|
||||
setData,
|
||||
filteredData,
|
||||
|
||||
// Validation
|
||||
isValidating,
|
||||
validationErrors,
|
||||
rowValidationStatus,
|
||||
validateRow,
|
||||
validateUpc,
|
||||
hasErrors,
|
||||
|
||||
// Row selection
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
|
||||
// Row manipulation
|
||||
updateRow,
|
||||
|
||||
// Templates
|
||||
templates,
|
||||
selectedTemplateId: templateState.selectedTemplateId,
|
||||
showSaveTemplateDialog: templateState.showSaveTemplateDialog,
|
||||
newTemplateName: templateState.newTemplateName,
|
||||
newTemplateType: templateState.newTemplateType,
|
||||
setTemplateState,
|
||||
templateState,
|
||||
loadTemplates,
|
||||
saveTemplate,
|
||||
applyTemplate,
|
||||
applyTemplateToSelected,
|
||||
getTemplateDisplayText,
|
||||
refreshTemplates,
|
||||
|
||||
// Filters
|
||||
filters,
|
||||
filterFields,
|
||||
filterValues,
|
||||
updateFilters,
|
||||
resetFilters,
|
||||
|
||||
// UPC validation
|
||||
validatingUpcRows,
|
||||
isValidatingUpc,
|
||||
|
||||
// Fields reference
|
||||
fields
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import ValidationContainer from './components/ValidationContainer'
|
||||
import { Props } from './hooks/useValidationState'
|
||||
|
||||
/**
|
||||
* ValidationStepNew component - modern implementation of the validation step
|
||||
*
|
||||
* This component is a refactored version of the original ValidationStep component
|
||||
* with improved architecture, performance and maintainability.
|
||||
*/
|
||||
export const ValidationStepNew = <T extends string>({
|
||||
initialData,
|
||||
file,
|
||||
onBack,
|
||||
onNext,
|
||||
isFromScratch
|
||||
}: Props<T>) => {
|
||||
return (
|
||||
<ValidationContainer<T>
|
||||
initialData={initialData}
|
||||
file={file}
|
||||
onBack={onBack}
|
||||
onNext={onNext}
|
||||
isFromScratch={isFromScratch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ValidationStepNew
|
||||
@@ -0,0 +1,24 @@
|
||||
import { InfoWithSource, ErrorLevel } from "../../types"
|
||||
|
||||
// Define our own Error type that's compatible with the original
|
||||
export interface ErrorType {
|
||||
message: string;
|
||||
level: ErrorLevel;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// Export a namespace to make it accessible at runtime
|
||||
export const ErrorTypes = {
|
||||
createError: (message: string, level: ErrorLevel = 'error', source: string = 'row'): ErrorType => {
|
||||
return { message, level, source };
|
||||
}
|
||||
};
|
||||
|
||||
// Type for a collection of errors
|
||||
export interface Errors { [id: string]: ErrorType[] }
|
||||
|
||||
// Make our Meta type match the original for compatibility
|
||||
export interface Meta {
|
||||
__index?: string;
|
||||
__errors?: Record<string, ErrorType[] | ErrorType | InfoWithSource | InfoWithSource[] | null>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { InfoWithSource } from '../../../types'
|
||||
import { ErrorType, ErrorTypes } from '../types/index'
|
||||
|
||||
/**
|
||||
* Converts an InfoWithSource or similar error object to our Error type
|
||||
* @param error The error object to convert
|
||||
* @returns Our standardized Error object
|
||||
*/
|
||||
export const convertToError = (error: any): ErrorType => {
|
||||
return {
|
||||
message: typeof error.message === 'string' ? error.message : String(error.message || ''),
|
||||
level: error.level || 'error',
|
||||
source: error.source || 'row'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely convert an error or array of errors to our Error[] format
|
||||
* @param errors The error or array of errors to convert
|
||||
* @returns Array of our Error objects
|
||||
*/
|
||||
export const convertToErrorArray = (errors: any): ErrorType[] => {
|
||||
if (Array.isArray(errors)) {
|
||||
return errors.map(convertToError)
|
||||
}
|
||||
return [convertToError(errors)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a record of errors to our standardized format
|
||||
* @param errorRecord Record with string keys and error values
|
||||
* @returns Standardized error record
|
||||
*/
|
||||
export const convertErrorRecord = (errorRecord: Record<string, any>): Record<string, ErrorType[]> => {
|
||||
const result: Record<string, ErrorType[]> = {}
|
||||
|
||||
Object.entries(errorRecord).forEach(([key, errors]) => {
|
||||
result[key] = convertToErrorArray(errors)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/**
|
||||
* Placeholder for validating UPC codes
|
||||
* @param upcValue UPC value to validate
|
||||
* @returns Validation result
|
||||
*/
|
||||
export const validateUpc = async (upcValue: string): Promise<any> => {
|
||||
// Basic validation - UPC should be 12-14 digits
|
||||
if (!/^\d{12,14}$/.test(upcValue)) {
|
||||
toast.error('Invalid UPC format. UPC should be 12-14 digits.')
|
||||
return {
|
||||
error: true,
|
||||
message: 'Invalid UPC format'
|
||||
}
|
||||
}
|
||||
|
||||
// In a real implementation, call an API to validate the UPC
|
||||
// For now, just return a successful result
|
||||
return {
|
||||
error: false,
|
||||
data: {
|
||||
// Mock data that would be returned from the API
|
||||
item_number: `ITEM-${upcValue.substring(0, 6)}`,
|
||||
sku: `SKU-${upcValue.substring(0, 4)}`,
|
||||
description: `Sample Product ${upcValue.substring(0, 4)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an item number for a UPC
|
||||
* @param upcValue UPC value
|
||||
* @returns Generated item number
|
||||
*/
|
||||
export const generateItemNumber = (upcValue: string): string => {
|
||||
// Simple item number generation logic
|
||||
return `ITEM-${upcValue.substring(0, 6)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for handling UPC validation process
|
||||
* @param upcValue UPC value to validate
|
||||
* @param rowIndex Row index being validated
|
||||
* @param updateRow Function to update row data
|
||||
*/
|
||||
export const handleUpcValidation = async (
|
||||
upcValue: string,
|
||||
rowIndex: number,
|
||||
updateRow: (rowIndex: number, key: string, value: any) => void
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Validate the UPC
|
||||
const result = await validateUpc(upcValue)
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.message || 'UPC validation failed')
|
||||
return
|
||||
}
|
||||
|
||||
// Update row with the validation result data
|
||||
if (result.data) {
|
||||
// Update each field returned from the API
|
||||
Object.entries(result.data).forEach(([key, value]) => {
|
||||
updateRow(rowIndex, key, value)
|
||||
})
|
||||
|
||||
toast.success('UPC validated successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error validating UPC:', error)
|
||||
toast.error('Failed to validate UPC')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Helper functions for validation that ensure proper error objects
|
||||
*/
|
||||
|
||||
// Create a standard error object
|
||||
export const createError = (message, level = 'error', source = 'row') => {
|
||||
return { message, level, source };
|
||||
};
|
||||
|
||||
// Convert any error to standard format
|
||||
export const convertError = (error) => {
|
||||
if (!error) return createError('Unknown error');
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return createError(error);
|
||||
}
|
||||
|
||||
return {
|
||||
message: error.message || 'Unknown error',
|
||||
level: error.level || 'error',
|
||||
source: error.source || 'row'
|
||||
};
|
||||
};
|
||||
|
||||
// Convert array of errors or single error to array
|
||||
export const convertToErrorArray = (errors) => {
|
||||
if (Array.isArray(errors)) {
|
||||
return errors.map(convertError);
|
||||
}
|
||||
return [convertError(errors)];
|
||||
};
|
||||
|
||||
// Convert a record of errors to standard format
|
||||
export const convertErrorRecord = (errorRecord) => {
|
||||
const result = {};
|
||||
|
||||
if (!errorRecord) return result;
|
||||
|
||||
Object.entries(errorRecord).forEach(([key, errors]) => {
|
||||
result[key] = convertToErrorArray(errors);
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Field, Data, ErrorSources } from '../../../types'
|
||||
import { ErrorType, ErrorTypes } from '../types/index'
|
||||
|
||||
/**
|
||||
* Formats a price value to a consistent format
|
||||
* @param value The price value to format
|
||||
* @returns The formatted price string
|
||||
*/
|
||||
export const formatPrice = (value: string | number): string => {
|
||||
if (!value) return ''
|
||||
|
||||
// Convert to string and clean
|
||||
const numericValue = String(value).replace(/[^\d.]/g, '')
|
||||
|
||||
// Parse the number
|
||||
const number = parseFloat(numericValue)
|
||||
if (isNaN(number)) return ''
|
||||
|
||||
// Format as currency
|
||||
return number.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a field is a price field
|
||||
* @param field The field to check
|
||||
* @returns True if the field is a price field
|
||||
*/
|
||||
export const isPriceField = (field: Field<any>): boolean => {
|
||||
return !!field.fieldType.price
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a field is a multi-input type
|
||||
* @param fieldType The field type to check
|
||||
* @returns True if the field is a multi-input type
|
||||
*/
|
||||
export const isMultiInputType = (fieldType: any): boolean => {
|
||||
return fieldType.type === 'multi-input'
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the separator for multi-input fields
|
||||
* @param fieldType The field type
|
||||
* @returns The separator string
|
||||
*/
|
||||
export const getMultiInputSeparator = (fieldType: any): string => {
|
||||
if (isMultiInputType(fieldType) && fieldType.separator) {
|
||||
return fieldType.separator
|
||||
}
|
||||
return ','
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs regex validation on a value
|
||||
* @param value The value to validate
|
||||
* @param regex The regex pattern
|
||||
* @param flags Regex flags
|
||||
* @returns True if validation passes
|
||||
*/
|
||||
export const validateRegex = (value: any, regex: string, flags?: string): boolean => {
|
||||
if (value === undefined || value === null || value === '') return true
|
||||
|
||||
try {
|
||||
const regexObj = new RegExp(regex, flags)
|
||||
return regexObj.test(String(value))
|
||||
} catch (error) {
|
||||
console.error('Invalid regex in validation:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a validation error object
|
||||
* @param message Error message
|
||||
* @param level Error level
|
||||
* @param source Error source
|
||||
* @returns Error object
|
||||
*/
|
||||
export const createError = (
|
||||
message: string,
|
||||
level: 'info' | 'warning' | 'error' = 'error',
|
||||
source: ErrorSources = ErrorSources.Row
|
||||
): ErrorType => {
|
||||
return {
|
||||
message,
|
||||
level,
|
||||
source
|
||||
} as ErrorType
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a display value based on field type
|
||||
* @param value The value to format
|
||||
* @param field The field definition
|
||||
* @returns Formatted display value
|
||||
*/
|
||||
export const getDisplayValue = (value: any, field: Field<any>): string => {
|
||||
if (value === undefined || value === null) return ''
|
||||
|
||||
// Handle price fields
|
||||
if (isPriceField(field)) {
|
||||
return formatPrice(value)
|
||||
}
|
||||
|
||||
// Handle multi-input fields
|
||||
if (isMultiInputType(field.fieldType)) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(`${getMultiInputSeparator(field.fieldType)} `)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle boolean values
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No'
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates supplier and company fields
|
||||
* @param row The data row
|
||||
* @returns Object with errors for invalid fields
|
||||
*/
|
||||
export const validateSpecialFields = <T extends string>(row: Data<T>): Record<string, ErrorType[]> => {
|
||||
const errors: Record<string, ErrorType[]> = {}
|
||||
|
||||
// Validate supplier field
|
||||
if (!row.supplier) {
|
||||
errors['supplier'] = [{
|
||||
message: 'Supplier is required',
|
||||
level: 'error',
|
||||
source: ErrorSources.Row
|
||||
} as ErrorType]
|
||||
}
|
||||
|
||||
// Validate company field
|
||||
if (!row.company) {
|
||||
errors['company'] = [{
|
||||
message: 'Company is required',
|
||||
level: 'error',
|
||||
source: ErrorSources.Row
|
||||
} as ErrorType]
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges multiple error objects
|
||||
* @param errors Array of error objects to merge
|
||||
* @returns Merged error object
|
||||
*/
|
||||
export const mergeErrors = (...errors: Record<string, ErrorType[]>[]): Record<string, ErrorType[]> => {
|
||||
const merged: Record<string, ErrorType[]> = {}
|
||||
|
||||
errors.forEach(errorObj => {
|
||||
if (!errorObj) return
|
||||
|
||||
Object.entries(errorObj).forEach(([key, errs]) => {
|
||||
if (!merged[key]) {
|
||||
merged[key] = []
|
||||
}
|
||||
|
||||
merged[key] = [
|
||||
...merged[key],
|
||||
...(Array.isArray(errs) ? errs : [errs as ErrorType])
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
@@ -69,6 +69,17 @@ const BASE_IMPORT_FIELDS = [
|
||||
{ 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",
|
||||
@@ -106,17 +117,6 @@ const BASE_IMPORT_FIELDS = [
|
||||
{ rule: "unique", errorMessage: "Must be unique", 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: "MSRP",
|
||||
key: "msrp",
|
||||
|
||||
Reference in New Issue
Block a user