diff --git a/inventory/src/components/product-import/config.ts b/inventory/src/components/product-import/config.ts index bf0ba1a..f76433c 100644 --- a/inventory/src/components/product-import/config.ts +++ b/inventory/src/components/product-import/config.ts @@ -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" }, diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/CopyDownBanner.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/CopyDownBanner.tsx index 62c40b3..56c3418 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/CopyDownBanner.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/CopyDownBanner.tsx @@ -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(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 ( -
-
+
+
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx index 7ee565c..4ff3bbe 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx @@ -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 ( -
-
- {/* Selection count badge */} -
-
- {selectedCount} selected + <> +
+
+ {/* Selection count badge */} +
+
+ {selectedCount} selected +
+
+ + {/* Divider */} +
+ + {/* Apply template to selected */} +
+ Apply template: + +
+ + {/* Divider */} +
+ + {/* Save as template - only when single row selected */} + {hasSingleRow && ( + <> + + + {/* Divider */} +
+ + )} + + {/* Delete selected */}
- - {/* Divider */} -
- - {/* Apply template to selected */} -
- Apply template: - -
- - {/* Divider */} -
- - {/* Save as template - only when single row selected */} - {hasSingleRow && ( - <> - - - {/* Divider */} -
- - )} - - {/* Delete selected */} -
-
+ + {/* Delete confirmation dialog */} + + + + Delete {selectedCount} rows? + + This action cannot be undone. This will permanently delete the selected rows from your import data. + + + + Cancel + + Delete + + + + + ); }); diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx index 719d86a..508bc98 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -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, - }); + 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); + } + } + + // 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'); + } } } - - clearFieldError(rowIndex, field.key); }, 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 (
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 */} -
+
- {/* Copy-down button - appears on hover */} + {/* Copy-down button - appears on hover, positioned to avoid error icons */} {showCopyDownButton && ( - {/* Create template */} + {/* Create template from existing product */} @@ -180,19 +173,14 @@ export const ValidationToolbar = ({ companies={companyOptions} onCreated={handleCategoryCreated} /> - - {/* Delete selected */} -
+ + {/* Product Search Template Dialog */} + setIsSearchDialogOpen(false)} + onTemplateCreated={loadTemplates} + />
); }; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx index 0569c16..640fd92 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx @@ -88,6 +88,12 @@ const ComboboxCellComponent = ({ [onBlur] ); + // Handle wheel scroll in dropdown - stop propagation to prevent table scroll + const handleWheel = useCallback((e: React.WheelEvent) => { + e.stopPropagation(); + e.currentTarget.scrollTop += e.deltaY; + }, []); + return (
@@ -122,23 +128,28 @@ const ComboboxCellComponent = ({ ) : ( <> No {field.label.toLowerCase()} found. - - {options.map((option) => ( - handleSelect(option.value)} - > - - {option.label} - - ))} - +
+ + {options.map((option) => ( + handleSelect(option.value)} + > + + {option.label} + + ))} + +
)} diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx index 46b7eb0..643ab33 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx @@ -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 ( +
+ +
+ ); + } return (
@@ -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 && ( -
- + + {/* Error icon with tooltip - only for non-required errors */} + {showErrorIcon && ( +
+ + + +
+ +
+
+ +

{errorMessage}

+
+
+
)}
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx index 319816c..5e1a86e 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx @@ -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) => { + 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 ( +
+ +
+ ); + } return ( - - - - - - - - - No options found. - - {options.map((option) => ( - handleSelect(option.value)} - > - + + + + + + + + + No options found. +
+ + {options.map((option) => { + const hexColor = field.key === 'colors' ? getOptionHex(option as MultiSelectOption) : undefined; + const isWhite = hexColor ? isWhiteColor(hexColor) : false; + + return ( + handleSelect(option.value)} + > + + {/* Color circle for colors field */} + {field.key === 'colors' && hexColor && ( + + )} + {option.label} + + ); + })} + +
+
+
+
+
+ + {/* Error icon with tooltip - only for non-required errors */} + {showErrorIcon && ( +
+ + + +
+ +
+
+ +

{errorMessage}

+
+
+
+
+ )} +
); }; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/SelectCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/SelectCell.tsx index 3bc6ab1..cb8164b 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/SelectCell.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/SelectCell.tsx @@ -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; + 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(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) => { + 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 ( +
+ +
+ ); + } + return (
@@ -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} > - {displayLabel || field.label} + {/* Show label if found, placeholder if no value, or loading indicator if value but no label */} + {displayLabel ? displayLabel : !stringValue ? field.label : ( + Loading... + )} @@ -128,11 +172,7 @@ const SelectCellComponent = ({ > - + {isLoadingOptions ? (
@@ -140,30 +180,48 @@ const SelectCellComponent = ({ ) : ( <> No results found. - - {options.map((option) => ( - handleSelect(option.value)} - className="cursor-pointer" - > - {option.label} - {String(option.value) === stringValue && ( - - )} - - ))} - +
+ + {options.map((option) => ( + handleSelect(option.value)} + className="cursor-pointer" + > + {option.label} + {String(option.value) === stringValue && ( + + )} + + ))} + +
)} - {isValidating && ( -
- + + {/* Error icon with tooltip - only for non-required errors */} + {showErrorIcon && ( +
+ + + +
+ +
+
+ +

{errorMessage}

+
+
+
)}
diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useUpcValidation.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useUpcValidation.ts index 069e9c7..db88ef4 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useUpcValidation.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useUpcValidation.ts @@ -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'); diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useValidationActions.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useValidationActions.ts index 8657a47..24e8e1f 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useValidationActions.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useValidationActions.ts @@ -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>(); const allStatuses = new Map(); + // Identify price fields for currency rounding + const priceFields = currentFields.filter((f: Field) => + 'price' in f.fieldType && f.fieldType.price + ).map((f: Field) => 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 = {}; + // 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]; } } diff --git a/inventory/src/components/product-import/steps/ValidationStep/index.tsx b/inventory/src/components/product-import/steps/ValidationStep/index.tsx index b91f5d5..5f0576d 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/index.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/index.tsx @@ -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; } diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts index 482d1f0..33ea360 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts @@ -381,6 +381,16 @@ export const useValidationStore = create()( 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()( 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()( 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); + } + } + } } }