Rewrite validation step part 2

This commit is contained in:
2026-01-18 16:26:34 -05:00
parent 262890a7be
commit 54ddaa0492
13 changed files with 950 additions and 282 deletions

View File

@@ -57,7 +57,7 @@ export const BASE_IMPORT_FIELDS = [
description: "Universal Product Code/Barcode",
alternateMatches: ["barcode", "bar code", "jan", "ean", "upc code"],
fieldType: { type: "input" },
width: 145,
width: 165,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" },

View File

@@ -2,10 +2,11 @@
* CopyDownBanner Component
*
* Shows instruction banner when copy-down mode is active.
* Positions above the source cell for better context.
* Memoized with minimal subscriptions for performance.
*/
import { memo } from 'react';
import { memo, useEffect, useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
import { useValidationStore } from '../store/validationStore';
@@ -16,9 +17,67 @@ import { useIsCopyDownActive } from '../store/selectors';
*
* PERFORMANCE: Only subscribes to copyDownMode.isActive boolean.
* Uses getState() for the cancel action to avoid additional subscriptions.
*
* POSITIONING: Uses the source row index to find the row element and position
* the banner above it for better visual context.
*/
export const CopyDownBanner = memo(() => {
const isActive = useIsCopyDownActive();
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const bannerRef = useRef<HTMLDivElement>(null);
// Update position based on source cell
useEffect(() => {
if (!isActive) {
setPosition(null);
return;
}
const updatePosition = () => {
const { copyDownMode } = useValidationStore.getState();
if (copyDownMode.sourceRowIndex === null || !copyDownMode.sourceFieldKey) return;
// Find the source cell by row index and field key
const rowElement = document.querySelector(
`[data-row-index="${copyDownMode.sourceRowIndex}"]`
) as HTMLElement | null;
if (!rowElement) return;
const cellElement = rowElement.querySelector(
`[data-cell-field="${copyDownMode.sourceFieldKey}"]`
) as HTMLElement | null;
if (!cellElement) return;
// Get the table container (parent of scroll container)
const tableContainer = rowElement.closest('.relative') as HTMLElement | null;
if (!tableContainer) return;
const tableRect = tableContainer.getBoundingClientRect();
const cellRect = cellElement.getBoundingClientRect();
// Calculate position relative to the table container
// Position banner centered horizontally on the cell, above it
const topPosition = cellRect.top - tableRect.top - 55; // 55px above the cell (enough to not cover it)
const leftPosition = cellRect.left - tableRect.left + cellRect.width / 2;
setPosition({
top: Math.max(topPosition, 8), // Minimum 8px from top
left: leftPosition,
});
};
// Initial position (with small delay to ensure DOM is ready)
setTimeout(updatePosition, 0);
// Update on scroll
const scrollContainer = document.querySelector('.overflow-auto');
if (scrollContainer) {
scrollContainer.addEventListener('scroll', updatePosition);
return () => scrollContainer.removeEventListener('scroll', updatePosition);
}
}, [isActive]);
if (!isActive) return null;
@@ -27,8 +86,15 @@ export const CopyDownBanner = memo(() => {
};
return (
<div className="sticky top-0 z-30 h-0 overflow-visible pointer-events-none">
<div className="absolute left-1/2 -translate-x-1/2 top-2 pointer-events-auto">
<div
className="absolute z-30 pointer-events-none"
style={{
top: position?.top ?? 8,
left: position?.left ?? '50%',
transform: 'translateX(-50%)',
}}
>
<div ref={bannerRef} className="pointer-events-auto">
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-xl shadow-lg px-4 py-2.5 flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">

View File

@@ -10,9 +10,19 @@
* - Uses getState() for action-time data access
*/
import { memo, useCallback } from 'react';
import { memo, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { X, Trash2, Save, FileDown } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { X, Trash2, Save } from 'lucide-react';
import { useValidationStore } from '../store/validationStore';
import {
useSelectedRowCount,
@@ -37,6 +47,7 @@ export const FloatingSelectionBar = memo(() => {
const hasSingleRow = useHasSingleRowSelected();
const templates = useTemplates();
const templatesLoading = useTemplatesLoading();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { applyTemplateToSelected, getTemplateDisplayText } = useTemplateManagement();
@@ -62,20 +73,33 @@ export const FloatingSelectionBar = memo(() => {
return;
}
// Confirm deletion for multiple rows
// Show confirmation dialog for multiple rows
if (indicesToDelete.length > 1) {
const confirmed = window.confirm(
`Are you sure you want to delete ${indicesToDelete.length} rows?`
);
if (!confirmed) return;
setDeleteDialogOpen(true);
return;
}
// For single row, delete directly
deleteRows(indicesToDelete);
toast.success(
indicesToDelete.length === 1
? 'Row deleted'
: `${indicesToDelete.length} rows deleted`
);
toast.success('Row deleted');
}, []);
// Confirm deletion callback
const handleConfirmDelete = useCallback(() => {
const { rows, selectedRows, deleteRows } = useValidationStore.getState();
const indicesToDelete: number[] = [];
rows.forEach((row, index) => {
if (selectedRows.has(row.__index)) {
indicesToDelete.push(index);
}
});
if (indicesToDelete.length > 0) {
deleteRows(indicesToDelete);
toast.success(`${indicesToDelete.length} rows deleted`);
}
setDeleteDialogOpen(false);
}, []);
// Save as template - opens dialog with row data
@@ -123,74 +147,97 @@ export const FloatingSelectionBar = memo(() => {
if (selectedCount === 0) return null;
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-200">
<div className="bg-background border border-border rounded-xl shadow-xl px-4 py-3 flex items-center gap-4">
{/* Selection count badge */}
<div className="flex items-center gap-2">
<div className="bg-primary text-primary-foreground text-sm font-medium px-2.5 py-1 rounded-md">
{selectedCount} selected
<>
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-200">
<div className="bg-background border border-border rounded-xl shadow-xl px-4 py-3 flex items-center gap-4">
{/* Selection count badge */}
<div className="flex items-center gap-2">
<div className="bg-primary text-primary-foreground text-sm font-medium px-2.5 py-1 rounded-md">
{selectedCount} selected
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClearSelection}
className="h-8 w-8 p-0"
title="Clear selection"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Divider */}
<div className="h-8 w-px bg-border" />
{/* Apply template to selected */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Apply template:</span>
<SearchableTemplateSelect
templates={templates}
value=""
onValueChange={handleTemplateChange}
getTemplateDisplayText={getTemplateDisplayText}
placeholder="Select template"
triggerClassName="w-[200px]"
disabled={templatesLoading}
/>
</div>
{/* Divider */}
<div className="h-8 w-px bg-border" />
{/* Save as template - only when single row selected */}
{hasSingleRow && (
<>
<Button
variant="outline"
size="sm"
onClick={handleSaveAsTemplate}
className="gap-2"
>
<Save className="h-4 w-4" />
Save as Template
</Button>
{/* Divider */}
<div className="h-8 w-px bg-border" />
</>
)}
{/* Delete selected */}
<Button
variant="ghost"
variant="destructive"
size="sm"
onClick={handleClearSelection}
className="h-8 w-8 p-0"
title="Clear selection"
onClick={handleDeleteSelected}
className="gap-2"
>
<X className="h-4 w-4" />
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
{/* Divider */}
<div className="h-8 w-px bg-border" />
{/* Apply template to selected */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Apply template:</span>
<SearchableTemplateSelect
templates={templates}
value=""
onValueChange={handleTemplateChange}
getTemplateDisplayText={getTemplateDisplayText}
placeholder="Select template"
triggerClassName="w-[200px]"
disabled={templatesLoading}
/>
</div>
{/* Divider */}
<div className="h-8 w-px bg-border" />
{/* Save as template - only when single row selected */}
{hasSingleRow && (
<>
<Button
variant="outline"
size="sm"
onClick={handleSaveAsTemplate}
className="gap-2"
>
<Save className="h-4 w-4" />
Save as Template
</Button>
{/* Divider */}
<div className="h-8 w-px bg-border" />
</>
)}
{/* Delete selected */}
<Button
variant="destructive"
size="sm"
onClick={handleDeleteSelected}
className="gap-2"
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</div>
{/* Delete confirmation dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedCount} rows?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the selected rows from your import data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
});

View File

@@ -32,6 +32,7 @@ import {
// actions directly and uses getState() for one-time data access.
import { ErrorSource, ErrorType, type RowData, type ValidationError } from '../store/types';
import type { Field, SelectOption, Validation } from '../../../types';
import { correctUpcValue } from '../utils/upcUtils';
// Copy-down banner component
import { CopyDownBanner } from './CopyDownBanner';
@@ -103,6 +104,8 @@ interface CellWrapperProps {
line?: unknown;
// For UPC generation
supplier?: unknown;
// Loading state for dependent dropdowns
isLoadingOptions?: boolean;
// Copy-down state (from parent to avoid subscriptions in every cell)
isCopyDownActive: boolean;
isCopyDownSource: boolean;
@@ -127,6 +130,7 @@ const CellWrapper = memo(({
company,
line,
supplier,
isLoadingOptions = false,
isCopyDownActive,
isCopyDownSource,
isInCopyDownRange,
@@ -141,12 +145,19 @@ const CellWrapper = memo(({
// Check if cell has a value (for showing copy-down button)
const hasValue = value !== undefined && value !== null && value !== '';
// Check if field has unique validation rule (copy-down should be disabled)
const hasUniqueValidation = field.validations?.some((v: Validation) => v.rule === 'unique') ?? false;
// Check if cell has errors (for positioning copy-down button)
const hasErrors = errors.length > 0;
// Show copy-down button when:
// - Cell is hovered
// - Cell has a value
// - Not already in copy-down mode
// - There are rows below this one
const showCopyDownButton = isHovered && hasValue && !isCopyDownActive && rowIndex < totalRowCount - 1;
// - Field does NOT have unique validation (can't copy unique values)
const showCopyDownButton = isHovered && hasValue && !isCopyDownActive && rowIndex < totalRowCount - 1 && !hasUniqueValidation;
// UPC Generation logic
const isUpcField = field.key === 'upc';
@@ -241,38 +252,144 @@ const CellWrapper = memo(({
useValidationStore.getState().updateCell(rowIndex, field.key, newValue);
}, [rowIndex, field.key]);
// Stable callback for onBlur - validates field
// Stable callback for onBlur - validates field and triggers UPC validation if needed
// Uses setTimeout(0) to defer validation AFTER browser paint
const handleBlur = useCallback((newValue: unknown) => {
const { updateCell } = useValidationStore.getState();
updateCell(rowIndex, field.key, newValue);
let valueToSave = newValue;
// Auto-correct UPC check digit if this is the UPC field
if (field.key === 'upc' && typeof newValue === 'string' && newValue.trim()) {
const { corrected, changed } = correctUpcValue(newValue);
if (changed) {
valueToSave = corrected;
// We'll use the corrected value
}
}
updateCell(rowIndex, field.key, valueToSave);
// Defer validation to after the browser paints
setTimeout(() => {
const { setError, clearFieldError, fields } = useValidationStore.getState();
setTimeout(async () => {
const { setError, clearFieldError, fields, setUpcStatus, setGeneratedItemNumber,
cacheUpcResult, getCachedItemNumber, startValidatingCell, stopValidatingCell } = useValidationStore.getState();
const fieldDef = fields.find((f) => f.key === field.key);
if (!fieldDef?.validations) return;
if (!fieldDef?.validations) {
clearFieldError(rowIndex, field.key);
} else {
let hasError = false;
for (const validation of fieldDef.validations) {
if (validation.rule === 'required') {
const isEmpty = newValue === undefined || newValue === null ||
(typeof newValue === 'string' && newValue.trim() === '') ||
(Array.isArray(newValue) && newValue.length === 0);
// Check required validation
for (const validation of fieldDef.validations) {
if (validation.rule === 'required') {
const isEmpty = valueToSave === undefined || valueToSave === null ||
(typeof valueToSave === 'string' && valueToSave.trim() === '') ||
(Array.isArray(valueToSave) && valueToSave.length === 0);
if (isEmpty) {
setError(rowIndex, field.key, {
message: validation.errorMessage || 'This field is required',
level: validation.level || 'error',
source: ErrorSource.Row,
type: ErrorType.Required,
});
return;
if (isEmpty) {
setError(rowIndex, field.key, {
message: validation.errorMessage || 'This field is required',
level: validation.level || 'error',
source: ErrorSource.Row,
type: ErrorType.Required,
});
hasError = true;
break;
}
}
}
// Check unique validation if no required error
if (!hasError) {
const uniqueValidation = fieldDef.validations.find((v: Validation) => v.rule === 'unique');
if (uniqueValidation) {
const rows = useValidationStore.getState().rows;
const stringValue = String(valueToSave ?? '').toLowerCase().trim();
// Only check uniqueness if value is not empty
if (stringValue !== '') {
const isDuplicate = rows.some((row, idx) => {
if (idx === rowIndex) return false;
const otherValue = String(row[field.key] ?? '').toLowerCase().trim();
return otherValue === stringValue;
});
if (isDuplicate) {
setError(rowIndex, field.key, {
message: (uniqueValidation as { errorMessage?: string }).errorMessage || 'Must be unique',
level: (uniqueValidation as { level?: 'error' | 'warning' | 'info' }).level || 'error',
source: ErrorSource.Table,
type: ErrorType.Unique,
});
hasError = true;
}
}
}
}
if (!hasError) {
clearFieldError(rowIndex, field.key);
}
}
clearFieldError(rowIndex, field.key);
// Trigger UPC validation if supplier or UPC changed
if (field.key === 'supplier' || field.key === 'upc') {
const currentRow = useValidationStore.getState().rows[rowIndex];
const supplierValue = field.key === 'supplier' ? valueToSave : currentRow?.supplier;
const upcValue = field.key === 'upc' ? valueToSave : currentRow?.upc;
if (supplierValue && upcValue) {
const supplierId = String(supplierValue);
const upc = String(upcValue);
// Check cache first
const cached = getCachedItemNumber(supplierId, upc);
if (cached) {
setGeneratedItemNumber(rowIndex, cached);
return;
}
// Start validation
setUpcStatus(rowIndex, 'validating');
startValidatingCell(rowIndex, 'item_number');
try {
const response = await fetch(
`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upc)}&supplierId=${encodeURIComponent(supplierId)}`
);
const payload = await response.json().catch(() => null);
if (response.status === 409) {
// UPC already exists
setError(rowIndex, 'upc', {
message: 'UPC already exists in database',
level: 'error',
source: ErrorSource.Upc,
type: ErrorType.Unique,
});
setUpcStatus(rowIndex, 'error');
updateCell(rowIndex, 'item_number', '');
} else if (response.ok && payload?.success && payload?.itemNumber) {
// Success - cache and apply
cacheUpcResult(supplierId, upc, payload.itemNumber);
setGeneratedItemNumber(rowIndex, payload.itemNumber);
clearFieldError(rowIndex, 'upc');
setUpcStatus(rowIndex, 'done');
} else {
setUpcStatus(rowIndex, 'error');
updateCell(rowIndex, 'item_number', '');
}
} catch (error) {
console.error('UPC validation error:', error);
setUpcStatus(rowIndex, 'error');
} finally {
stopValidatingCell(rowIndex, 'item_number');
}
}
}
}, 0);
}, [rowIndex, field.key]);
@@ -340,15 +457,25 @@ const CellWrapper = memo(({
// When in copy-down mode for this field, make cell non-interactive so clicks go to parent
const isCopyDownModeForThisField = isCopyDownActive && (isCopyDownSource || isCopyDownTarget);
// Style override to make cell components transparent when copy-down highlighting should show
const cellWrapperStyle = (isCopyDownSource || isInCopyDownRange) ? {
'--cell-bg': 'transparent',
} as React.CSSProperties : undefined;
return (
<div
className={cellHighlightClass}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={isCopyDownTarget ? handleTargetClick : undefined}
style={cellWrapperStyle}
>
{/* Wrap cell in a div that blocks pointer events during copy-down for this field */}
<div className={isCopyDownModeForThisField ? 'pointer-events-none' : ''}>
<div className={cn(
isCopyDownModeForThisField && 'pointer-events-none',
// Force transparent background on child buttons/inputs when copy-down is active
(isCopyDownSource || isInCopyDownRange) && '[&_button]:bg-transparent [&_input]:bg-transparent'
)}>
<CellComponent
value={value}
field={field}
@@ -359,18 +486,23 @@ const CellWrapper = memo(({
onChange={handleChange}
onBlur={handleBlur}
onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined}
isLoadingOptions={isLoadingOptions}
/>
</div>
{/* Copy-down button - appears on hover */}
{/* Copy-down button - appears on hover, positioned to avoid error icons */}
{showCopyDownButton && (
<button
type="button"
onClick={handleStartCopyDown}
className="absolute right-0.5 top-1/2 -translate-y-1/2 z-10 p-1 rounded-full
bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600
dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400
opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
className={cn(
'absolute top-1/2 -translate-y-1/2 z-10 p-1 rounded-full',
'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600',
'dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400',
'shadow-sm',
// Position further left if there are errors to avoid overlap
hasErrors ? 'right-7' : 'right-0.5'
)}
title="Copy value to rows below"
>
<ArrowDown className="h-3.5 w-3.5" />
@@ -462,13 +594,19 @@ const TemplateCell = memo(({ rowIndex, currentTemplateId, defaultBrand }: Templa
// Handle template selection - apply template to this row
const handleTemplateChange = useCallback(
(templateId: string) => {
async (templateId: string) => {
if (!templateId) return;
const template = templates.find((t) => t.id.toString() === templateId);
if (!template) return;
const { updateRow, clearRowErrors, setRowValidationStatus } = useValidationStore.getState();
const state = useValidationStore.getState();
const { updateRow, setError, clearFieldError, setRowValidationStatus,
setUpcStatus, setGeneratedItemNumber, cacheUpcResult, getCachedItemNumber,
startValidatingCell, stopValidatingCell, updateCell } = state;
// Get current row data before applying template
const currentRow = state.rows[rowIndex];
// Build updates from template
const updates: Partial<RowData> = {
@@ -476,21 +614,100 @@ const TemplateCell = memo(({ rowIndex, currentTemplateId, defaultBrand }: Templa
};
// Copy template fields to row (excluding metadata)
const excludeFields = ['id', '__index', '__meta', '__template', '__original', '__corrected', '__changes'];
const excludeFields = ['id', '__index', '__meta', '__template', '__original', '__corrected', '__changes', '__aiSupplemental'];
const templateFieldsSet = new Set<string>();
Object.entries(template).forEach(([key, value]) => {
if (!excludeFields.includes(key)) {
updates[key] = value;
templateFieldsSet.add(key);
}
});
// If template sets company but not line, clear the line value
// (old line won't be valid for new company)
if (templateFieldsSet.has('company') && !templateFieldsSet.has('line')) {
updates.line = '';
}
// If template sets line but not subline, clear the subline value
if (templateFieldsSet.has('line') && !templateFieldsSet.has('subline')) {
updates.subline = '';
}
// Apply updates
updateRow(rowIndex, updates);
// Clear errors and mark as validated
clearRowErrors(rowIndex);
setRowValidationStatus(rowIndex, 'validated');
// Clear errors only for fields that were set by template
templateFieldsSet.forEach((fieldKey) => {
clearFieldError(rowIndex, fieldKey);
});
// Also clear line/subline errors if they were cleared
if (templateFieldsSet.has('company') && !templateFieldsSet.has('line')) {
clearFieldError(rowIndex, 'line');
}
if (templateFieldsSet.has('line') && !templateFieldsSet.has('subline')) {
clearFieldError(rowIndex, 'subline');
}
toast.success('Template applied');
// Trigger UPC validation if template set supplier or upc, and we have both values
const finalSupplier = updates.supplier ?? currentRow?.supplier;
const finalUpc = updates.upc ?? currentRow?.upc;
if (finalSupplier && finalUpc) {
const supplierId = String(finalSupplier);
const upc = String(finalUpc);
// Check cache first
const cached = getCachedItemNumber(supplierId, upc);
if (cached) {
setGeneratedItemNumber(rowIndex, cached);
setRowValidationStatus(rowIndex, 'validated');
return;
}
// Start validation
setUpcStatus(rowIndex, 'validating');
startValidatingCell(rowIndex, 'item_number');
try {
const response = await fetch(
`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upc)}&supplierId=${encodeURIComponent(supplierId)}`
);
const payload = await response.json().catch(() => null);
if (response.status === 409) {
// UPC already exists
setError(rowIndex, 'upc', {
message: 'UPC already exists in database',
level: 'error',
source: ErrorSource.Upc,
type: ErrorType.Unique,
});
setUpcStatus(rowIndex, 'error');
updateCell(rowIndex, 'item_number', '');
setRowValidationStatus(rowIndex, 'error');
} else if (response.ok && payload?.success && payload?.itemNumber) {
// Success - cache and apply
cacheUpcResult(supplierId, upc, payload.itemNumber);
setGeneratedItemNumber(rowIndex, payload.itemNumber);
clearFieldError(rowIndex, 'upc');
setUpcStatus(rowIndex, 'done');
setRowValidationStatus(rowIndex, 'validated');
} else {
setUpcStatus(rowIndex, 'error');
updateCell(rowIndex, 'item_number', '');
}
} catch (error) {
console.error('UPC validation error:', error);
setUpcStatus(rowIndex, 'error');
} finally {
stopValidatingCell(rowIndex, 'item_number');
}
}
},
[templates, rowIndex]
);
@@ -651,6 +868,14 @@ const VirtualRow = memo(({
const needsLine = field.key === 'subline';
const needsSupplier = field.key === 'upc';
// Check loading state for dependent dropdowns via getState()
let isLoadingOptions = false;
if (needsCompany && company) {
isLoadingOptions = useValidationStore.getState().loadingProductLines.has(String(company));
} else if (needsLine && line) {
isLoadingOptions = useValidationStore.getState().loadingSublines.has(String(line));
}
// Calculate copy-down state for this cell
const isCopyDownActive = copyDownMode.isActive;
const isCopyDownSource = isCopyDownActive &&
@@ -667,6 +892,7 @@ const VirtualRow = memo(({
return (
<div
key={field.key}
data-cell-field={field.key}
className="px-2 py-1 border-r last:border-r-0 flex items-center overflow-hidden"
style={{
width: columnWidth,
@@ -683,6 +909,7 @@ const VirtualRow = memo(({
company={needsCompany ? company : undefined}
line={needsLine ? line : undefined}
supplier={needsSupplier ? supplier : undefined}
isLoadingOptions={isLoadingOptions}
isCopyDownActive={isCopyDownActive}
isCopyDownSource={isCopyDownSource}
isInCopyDownRange={isInCopyDownRange}

View File

@@ -9,8 +9,8 @@
* dropdowns extremely slow. Instead, use getState() for one-time reads.
*/
import { useMemo, useCallback } from 'react';
import { Search, Plus, Trash2, FolderPlus, FilePlus2 } from 'lucide-react';
import { useMemo, useCallback, useState } from 'react';
import { Search, Plus, FolderPlus, Edit3 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
@@ -23,6 +23,8 @@ import {
useFields,
} from '../store/selectors';
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '../../../CreateProductCategoryDialog';
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog';
import { useTemplateManagement } from '../hooks/useTemplateManagement';
interface ValidationToolbarProps {
rowCount: number;
@@ -39,6 +41,12 @@ export const ValidationToolbar = ({
const selectedRowCount = useSelectedRowCount();
const fields = useFields();
// State for the product search template dialog
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
// Get template management hook for reloading templates
const { loadTemplates } = useTemplateManagement();
// Store actions - get directly, no subscription needed
const setSearchText = useValidationStore((state) => state.setSearchText);
const setShowErrorsOnly = useValidationStore((state) => state.setShowErrorsOnly);
@@ -56,21 +64,6 @@ export const ValidationToolbar = ({
}));
}, [fields]);
// PERFORMANCE: Get row indices at action time, not via subscription
// This avoids re-rendering when rows change
const handleDeleteSelected = useCallback(() => {
const { rows, selectedRows, deleteRows } = useValidationStore.getState();
const indices: number[] = [];
rows.forEach((row, index) => {
if (selectedRows.has(row.__index)) {
indices.push(index);
}
});
if (indices.length > 0) {
deleteRows(indices);
}
}, []);
// PERFORMANCE: Get addRow at action time
const handleAddRow = useCallback(() => {
useValidationStore.getState().addRow();
@@ -159,13 +152,13 @@ export const ValidationToolbar = ({
Add Row
</Button>
{/* Create template */}
{/* Create template from existing product */}
<Button
variant="outline"
size="sm"
onClick={() => useValidationStore.getState().openTemplateForm({})}
onClick={() => setIsSearchDialogOpen(true)}
>
<FilePlus2 className="h-4 w-4 mr-1" />
<Edit3 className="h-4 w-4 mr-1" />
New Template
</Button>
@@ -180,19 +173,14 @@ export const ValidationToolbar = ({
companies={companyOptions}
onCreated={handleCategoryCreated}
/>
{/* Delete selected */}
<Button
variant="outline"
size="sm"
onClick={handleDeleteSelected}
disabled={selectedRowCount === 0}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
</div>
{/* Product Search Template Dialog */}
<SearchProductTemplateDialog
isOpen={isSearchDialogOpen}
onClose={() => setIsSearchDialogOpen(false)}
onTemplateCreated={loadTemplates}
/>
</div>
);
};

View File

@@ -88,6 +88,12 @@ const ComboboxCellComponent = ({
[onBlur]
);
// Handle wheel scroll in dropdown - stop propagation to prevent table scroll
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
e.currentTarget.scrollTop += e.deltaY;
}, []);
return (
<div className="relative w-full">
<Popover open={open} onOpenChange={handleOpenChange}>
@@ -122,23 +128,28 @@ const ComboboxCellComponent = ({
) : (
<>
<CommandEmpty>No {field.label.toLowerCase()} found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label} // cmdk filters by this value
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
stringValue === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
<div
className="max-h-[200px] overflow-y-auto overscroll-contain"
onWheel={handleWheel}
>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label} // cmdk filters by this value
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
stringValue === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</div>
</>
)}
</CommandList>

View File

@@ -7,10 +7,18 @@
import { useState, useCallback, useEffect, useRef, memo } from 'react';
import { Input } from '@/components/ui/input';
import { Loader2 } from 'lucide-react';
import { Loader2, AlertCircle } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
import { ErrorType } from '../../store/types';
interface InputCellProps {
value: unknown;
@@ -84,8 +92,26 @@ const InputCellComponent = ({
onBlur(valueToSave);
}, [localValue, onBlur, field.fieldType]);
// Process errors - show icon only for non-required errors when field has value
// Don't show error icon while user is actively editing (focused)
const hasError = errors.length > 0;
const errorMessage = errors[0]?.message;
const isRequiredError = errors.length === 1 && errors[0]?.type === ErrorType.Required;
const valueIsEmpty = !localValue;
const showErrorIcon = !isFocused && hasError && !(valueIsEmpty && isRequiredError);
const errorMessage = errors.filter(e => !(valueIsEmpty && e.type === ErrorType.Required))
.map(e => e.message).join('\n');
// Show skeleton while validating
if (isValidating) {
return (
<div className={cn(
'h-8 w-full flex items-center border rounded-md px-2',
hasError && 'border-destructive'
)}>
<Skeleton className="h-4 w-full" />
</div>
);
}
return (
<div className="relative w-full">
@@ -99,13 +125,26 @@ const InputCellComponent = ({
className={cn(
'h-8 text-sm',
hasError && 'border-destructive focus-visible:ring-destructive',
isValidating && 'opacity-50'
isValidating && 'opacity-50',
showErrorIcon && 'pr-8'
)}
title={errorMessage}
/>
{isValidating && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
{/* Error icon with tooltip - only for non-required errors */}
{showErrorIcon && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="cursor-help">
<AlertCircle className="h-4 w-4 text-destructive" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p className="whitespace-pre-wrap">{errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>

View File

@@ -8,8 +8,8 @@
* Controlled open state can cause delays due to React state processing.
*/
import { useCallback, useMemo, memo } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useCallback, useMemo, memo, useState } from 'react';
import { Check, ChevronsUpDown, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
@@ -24,10 +24,25 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
import { ErrorType } from '../../store/types';
// Extended option type to include hex color values
interface MultiSelectOption extends SelectOption {
hex?: string;
hexColor?: string;
hex_color?: string;
}
interface MultiSelectCellProps {
value: unknown;
@@ -41,6 +56,25 @@ interface MultiSelectCellProps {
onFetchOptions?: () => void;
}
/**
* Helper to extract hex color from option
* Supports hex, hexColor, and hex_color field names
*/
const getOptionHex = (option: MultiSelectOption): string | undefined => {
if (option.hex) return option.hex.startsWith('#') ? option.hex : `#${option.hex}`;
if (option.hexColor) return option.hexColor.startsWith('#') ? option.hexColor : `#${option.hexColor}`;
if (option.hex_color) return option.hex_color.startsWith('#') ? option.hex_color : `#${option.hex_color}`;
return undefined;
};
/**
* Check if a color is white or near-white (needs border)
*/
const isWhiteColor = (hex: string): boolean => {
const normalized = hex.toLowerCase();
return normalized === '#ffffff' || normalized === '#fff' || normalized === 'ffffff' || normalized === 'fff';
};
const MultiSelectCellComponent = ({
value,
field,
@@ -50,7 +84,13 @@ const MultiSelectCellComponent = ({
onChange: _onChange, // Unused - onBlur handles both update and validation
onBlur,
}: MultiSelectCellProps) => {
// PERFORMANCE: Don't use controlled open state
const [open, setOpen] = useState(false);
// Handle wheel scroll in dropdown - stop propagation to prevent table scroll
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
e.currentTarget.scrollTop += e.deltaY;
}, []);
// Parse value to array
const selectedValues = useMemo(() => {
@@ -62,8 +102,13 @@ const MultiSelectCellComponent = ({
return [];
}, [value, field.fieldType]);
// Process errors - show icon only for non-required errors when field has value
const hasError = errors.length > 0;
const errorMessage = errors[0]?.message;
const isRequiredError = errors.length === 1 && errors[0]?.type === ErrorType.Required;
const valueIsEmpty = selectedValues.length === 0;
const showErrorIcon = hasError && !(valueIsEmpty && isRequiredError);
const errorMessage = errors.filter(e => !(valueIsEmpty && e.type === ErrorType.Required))
.map(e => e.message).join('\n');
// Only call onBlur - it handles both the cell update AND validation
// Calling onChange separately would cause a redundant store update
@@ -80,78 +125,164 @@ const MultiSelectCellComponent = ({
[selectedValues, onBlur]
);
// Get labels for selected values
// Get labels for selected values (including hex color for colors field)
const selectedLabels = useMemo(() => {
return selectedValues.map((val) => {
const option = options.find((opt) => opt.value === val);
return { value: val, label: option?.label || val };
const option = options.find((opt) => opt.value === val) as MultiSelectOption | undefined;
const hexColor = field.key === 'colors' && option ? getOptionHex(option) : undefined;
return { value: val, label: option?.label || val, hex: hexColor };
});
}, [selectedValues, options]);
}, [selectedValues, options, field.key]);
// Show loading skeleton when validating or when we have values but no options (not yet loaded)
const showLoadingSkeleton = isValidating || (selectedValues.length > 0 && options.length === 0);
if (showLoadingSkeleton) {
return (
<div className={cn(
'h-8 w-full flex items-center border rounded-md px-2',
hasError && 'border-destructive'
)}>
<Skeleton className="h-4 w-full" />
</div>
);
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isValidating}
className={cn(
'h-8 w-full justify-between text-sm font-normal',
hasError && 'border-destructive focus:ring-destructive',
isValidating && 'opacity-50',
selectedValues.length === 0 && 'text-muted-foreground'
)}
title={errorMessage}
>
<div className="flex items-center w-full justify-between overflow-hidden">
<div className="flex items-center gap-2 overflow-hidden">
{selectedLabels.length === 0 ? (
<span className="text-muted-foreground truncate">Select...</span>
) : selectedLabels.length === 1 ? (
<span className="truncate">{selectedLabels[0].label}</span>
) : (
<>
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
{selectedLabels.length} selected
</Badge>
<span className="truncate">
{selectedLabels.map((v) => v.label).join(', ')}
</span>
</>
)}
</div>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder={`Search ${field.label}...`} />
<CommandList>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(option.value)
? 'opacity-100'
: 'opacity-0'
<div className="relative w-full">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={isValidating}
className={cn(
'h-8 w-full justify-between text-sm font-normal',
hasError && 'border-destructive focus:ring-destructive',
isValidating && 'opacity-50',
selectedValues.length === 0 && 'text-muted-foreground',
showErrorIcon && 'pr-8'
)}
>
<div className="flex items-center w-full justify-between overflow-hidden">
<div className="flex items-center gap-2 overflow-hidden">
{selectedLabels.length === 0 ? (
<span className="text-muted-foreground truncate">Select...</span>
) : selectedLabels.length === 1 ? (
<div className="flex items-center gap-1.5 truncate">
{field.key === 'colors' && selectedLabels[0].hex && (
<span
className={cn(
'inline-block h-3 w-3 rounded-full flex-shrink-0',
isWhiteColor(selectedLabels[0].hex) && 'border border-black'
)}
style={{ backgroundColor: selectedLabels[0].hex }}
/>
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<span className="truncate">{selectedLabels[0].label}</span>
</div>
) : (
<>
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
{selectedLabels.length} selected
</Badge>
<div className="flex items-center gap-1 truncate">
{field.key === 'colors' ? (
// Show color swatches for colors field
selectedLabels.slice(0, 5).map((v, i) => (
v.hex ? (
<span
key={i}
className={cn(
'inline-block h-3 w-3 rounded-full flex-shrink-0',
isWhiteColor(v.hex) && 'border border-black'
)}
style={{ backgroundColor: v.hex }}
title={v.label}
/>
) : null
))
) : (
<span className="truncate">
{selectedLabels.map((v) => v.label).join(', ')}
</span>
)}
</div>
</>
)}
</div>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder={`Search ${field.label}...`} />
<CommandList>
<CommandEmpty>No options found.</CommandEmpty>
<div
className="max-h-[200px] overflow-y-auto overscroll-contain"
onWheel={handleWheel}
>
<CommandGroup>
{options.map((option) => {
const hexColor = field.key === 'colors' ? getOptionHex(option as MultiSelectOption) : undefined;
const isWhite = hexColor ? isWhiteColor(hexColor) : false;
return (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(option.value)
? 'opacity-100'
: 'opacity-0'
)}
/>
{/* Color circle for colors field */}
{field.key === 'colors' && hexColor && (
<span
className={cn(
'inline-block h-3.5 w-3.5 rounded-full mr-2 flex-shrink-0',
isWhite && 'border border-black'
)}
style={{ backgroundColor: hexColor }}
/>
)}
{option.label}
</CommandItem>
);
})}
</CommandGroup>
</div>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Error icon with tooltip - only for non-required errors */}
{showErrorIcon && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="cursor-help">
<AlertCircle className="h-4 w-4 text-destructive" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p className="whitespace-pre-wrap">{errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
);
};

View File

@@ -7,7 +7,7 @@
*/
import { useState, useCallback, useRef, useMemo, memo } from 'react';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { Check, ChevronsUpDown, Loader2, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
@@ -22,9 +22,17 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
import { ErrorType } from '../../store/types';
interface SelectCellProps {
value: unknown;
@@ -36,6 +44,7 @@ interface SelectCellProps {
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => Promise<SelectOption[]>;
isLoadingOptions?: boolean; // External loading state (e.g., when company changes)
}
const SelectCellComponent = ({
@@ -47,26 +56,35 @@ const SelectCellComponent = ({
onChange: _onChange,
onBlur,
onFetchOptions,
isLoadingOptions: externalLoadingOptions = false,
}: SelectCellProps) => {
const [open, setOpen] = useState(false);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const [isFetchingOptions, setIsFetchingOptions] = useState(false);
const hasFetchedRef = useRef(false);
const commandListRef = useRef<HTMLDivElement>(null);
// Combined loading state - either internal fetch or external loading
const isLoadingOptions = isFetchingOptions || externalLoadingOptions;
const stringValue = String(value ?? '');
// Process errors - show icon only for non-required errors when field has value
const hasError = errors.length > 0;
const errorMessage = errors[0]?.message;
const isRequiredError = errors.length === 1 && errors[0]?.type === ErrorType.Required;
const valueIsEmpty = !stringValue;
const showErrorIcon = hasError && !(valueIsEmpty && isRequiredError);
const errorMessage = errors.filter(e => !(valueIsEmpty && e.type === ErrorType.Required))
.map(e => e.message).join('\n');
// Handle opening the dropdown - fetch options if needed
const handleOpenChange = useCallback(
async (isOpen: boolean) => {
if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true;
setIsLoadingOptions(true);
setIsFetchingOptions(true);
try {
await onFetchOptions();
} finally {
setIsLoadingOptions(false);
setIsFetchingOptions(false);
}
}
setOpen(isOpen);
@@ -83,21 +101,44 @@ const SelectCellComponent = ({
[onBlur]
);
// Handle wheel scroll in dropdown
const handleWheel = useCallback((e: React.WheelEvent) => {
if (commandListRef.current) {
e.stopPropagation();
commandListRef.current.scrollTop += e.deltaY;
}
// Handle wheel scroll in dropdown - stop propagation to prevent table scroll
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
e.currentTarget.scrollTop += e.deltaY;
}, []);
// Find display label for current value
// IMPORTANT: We need to match against both string and number value types
const displayLabel = useMemo(() => {
if (!stringValue) return '';
const found = options.find((opt) => String(opt.value) === stringValue);
return found?.label || stringValue;
// Try exact string match first, then loose match
const found = options.find((opt) =>
String(opt.value) === stringValue || opt.value === stringValue
);
return found?.label ?? null; // Return null if not found (don't fallback to ID)
}, [options, stringValue]);
// Check if we have a value but couldn't find its label in options
// This indicates options haven't loaded yet
const valueWithoutLabel = stringValue && displayLabel === null;
// Show loading skeleton when:
// - Validating
// - Have a value but no options and couldn't find label
// - External loading state (e.g., fetching lines after company change)
const showLoadingSkeleton = isValidating || (valueWithoutLabel && options.length === 0) || externalLoadingOptions;
if (showLoadingSkeleton) {
return (
<div className={cn(
'h-8 w-full flex items-center border rounded-md px-2',
hasError && 'border-destructive'
)}>
<Skeleton className="h-4 w-full" />
</div>
);
}
return (
<div className="relative w-full">
<Popover open={open} onOpenChange={handleOpenChange}>
@@ -111,12 +152,15 @@ const SelectCellComponent = ({
'h-8 w-full justify-between text-sm font-normal',
hasError && 'border-destructive focus:ring-destructive',
isValidating && 'opacity-50',
!stringValue && 'text-muted-foreground'
!stringValue && 'text-muted-foreground',
showErrorIcon && 'pr-8'
)}
title={errorMessage}
>
<span className="truncate">
{displayLabel || field.label}
{/* Show label if found, placeholder if no value, or loading indicator if value but no label */}
{displayLabel ? displayLabel : !stringValue ? field.label : (
<span className="text-muted-foreground italic">Loading...</span>
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -128,11 +172,7 @@ const SelectCellComponent = ({
>
<Command shouldFilter={true}>
<CommandInput placeholder="Search..." className="h-9" />
<CommandList
ref={commandListRef}
onWheel={handleWheel}
className="max-h-[200px]"
>
<CommandList>
{isLoadingOptions ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -140,30 +180,48 @@ const SelectCellComponent = ({
) : (
<>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{option.label}
{String(option.value) === stringValue && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
<div
className="max-h-[200px] overflow-y-auto overscroll-contain"
onWheel={handleWheel}
>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{option.label}
{String(option.value) === stringValue && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</div>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{isValidating && (
<div className="absolute right-8 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
{/* Error icon with tooltip - only for non-required errors */}
{showErrorIcon && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="cursor-help">
<AlertCircle className="h-4 w-4 text-destructive" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p className="whitespace-pre-wrap">{errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>

View File

@@ -13,6 +13,7 @@ import { useCallback, useRef } from 'react';
import { useValidationStore } from '../store/validationStore';
import { useInitialUpcValidationDone } from '../store/selectors';
import { ErrorSource, ErrorType, type UpcValidationResult } from '../store/types';
import { correctUpcValue } from '../utils/upcUtils';
import config from '@/config';
const BATCH_SIZE = 50;
@@ -195,12 +196,17 @@ export const useUpcValidation = () => {
// Get current rows at action time via getState()
const currentRows = useValidationStore.getState().rows;
console.log('[UPC Validation] Starting batch validation for', currentRows.length, 'rows');
// Find rows that need UPC validation
const rowsToValidate = currentRows
.map((row, index) => ({ row, index }))
.filter(({ row }) => row.supplier && (row.upc || row.barcode));
console.log('[UPC Validation] Found', rowsToValidate.length, 'rows with supplier and UPC/barcode');
if (rowsToValidate.length === 0) {
console.log('[UPC Validation] No rows to validate, skipping to next phase');
setInitialUpcValidationDone(true);
setInitPhase('validating-fields');
return;
@@ -214,7 +220,16 @@ export const useUpcValidation = () => {
await Promise.all(
batch.map(async ({ row, index }) => {
const supplierId = String(row.supplier);
const upcValue = String(row.upc || row.barcode);
const rawUpc = String(row.upc || row.barcode);
// Apply UPC check digit correction before validating
// This handles imported data with incorrect/missing check digits
const { corrected: upcValue, changed: upcCorrected } = correctUpcValue(rawUpc);
// If UPC was corrected, update the cell value
if (upcCorrected) {
updateCell(index, 'upc', upcValue);
}
// Mark as validating
setUpcStatus(index, 'validating');
@@ -232,12 +247,16 @@ export const useUpcValidation = () => {
// Make API call
const result = await fetchProductByUpc(supplierId, upcValue);
console.log(`[UPC Validation] Row ${index}: supplierId=${supplierId}, upc=${upcValue}, result=`, result);
if (result.success && result.itemNumber) {
console.log(`[UPC Validation] Row ${index}: Setting item_number to "${result.itemNumber}"`);
cacheUpcResult(supplierId, upcValue, result.itemNumber);
setGeneratedItemNumber(index, result.itemNumber);
clearFieldError(index, 'upc');
setUpcStatus(index, 'done');
} else {
console.log(`[UPC Validation] Row ${index}: No item number returned, error code=${result.code}`);
updateCell(index, 'item_number', '');
setUpcStatus(index, 'error');

View File

@@ -185,28 +185,66 @@ export const useValidationActions = () => {
*
* With 100 rows × 30 fields, the old approach would trigger ~3000 individual
* set() calls, each cloning the entire errors Map. This approach triggers ONE.
*
* Also handles:
* - Rounding currency fields to 2 decimal places
*/
const validateAllRows = useCallback(async () => {
const { rows: currentRows, fields: currentFields, setBulkValidationResults } = useValidationStore.getState();
const { rows: currentRows, fields: currentFields, errors: existingErrors, setBulkValidationResults, updateCell: updateCellAction } = useValidationStore.getState();
// Collect ALL errors in plain JS Maps (no Immer overhead)
const allErrors = new Map<number, Record<string, ValidationError[]>>();
const allStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>();
// Identify price fields for currency rounding
const priceFields = currentFields.filter((f: Field<string>) =>
'price' in f.fieldType && f.fieldType.price
).map((f: Field<string>) => f.key);
// Process all rows - collect errors without touching the store
for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) {
const row = currentRows[rowIndex];
if (!row) continue;
// IMPORTANT: Preserve existing UPC errors (from UPC validation phase)
// These have source: ErrorSource.Upc and would otherwise be overwritten
const existingRowErrors = existingErrors.get(rowIndex);
const rowErrors: Record<string, ValidationError[]> = {};
// Copy over any existing UPC-sourced errors
if (existingRowErrors) {
Object.entries(existingRowErrors).forEach(([fieldKey, errors]) => {
const upcErrors = errors.filter(e => e.source === ErrorSource.Upc);
if (upcErrors.length > 0) {
rowErrors[fieldKey] = upcErrors;
}
});
}
// Round currency fields to 2 decimal places on initial load
for (const priceFieldKey of priceFields) {
const value = row[priceFieldKey];
if (value !== undefined && value !== null && value !== '') {
const numValue = parseFloat(String(value));
if (!isNaN(numValue)) {
const rounded = numValue.toFixed(2);
if (String(value) !== rounded) {
// Update the cell with rounded value (batched later)
updateCellAction(rowIndex, priceFieldKey, rounded);
}
}
}
}
// Validate each field
for (const field of currentFields) {
const value = row[field.key];
const error = validateFieldValue(value, field, currentRows, rowIndex);
if (error) {
rowErrors[field.key] = [error];
// Merge with existing errors (e.g., UPC errors) rather than replacing
const existingFieldErrors = rowErrors[field.key] || [];
rowErrors[field.key] = [...existingFieldErrors, error];
}
}

View File

@@ -34,6 +34,18 @@ const fetchFieldOptions = async () => {
return response.json();
};
/**
* Normalize option values to strings
* API may return numeric values (e.g., supplier IDs from MySQL) but our
* SelectOption type expects string values for consistent comparison
*/
const normalizeOptions = (options: SelectOption[]): SelectOption[] => {
return options.map((opt) => ({
...opt,
value: String(opt.value),
}));
};
/**
* Merge API options into base field definitions
*/
@@ -62,7 +74,8 @@ const mergeFieldOptions = (
...field,
fieldType: {
...field.fieldType,
options: options[optionKey],
// Normalize option values to strings for consistent type handling
options: normalizeOptions(options[optionKey]),
},
} as Field<string>;
}

View File

@@ -381,6 +381,16 @@ export const useValidationStore = create<ValidationStore>()(
if (state.rows[rowIndex]) {
state.rows[rowIndex].item_number = itemNumber;
}
// Clear any validation errors for item_number since we just set a valid value
const rowErrors = state.errors.get(rowIndex);
if (rowErrors && rowErrors.item_number) {
const { item_number: _, ...remainingErrors } = rowErrors;
if (Object.keys(remainingErrors).length === 0) {
state.errors.delete(rowIndex);
} else {
state.errors.set(rowIndex, remainingErrors);
}
}
});
},
@@ -545,9 +555,12 @@ export const useValidationStore = create<ValidationStore>()(
return;
}
const fieldKey = copyDownMode.sourceFieldKey;
const sourceRowIndex = copyDownMode.sourceRowIndex;
// First, perform the copy operation
set((state) => {
const sourceValue = state.rows[copyDownMode.sourceRowIndex!]?.[copyDownMode.sourceFieldKey!];
const sourceValue = state.rows[sourceRowIndex]?.[fieldKey];
if (sourceValue === undefined) return;
// Clone value for arrays/objects to prevent reference sharing
@@ -557,9 +570,27 @@ export const useValidationStore = create<ValidationStore>()(
return val;
};
for (let i = copyDownMode.sourceRowIndex! + 1; i <= targetRowIndex; i++) {
// Check if value is non-empty (for clearing required errors)
const hasValue = sourceValue !== null && sourceValue !== '' &&
!(Array.isArray(sourceValue) && sourceValue.length === 0);
for (let i = sourceRowIndex + 1; i <= targetRowIndex; i++) {
if (state.rows[i]) {
state.rows[i][copyDownMode.sourceFieldKey!] = cloneValue(sourceValue);
state.rows[i][fieldKey] = cloneValue(sourceValue);
// Clear validation errors for this field if value is non-empty
if (hasValue) {
const rowErrors = state.errors.get(i);
if (rowErrors && rowErrors[fieldKey]) {
// Remove errors for this field
const { [fieldKey]: _, ...remainingErrors } = rowErrors;
if (Object.keys(remainingErrors).length === 0) {
state.errors.delete(i);
} else {
state.errors.set(i, remainingErrors);
}
}
}
}
}