Rewrite validation step part 2
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user