Product import fixes/enhancements

This commit is contained in:
2026-01-26 23:54:46 -05:00
parent ec8ab17d3f
commit 11d0555eeb
14 changed files with 660 additions and 166 deletions

View File

@@ -25,7 +25,7 @@ const getFullImageUrl = (url: string): string => {
}
// Otherwise, it's a relative URL, prepend the domain
const baseUrl = 'https://acot.site';
const baseUrl = 'https://tools.acherryontop.com';
// Make sure url starts with / for path
const path = url.startsWith('/') ? url : `/${url}`;
return `${baseUrl}${path}`;

View File

@@ -74,7 +74,7 @@ export const useProductImagesInit = (data: Product[]) => {
}
// Otherwise, it's a relative URL, prepend the domain
const baseUrl = 'https://acot.site';
const baseUrl = 'https://tools.acherryontop.com';
// Make sure url starts with / for path
const path = url.startsWith('/') ? url : `/${url}`;
return `${baseUrl}${path}`;

View File

@@ -30,7 +30,14 @@ export const Steps = () => {
const onClickStep = (stepIndex: number) => {
const type = stepIndexToStepType(stepIndex)
const historyIdx = history.current.findIndex((v) => v.type === type)
let historyIdx = history.current.findIndex((v) => v.type === type)
// Special case: step index 0 could be either upload or selectSheet
// If we didn't find upload, also check for selectSheet
if (historyIdx === -1 && stepIndex === 0) {
historyIdx = history.current.findIndex((v) => v.type === StepType.selectSheet)
}
if (historyIdx === -1) return
const nextHistory = history.current.slice(0, historyIdx + 1)
history.current = nextHistory
@@ -39,7 +46,14 @@ export const Steps = () => {
}
const onBack = () => {
onClickStep(Math.max(activeStep - 1, 0))
// For back navigation, we want to go to the previous entry in history
// rather than relying on step index, since selectSheet shares index with upload
if (history.current.length > 0) {
const previousState = history.current[history.current.length - 1]
history.current = history.current.slice(0, -1)
setState(previousState)
setActiveStep(stepTypeToStepIndex(previousState.type))
}
}
const onNext = (v: StepState) => {

View File

@@ -128,6 +128,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
)
switch (state.type) {
case StepType.upload:
return (

View File

@@ -16,7 +16,7 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Checkbox } from '@/components/ui/checkbox';
import { ArrowDown, Wand2, Loader2, Calculator, Scale } from 'lucide-react';
import { ArrowDown, Wand2, Loader2, Calculator, Scale, Pin, PinOff } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -1210,8 +1210,10 @@ interface VirtualRowProps {
columns: ColumnDef<RowData>[];
fields: Field<string>[];
totalRowCount: number;
/** Whether table is scrolled horizontally - used for sticky column shadow */
isScrolledHorizontally: boolean;
/** Whether the name column sticky behavior is enabled */
nameColumnSticky: boolean;
/** Direction for sticky name column: 'left', 'right', or null (not sticky) */
stickyDirection: 'left' | 'right' | null;
}
const VirtualRow = memo(({
@@ -1221,7 +1223,8 @@ const VirtualRow = memo(({
columns,
fields,
totalRowCount,
isScrolledHorizontally,
nameColumnSticky,
stickyDirection,
}: VirtualRowProps) => {
// Subscribe to row data - this is THE subscription for all cell values in this row
const rowData = useValidationStore(
@@ -1317,13 +1320,18 @@ const VirtualRow = memo(({
<div
data-row-index={rowIndex}
className={cn(
'flex border-b absolute',
'flex absolute',
// Use box-shadow for bottom border - renders more consistently with transforms than border-b
'shadow-[inset_0_-1px_0_0_hsl(var(--border))]',
hasErrors && 'bg-destructive/5',
isSelected && 'bg-primary/5'
isSelected && 'bg-blue-100 dark:bg-blue-900/40'
)}
style={{
height: ROW_HEIGHT,
transform: `translateY(${virtualStart}px)`,
// Round to whole pixels to prevent sub-pixel rendering issues during scroll
transform: `translateY(${Math.round(virtualStart)}px)`,
// Promote to GPU layer for smoother rendering
willChange: 'transform',
// Elevate row when it has a visible AI suggestion so badge shows above next row
zIndex: hasVisibleAiSuggestion ? 10 : undefined,
}}
@@ -1331,7 +1339,7 @@ const VirtualRow = memo(({
>
{/* Selection checkbox cell */}
<div
className="px-2 py-3 border-r flex items-start justify-center"
className="px-2 py-3 flex items-start justify-center shadow-[inset_-1px_0_0_0_hsl(var(--border))]"
style={{
width: columns[0]?.size || 40,
minWidth: columns[0]?.size || 40,
@@ -1346,7 +1354,7 @@ const VirtualRow = memo(({
{/* Template column */}
<div
className="px-2 py-2 border-r flex items-start overflow-hidden"
className="px-2 py-2 flex items-start overflow-hidden shadow-[inset_-1px_0_0_0_hsl(var(--border))]"
style={{
width: TEMPLATE_COLUMN_WIDTH,
minWidth: TEMPLATE_COLUMN_WIDTH,
@@ -1400,33 +1408,50 @@ const VirtualRow = memo(({
const isNameColumn = field.key === 'name';
// Determine sticky behavior for name column
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
const stickyRight = shouldBeSticky && stickyDirection === 'right';
return (
<div
key={field.key}
data-cell-field={field.key}
className={cn(
"px-2 py-2 border-r last:border-r-0 flex items-start",
"px-2 py-2 flex items-start",
// Use box-shadow for right border - renders more consistently with transforms
// last:shadow-none removes the shadow from the last cell
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
// Name column needs overflow-visible for the floating AI suggestion badge
// Description handles AI suggestions inside its popover, so no overflow needed
isNameColumn ? "overflow-visible" : "overflow-hidden",
// Name column is sticky - needs SOLID (opaque) background that matches row state
// Uses gradient trick to composite semi-transparent tint onto solid background
// Shadow only shows when scrolled horizontally (column is actually overlaying content)
isNameColumn && "lg:sticky lg:z-10",
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
isNameColumn && (
hasErrors
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
: isSelected
? "lg:[background:linear-gradient(hsl(var(--primary)/0.05),hsl(var(--primary)/0.05)),hsl(var(--background))]"
: "lg:bg-background"
)
// Name column sticky behavior - only when enabled and scrolled appropriately
shouldBeSticky && "lg:sticky lg:z-10",
// Add left border when sticky-right since content scrolls behind from the left
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border))]",
// Directional drop shadow on the outside edge where content scrolls behind (combined with border shadow)
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
// Solid background when sticky to overlay content
// Use explicit [background:] syntax for consistent specificity
// Selection (blue) takes priority over errors (red)
shouldBeSticky && (
isSelected
? "lg:[background:#dbeafe] lg:dark:[background:hsl(221_83%_25%/0.4)]"
: hasErrors
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
: "lg:[background:hsl(var(--background))]"
),
// Make child inputs/buttons transparent when sticky + selected so blue background shows through
shouldBeSticky && isSelected && "lg:[&_input]:!bg-transparent lg:[&_button]:!bg-transparent lg:[&_textarea]:!bg-transparent"
)}
style={{
width: columnWidth,
minWidth: columnWidth,
flexShrink: 0,
...(isNameColumn && { left: NAME_COLUMN_STICKY_LEFT }),
// Position sticky left or right based on scroll direction
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
...(stickyRight && { right: 0 }),
}}
>
<CellWrapper
@@ -1462,27 +1487,77 @@ VirtualRow.displayName = 'VirtualRow';
/**
* Header checkbox component - isolated to prevent re-renders of the entire table
* When filtering is active, only selects/deselects visible (filtered) rows
*/
const HeaderCheckbox = memo(() => {
const rowCount = useValidationStore((state) => state.rows.length);
const selectedCount = useValidationStore((state) => state.selectedRows.size);
const filters = useFilters();
const allSelected = rowCount > 0 && selectedCount === rowCount;
const someSelected = selectedCount > 0 && selectedCount < rowCount;
// Compute which rows are visible based on current filters
const { visibleRowIds, visibleCount } = useMemo(() => {
const { rows, errors } = useValidationStore.getState();
const isFiltering = filters.searchText || filters.showErrorsOnly;
if (!isFiltering) {
// No filtering - all rows are visible
const ids = new Set(rows.map((row) => row.__index));
return { visibleRowIds: ids, visibleCount: rows.length };
}
// Apply filters to get visible row IDs
const ids = new Set<string>();
rows.forEach((row, index) => {
// Apply search filter
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const matches = Object.values(row).some((value) =>
String(value ?? '').toLowerCase().includes(searchLower)
);
if (!matches) return;
}
// Apply errors-only filter
if (filters.showErrorsOnly) {
const rowErrors = errors.get(index);
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
}
ids.add(row.__index);
});
return { visibleRowIds: ids, visibleCount: ids.size };
}, [filters.searchText, filters.showErrorsOnly]);
// Check selection state against visible rows only
const selectedRows = useValidationStore((state) => state.selectedRows);
const selectedVisibleCount = useMemo(() => {
let count = 0;
visibleRowIds.forEach((id) => {
if (selectedRows.has(id)) count++;
});
return count;
}, [visibleRowIds, selectedRows]);
const allVisibleSelected = visibleCount > 0 && selectedVisibleCount === visibleCount;
const someVisibleSelected = selectedVisibleCount > 0 && selectedVisibleCount < visibleCount;
const handleChange = useCallback((value: boolean | 'indeterminate') => {
const { setSelectedRows, rows } = useValidationStore.getState();
const { setSelectedRows, selectedRows: currentSelected } = useValidationStore.getState();
if (value) {
const allIds = new Set(rows.map((row) => row.__index));
setSelectedRows(allIds);
// Add all visible rows to selection (keep existing selections of non-visible rows)
const newSelection = new Set(currentSelected);
visibleRowIds.forEach((id) => newSelection.add(id));
setSelectedRows(newSelection);
} else {
setSelectedRows(new Set());
// Remove all visible rows from selection (keep selections of non-visible rows)
const newSelection = new Set(currentSelected);
visibleRowIds.forEach((id) => newSelection.delete(id));
setSelectedRows(newSelection);
}
}, []);
}, [visibleRowIds]);
return (
<Checkbox
checked={allSelected || (someSelected && 'indeterminate')}
checked={allVisibleSelected || (someVisibleSelected && 'indeterminate')}
onCheckedChange={handleChange}
/>
);
@@ -1536,20 +1611,19 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
: 'Fill empty cells with MSRP ÷ 2';
// Check if there are any cells that can be filled (called on hover)
// Now returns true if ANY row has a valid source value (allows overwriting existing values)
const checkFillableCells = useCallback(() => {
const { rows } = useValidationStore.getState();
return rows.some((row) => {
const currentValue = row[fieldKey];
const sourceValue = row[sourceField];
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
if (isEmpty && hasSource) {
if (hasSource) {
const sourceNum = parseFloat(String(sourceValue));
return !isNaN(sourceNum) && sourceNum > 0;
}
return false;
});
}, [fieldKey, sourceField]);
}, [sourceField]);
// Update fillable check on hover
const handleMouseEnter = useCallback(() => {
@@ -1563,29 +1637,28 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
// Use setState() for efficient batch update with Immer
useValidationStore.setState((draft) => {
draft.rows.forEach((row, index) => {
const currentValue = row[fieldKey];
const sourceValue = row[sourceField];
// Only fill if current field is empty and source has a value
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
// Fill if source has a value (overwrite existing values based on source)
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
if (isEmpty && hasSource) {
if (hasSource) {
const sourceNum = parseFloat(String(sourceValue));
if (!isNaN(sourceNum) && sourceNum > 0) {
let msrp = sourceNum * multiplier;
if (multiplier === 2.0) {
// For 2x: auto-adjust by ±1 cent to get to .99 if close
// For 2x: auto-adjust by ±1 cent ONLY if result ends in .99
const cents = Math.round((msrp % 1) * 100);
if (cents === 0) {
// .00 → subtract 1 cent to get .99
msrp -= 0.01;
} else if (cents === 98) {
// .98 → add 1 cent to get .99
msrp += 0.01;
if (cents === 0 || cents === 98) {
const adjustment = cents === 0 ? -0.01 : 0.01;
const adjusted = (msrp + adjustment).toFixed(2);
// Only apply if the adjusted value actually ends in .99
if (adjusted.endsWith('.99')) {
msrp = parseFloat(adjusted);
}
}
// Otherwise leave as-is
// Otherwise leave as-is (exact 2x)
} else if (roundNine) {
// For >2x with checkbox: round to nearest .X9
msrp = roundToNine(msrp);
@@ -1616,13 +1689,12 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
useValidationStore.setState((draft) => {
draft.rows.forEach((row, index) => {
const currentValue = row[fieldKey];
const sourceValue = row[sourceField];
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
// Fill if source has a value (overwrite existing values based on source)
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
if (isEmpty && hasSource) {
if (hasSource) {
const sourceNum = parseFloat(String(sourceValue));
if (!isNaN(sourceNum) && sourceNum > 0) {
draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2);
@@ -1928,6 +2000,165 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader';
/**
* DefaultValueColumnHeader Component
*
* Renders a column header with a hover button that sets a default value for all rows.
* Used for Tax Category ("Not Specifically Set") and Shipping Restrictions ("None").
*
* PERFORMANCE: Uses local hover state and getState() for bulk updates.
*/
interface DefaultValueColumnHeaderProps {
fieldKey: 'tax_cat' | 'ship_restrictions';
label: string;
isRequired: boolean;
}
const DEFAULT_VALUE_CONFIG: Record<string, { value: string; displayName: string; buttonLabel: string }> = {
tax_cat: { value: '0', displayName: 'Not Specifically Set', buttonLabel: 'Set All Default' },
ship_restrictions: { value: '0', displayName: 'None', buttonLabel: 'Set All None' },
};
const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultValueColumnHeaderProps) => {
const [isHovered, setIsHovered] = useState(false);
const [hasEmptyCells, setHasEmptyCells] = useState(false);
const config = DEFAULT_VALUE_CONFIG[fieldKey];
// Check if there are any empty cells that can be filled
const checkEmptyCells = useCallback(() => {
const { rows } = useValidationStore.getState();
return rows.some((row) => {
const value = row[fieldKey];
return value === undefined || value === null || value === '';
});
}, [fieldKey]);
// Update empty check on hover
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
setHasEmptyCells(checkEmptyCells());
}, [checkEmptyCells]);
const handleSetDefault = useCallback(() => {
const updatedIndices: number[] = [];
useValidationStore.setState((draft) => {
draft.rows.forEach((row, index) => {
const value = row[fieldKey];
const isEmpty = value === undefined || value === null || value === '';
if (isEmpty) {
draft.rows[index][fieldKey] = config.value;
updatedIndices.push(index);
}
});
});
if (updatedIndices.length > 0) {
const { clearFieldError } = useValidationStore.getState();
updatedIndices.forEach((rowIndex) => {
clearFieldError(rowIndex, fieldKey);
});
toast.success(`Set ${updatedIndices.length} row${updatedIndices.length === 1 ? '' : 's'} to "${config.displayName}"`);
}
}, [fieldKey, config]);
return (
<div
className="flex items-center gap-1 truncate w-full group relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setIsHovered(false)}
>
<span className="truncate">{label}</span>
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
{isHovered && hasEmptyCells && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSetDefault();
}}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2',
'flex items-center gap-0.5',
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'transition-opacity whitespace-nowrap'
)}
>
<Wand2 className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Fill empty cells with "{config.displayName}"</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
});
DefaultValueColumnHeader.displayName = 'DefaultValueColumnHeader';
/**
* NameColumnHeader Component
*
* Renders the Name column header with a sticky toggle button.
* Pin icon toggles whether the name column sticks to edges when scrolling.
*/
interface NameColumnHeaderProps {
label: string;
isRequired: boolean;
isSticky: boolean;
onToggleSticky: () => void;
}
const NameColumnHeader = memo(({ label, isRequired, isSticky, onToggleSticky }: NameColumnHeaderProps) => {
return (
<div className="flex items-center gap-1 truncate w-full group relative">
<span className="truncate">{label}</span>
{isRequired && (
<span className="text-destructive flex-shrink-0">*</span>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleSticky();
}}
className={cn(
'ml-auto flex items-center justify-center w-6 h-6 rounded',
'transition-colors',
isSticky
? 'text-primary bg-primary/10 hover:bg-primary/20'
: 'text-muted-foreground hover:bg-muted'
)}
>
{isSticky ? <Pin className="h-3.5 w-3.5" /> : <PinOff className="h-3.5 w-3.5" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{isSticky ? 'Unpin column' : 'Pin column'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
});
NameColumnHeader.displayName = 'NameColumnHeader';
/**
* Main table component
*
@@ -1958,18 +2189,46 @@ export const ValidationTable = () => {
return offset;
}, [fields]);
// Track horizontal scroll for sticky column shadow
const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false);
// Name column sticky toggle - when enabled, column sticks to left/right edges based on scroll
const [nameColumnSticky, setNameColumnSticky] = useState(true);
// Sync header scroll with body scroll + track horizontal scroll state
// Track scroll direction relative to name column: 'left' (stick to left) or 'right' (stick to right)
const [stickyDirection, setStickyDirection] = useState<'left' | 'right' | null>(null);
// Calculate name column width
const nameColumnWidth = useMemo(() => {
const nameField = fields.find(f => f.key === 'name');
return nameField?.width || 400;
}, [fields]);
// Sync header scroll with body scroll + track sticky direction
const handleScroll = useCallback(() => {
if (tableContainerRef.current && headerRef.current) {
const scrollLeft = tableContainerRef.current.scrollLeft;
const viewportWidth = tableContainerRef.current.clientWidth;
headerRef.current.scrollLeft = scrollLeft;
// Only show shadow when scrolled past the name column's natural position
setIsScrolledHorizontally(scrollLeft > nameColumnLeftOffset);
// Calculate name column's position relative to viewport
const namePositionInViewport = nameColumnLeftOffset - scrollLeft;
const nameRightEdge = namePositionInViewport + nameColumnWidth;
// Determine sticky direction for name column
if (nameColumnSticky) {
if (scrollLeft > nameColumnLeftOffset) {
// Scrolled right past name column - stick to left
setStickyDirection('left');
} else if (nameRightEdge > viewportWidth) {
// Name column extends beyond viewport to the right - stick to right
setStickyDirection('right');
} else {
// Name column is fully visible - no sticky needed
setStickyDirection(null);
}
} else {
setStickyDirection(null);
}
}
}, [nameColumnLeftOffset]);
}, [nameColumnLeftOffset, nameColumnWidth, nameColumnSticky]);
// Compute filtered indices AND row IDs in a single pass
// This avoids calling getState() during render for each row
@@ -2012,6 +2271,11 @@ export const ValidationTable = () => {
return { filteredIndices: indices, rowIdMap: idMap };
}, [rowCount, filters.searchText, filters.showErrorsOnly]);
// Toggle for sticky name column
const toggleNameColumnSticky = useCallback(() => {
setNameColumnSticky(prev => !prev);
}, []);
// Build columns - ONLY depends on fields, NOT selection state
// Selection state is handled by isolated HeaderCheckbox component
const columns = useMemo<ColumnDef<RowData>[]>(() => {
@@ -2034,9 +2298,21 @@ export const ValidationTable = () => {
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height';
const isDefaultValueColumn = field.key === 'tax_cat' || field.key === 'ship_restrictions';
const isNameColumn = field.key === 'name';
// Determine which header component to render
const renderHeader = () => {
if (isNameColumn) {
return (
<NameColumnHeader
label={field.label}
isRequired={isRequired}
isSticky={nameColumnSticky}
onToggleSticky={toggleNameColumnSticky}
/>
);
}
if (isPriceColumn) {
return (
<PriceColumnHeader
@@ -2055,6 +2331,15 @@ export const ValidationTable = () => {
/>
);
}
if (isDefaultValueColumn) {
return (
<DefaultValueColumnHeader
fieldKey={field.key as 'tax_cat' | 'ship_restrictions'}
label={field.label}
isRequired={isRequired}
/>
);
}
return (
<div className="flex items-center gap-1 truncate">
<span className="truncate">{field.label}</span>
@@ -2073,7 +2358,7 @@ export const ValidationTable = () => {
});
return [selectionColumn, templateColumn, ...dataColumns];
}, [fields]); // CRITICAL: No selection-related deps!
}, [fields, nameColumnSticky, toggleNameColumnSticky]); // Added nameColumnSticky dependencies
// Calculate total table width for horizontal scrolling
const totalTableWidth = useMemo(() => {
@@ -2109,20 +2394,31 @@ export const ValidationTable = () => {
>
{columns.map((column, index) => {
const isNameColumn = column.id === 'name';
// Determine sticky behavior for header name column
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
const stickyRight = shouldBeSticky && stickyDirection === 'right';
return (
<div
key={column.id || index}
className={cn(
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0",
// Sticky header needs solid background matching the row's bg-muted/50 appearance
isNameColumn && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground",
// Use box-shadow for right border - renders more consistently
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
// Sticky header - only when enabled and scrolled appropriately
shouldBeSticky && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
// Directional shadow on the outside edge where content scrolls behind (combined with border shadow)
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
)}
style={{
width: column.size || 150,
minWidth: column.size || 150,
flexShrink: 0,
...(isNameColumn && { left: NAME_COLUMN_STICKY_LEFT }),
// Position sticky left or right based on scroll direction
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
...(stickyRight && { right: 0 }),
}}
>
{typeof column.header === 'function'
@@ -2159,7 +2455,8 @@ export const ValidationTable = () => {
columns={columns}
fields={fields}
totalRowCount={rowCount}
isScrolledHorizontally={isScrolledHorizontally}
nameColumnSticky={nameColumnSticky}
stickyDirection={stickyDirection}
/>
);
})}

View File

@@ -10,7 +10,7 @@
*/
import { useMemo, useCallback, useState } from 'react';
import { Search, Plus, FolderPlus, Edit3 } from 'lucide-react';
import { Search, Plus, FolderPlus, Edit3, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
@@ -39,6 +39,38 @@ export const ValidationToolbar = ({
const filters = useFilters();
const fields = useFields();
// Compute filtered count when filtering is active
const filteredCount = useMemo(() => {
const isFiltering = filters.searchText || filters.showErrorsOnly;
if (!isFiltering) return rowCount;
const { rows, errors } = useValidationStore.getState();
let count = 0;
rows.forEach((row, index) => {
// Apply search filter
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const matches = Object.values(row).some((value) =>
String(value ?? '').toLowerCase().includes(searchLower)
);
if (!matches) return;
}
// Apply errors-only filter
if (filters.showErrorsOnly) {
const rowErrors = errors.get(index);
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
}
count++;
});
return count;
}, [filters.searchText, filters.showErrorsOnly, rowCount]);
const isFiltering = filters.searchText || filters.showErrorsOnly;
// State for the product search template dialog
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
@@ -112,12 +144,24 @@ export const ValidationToolbar = ({
placeholder="Filter products..."
value={filters.searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9"
className={filters.searchText ? "pl-9 pr-8" : "pl-9"}
/>
{filters.searchText && (
<button
type="button"
onClick={() => setSearchText('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Product count */}
<span className="text-sm text-muted-foreground">{rowCount} products</span>
<span className="text-sm text-muted-foreground">
{isFiltering ? `${filteredCount} of ${rowCount} products shown` : `${rowCount} products`}
</span>
{/* Action buttons */}
<div className="flex items-center gap-2 ml-auto">

View File

@@ -3,9 +3,14 @@
*
* Editable input cell for text, numbers, and price values.
* Memoized to prevent unnecessary re-renders when parent table updates.
*
* PRICE PRECISION: For price fields, we store FULL precision internally
* (e.g., "3.625") but display rounded to 2 decimals when not focused.
* This allows calculations like 2x to use full precision while showing
* user-friendly rounded values in the UI.
*/
import { useState, useCallback, useEffect, useRef, memo } from 'react';
import { useState, useCallback, useEffect, useRef, memo, useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { AlertCircle } from 'lucide-react';
import {
@@ -21,6 +26,17 @@ import type { ValidationError } from '../../store/types';
import { ErrorType } from '../../store/types';
import { useValidationStore } from '../../store/validationStore';
/**
* Format a price value for display (2 decimal places)
* Returns the original string if it's not a valid number
*/
const formatPriceForDisplay = (value: string): string => {
if (!value) return value;
const num = parseFloat(value);
if (isNaN(num)) return value;
return num.toFixed(2);
};
/** Time window (ms) during which this cell should not focus after a popover closes */
const POPOVER_CLOSE_DELAY = 150;
@@ -43,10 +59,14 @@ const InputCellComponent = ({
errors,
onBlur,
}: InputCellProps) => {
// Store the full precision value internally
const [localValue, setLocalValue] = useState(String(value ?? ''));
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Check if this is a price field
const isPriceField = 'price' in field.fieldType && field.fieldType.price;
// Get store state for coordinating with popover close behavior
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
@@ -57,6 +77,14 @@ const InputCellComponent = ({
}
}, [value, isFocused]);
// For price fields: show formatted value when not focused, full precision when focused
const displayValue = useMemo(() => {
if (isPriceField && !isFocused && localValue) {
return formatPriceForDisplay(localValue);
}
return localValue;
}, [isPriceField, isFocused, localValue]);
// PERFORMANCE: Only update local state while typing, NOT the store
// The store is updated on blur, which prevents thousands of subscription
// checks per keystroke
@@ -86,23 +114,13 @@ const InputCellComponent = ({
}, [cellPopoverClosedAt]);
// Update store only on blur - this is when validation runs too
// Round price fields to 2 decimal places
// IMPORTANT: We store FULL precision for price fields to allow accurate calculations
// The display formatting happens separately via displayValue
const handleBlur = useCallback(() => {
setIsFocused(false);
let valueToSave = localValue;
// Round price fields to 2 decimal places
if ('price' in field.fieldType && field.fieldType.price && localValue) {
const numValue = parseFloat(localValue);
if (!isNaN(numValue)) {
valueToSave = numValue.toFixed(2);
setLocalValue(valueToSave);
}
}
onBlur(valueToSave);
}, [localValue, onBlur, field.fieldType]);
// Store the full precision value - no rounding here
onBlur(localValue);
}, [localValue, onBlur]);
// Process errors - show icon only for non-required errors when field has value
// Don't show error icon while user is actively editing (focused)
@@ -129,7 +147,7 @@ const InputCellComponent = ({
<div className="relative w-full">
<Input
ref={inputRef}
value={localValue}
value={displayValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}

View File

@@ -57,7 +57,7 @@ const MultilineInputComponent = ({
value,
isValidating,
errors,
onChange,
onChange: _onChange, // Unused - onBlur handles both update and validation
onBlur,
aiSuggestion,
isAiValidating,
@@ -68,10 +68,15 @@ const MultilineInputComponent = ({
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
const [editedSuggestion, setEditedSuggestion] = useState('');
const [popoverWidth, setPopoverWidth] = useState(400);
const cellRef = useRef<HTMLDivElement>(null);
const preventReopenRef = useRef(false);
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
const intentionalCloseRef = useRef(false);
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
// Tracks the value when popover opened, to detect actual changes
const initialEditValueRef = useRef('');
// Get store state and actions for coordinating popover close behavior across cells
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
@@ -112,11 +117,44 @@ const MultilineInputComponent = ({
}
}, [aiSuggestion?.suggestion]);
// Auto-resize a textarea to fit its content
const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}, []);
// Auto-resize main textarea when value changes or popover opens
useEffect(() => {
if (popoverOpen) {
// Small delay to ensure textarea is rendered
requestAnimationFrame(() => {
autoResizeTextarea(mainTextareaRef.current);
});
}
}, [popoverOpen, editValue, autoResizeTextarea]);
// Auto-resize suggestion textarea when expanded or value changes
useEffect(() => {
if (aiSuggestionExpanded) {
requestAnimationFrame(() => {
autoResizeTextarea(suggestionTextareaRef.current);
});
}
}, [aiSuggestionExpanded, editedSuggestion, autoResizeTextarea]);
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
const wasPopoverRecentlyClosed = useCallback(() => {
return Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY;
}, [cellPopoverClosedAt]);
// Calculate and set popover width based on cell width
const updatePopoverWidth = useCallback(() => {
if (cellRef.current) {
setPopoverWidth(Math.max(cellRef.current.offsetWidth, 200));
}
}, []);
// Handle trigger click to toggle the popover
const handleTriggerClick = useCallback(
(e: React.MouseEvent) => {
@@ -136,23 +174,26 @@ const MultilineInputComponent = ({
// Only process if not already open
if (!popoverOpen) {
updatePopoverWidth();
setPopoverOpen(true);
// Initialize edit value from the current display
setEditValue(localDisplayValue || String(value ?? ''));
// Initialize edit value from the current display and track it for change detection
const initValue = localDisplayValue || String(value ?? '');
setEditValue(initValue);
initialEditValueRef.current = initValue;
}
},
[popoverOpen, value, localDisplayValue, wasPopoverRecentlyClosed]
[popoverOpen, value, localDisplayValue, wasPopoverRecentlyClosed, updatePopoverWidth]
);
// Handle immediate close of popover (used by close button and actions - intentional closes)
const handleClosePopover = useCallback(() => {
// Only process if we have changes
if (editValue !== value || editValue !== localDisplayValue) {
// Only process if the user actually changed the value
if (editValue !== initialEditValueRef.current) {
// Update local display immediately
setLocalDisplayValue(editValue);
// Queue up the change
onChange(editValue);
// onBlur handles both cell update and validation (don't call onChange first
// as it would update the store before onBlur can capture previousValue)
onBlur(editValue);
}
@@ -168,7 +209,7 @@ const MultilineInputComponent = ({
setTimeout(() => {
preventReopenRef.current = false;
}, 100);
}, [editValue, value, localDisplayValue, onChange, onBlur]);
}, [editValue, onBlur]);
// Handle popover open/close (called by Radix for click-outside and escape key)
const handlePopoverOpenChange = useCallback(
@@ -183,10 +224,10 @@ const MultilineInputComponent = ({
return;
}
// This is a click-outside close - save changes and signal other cells
if (editValue !== value || editValue !== localDisplayValue) {
// This is a click-outside close - only save if user actually changed the value
if (editValue !== initialEditValueRef.current) {
setLocalDisplayValue(editValue);
onChange(editValue);
// onBlur handles both cell update and validation
onBlur(editValue);
}
@@ -205,28 +246,33 @@ const MultilineInputComponent = ({
if (wasPopoverRecentlyClosed()) {
return;
}
setEditValue(localDisplayValue || String(value ?? ''));
updatePopoverWidth();
// Initialize edit value and track it for change detection
const initValue = localDisplayValue || String(value ?? '');
setEditValue(initValue);
initialEditValueRef.current = initValue;
setPopoverOpen(true);
}
},
[value, popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onChange, onBlur, setCellPopoverClosed]
[popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onBlur, setCellPopoverClosed, updatePopoverWidth, value]
);
// Handle direct input change
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditValue(e.target.value);
}, []);
autoResizeTextarea(e.target);
}, [autoResizeTextarea]);
// Handle accepting the AI suggestion (possibly edited)
const handleAcceptSuggestion = useCallback(() => {
// Use the edited suggestion
setEditValue(editedSuggestion);
setLocalDisplayValue(editedSuggestion);
onChange(editedSuggestion);
// onBlur handles both cell update and validation
onBlur(editedSuggestion);
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
setAiSuggestionExpanded(false);
}, [editedSuggestion, onChange, onBlur, onDismissAiSuggestion]);
}, [editedSuggestion, onBlur, onDismissAiSuggestion]);
// Handle dismissing the AI suggestion
const handleDismissSuggestion = useCallback(() => {
@@ -243,7 +289,7 @@ const MultilineInputComponent = ({
return (
<div className="w-full relative" ref={cellRef}>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange} modal>
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
@@ -270,9 +316,13 @@ const MultilineInputComponent = ({
if (wasPopoverRecentlyClosed()) {
return;
}
updatePopoverWidth();
setAiSuggestionExpanded(true);
setPopoverOpen(true);
setEditValue(localDisplayValue || String(value ?? ''));
// Initialize edit value and track it for change detection
const initValue = localDisplayValue || String(value ?? '');
setEditValue(initValue);
initialEditValueRef.current = initValue;
}}
className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors"
title="View AI suggestion"
@@ -303,7 +353,7 @@ const MultilineInputComponent = ({
</TooltipProvider>
<PopoverContent
className="p-0 shadow-lg rounded-md"
style={{ width: Math.max(cellRef.current?.offsetWidth || 400, 400) }}
style={{ width: popoverWidth }}
align="start"
side="bottom"
alignOffset={0}
@@ -322,10 +372,11 @@ const MultilineInputComponent = ({
{/* Main textarea */}
<Textarea
ref={mainTextareaRef}
value={editValue}
onChange={handleChange}
onWheel={handleTextareaWheel}
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none pl-2 pr-4 py-1 resize-y"
className="overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none pl-2 pr-4 py-1 resize-y min-h-[65px]"
placeholder={`Enter ${field.label || 'text'}...`}
autoFocus
/>
@@ -379,10 +430,14 @@ const MultilineInputComponent = ({
Suggested (editable):
</div>
<Textarea
ref={suggestionTextareaRef}
value={editedSuggestion}
onChange={(e) => setEditedSuggestion(e.target.value)}
onChange={(e) => {
setEditedSuggestion(e.target.value);
autoResizeTextarea(e.target);
}}
onWheel={handleTextareaWheel}
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y"
className="min-h-[80px] overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y"
/>
</div>
@@ -403,7 +458,7 @@ const MultilineInputComponent = ({
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
onClick={handleDismissSuggestion}
>
Dismiss
Ignore
</Button>
</div>
</div>

View File

@@ -185,22 +185,14 @@ 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, errors: existingErrors, setBulkValidationResults, updateCell: updateCellAction } = useValidationStore.getState();
const { rows: currentRows, fields: currentFields, errors: existingErrors, setBulkValidationResults } = 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];
@@ -221,20 +213,11 @@ export const useValidationActions = () => {
});
}
// 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);
}
}
}
}
// NOTE: We no longer round price fields on initial load.
// Full precision is preserved internally (e.g., "3.625") for accurate calculations.
// - Display: InputCell shows 2 decimals when not focused
// - Calculations: 2x button uses full precision
// - API submission: getCleanedData() formats to 2 decimals
// Validate each field
for (const field of currentFields) {

View File

@@ -8,7 +8,7 @@
* 4. Renders the ValidationContainer once initialized
*/
import { useEffect, useRef, useDeferredValue } from 'react';
import { useEffect, useRef, useDeferredValue, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useValidationStore } from './store/validationStore';
import { useInitPhase, useIsReady } from './store/selectors';
@@ -24,6 +24,36 @@ import config from '@/config';
import type { ValidationStepProps } from './store/types';
import type { Field, SelectOption } from '../../types';
/**
* Create a fingerprint of the data to detect changes.
* This is used to determine if we need to re-initialize the store
* when navigating back to this step with potentially modified data.
*/
const createDataFingerprint = (data: Record<string, unknown>[]): string => {
// Sample key fields that are likely to change when user modifies data in previous steps
const keyFields = ['supplier', 'company', 'line', 'subline', 'name', 'upc', 'item_number'];
// Create a simple hash from first few rows + last row + count
const sampleSize = Math.min(3, data.length);
const samples: string[] = [];
// First few rows
for (let i = 0; i < sampleSize; i++) {
const row = data[i];
const values = keyFields.map(k => String(row[k] ?? '')).join('|');
samples.push(values);
}
// Last row (if different from samples)
if (data.length > sampleSize) {
const lastRow = data[data.length - 1];
const values = keyFields.map(k => String(lastRow[k] ?? '')).join('|');
samples.push(values);
}
return `${data.length}:${samples.join(';;')}`;
};
/**
* Fetch field options from the API
*/
@@ -105,6 +135,7 @@ export const ValidationStep = ({
const templatesLoadedRef = useRef(false);
const upcValidationStartedRef = useRef(false);
const fieldValidationStartedRef = useRef(false);
const lastDataFingerprintRef = useRef<string | null>(null);
// Debug logging
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
@@ -132,12 +163,25 @@ export const ValidationStep = ({
retry: 2,
});
// Get current store state to check if we're returning to an already-initialized store
const storeRows = useValidationStore((state) => state.rows);
// Create a fingerprint of the incoming data to detect changes
const dataFingerprint = useMemo(() => createDataFingerprint(initialData), [initialData]);
// Initialize store with data
useEffect(() => {
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
console.log('[ValidationStep] Data fingerprint:', dataFingerprint, 'Last fingerprint:', lastDataFingerprintRef.current);
// Check if data has changed since last initialization
const dataHasChanged = lastDataFingerprintRef.current !== null && lastDataFingerprintRef.current !== dataFingerprint;
if (dataHasChanged) {
console.log('[ValidationStep] Data has changed - forcing re-initialization');
// Reset all refs to allow re-initialization
initStartedRef.current = false;
templatesLoadedRef.current = false;
upcValidationStartedRef.current = false;
fieldValidationStartedRef.current = false;
}
// Skip if already initialized (check both ref AND store state)
// The ref prevents double-init within the same mount cycle
@@ -148,17 +192,16 @@ export const ValidationStep = ({
}
// IMPORTANT: Skip initialization if we're returning to an already-ready store
// This happens when navigating back from ImageUploadStep - the store still has
// all the validated data, so we don't need to re-run the initialization sequence.
// We check that the store is 'ready' and has matching row count to avoid
// false positives from stale store data.
if (initPhase === 'ready' && storeRows.length === initialData.length && storeRows.length > 0) {
console.log('[ValidationStep] Skipping init - returning to already-ready store with', storeRows.length, 'rows');
// with the SAME data. This happens when navigating back from ImageUploadStep.
// We compare fingerprints to detect if the data has actually changed.
if (initPhase === 'ready' && !dataHasChanged && lastDataFingerprintRef.current === dataFingerprint) {
console.log('[ValidationStep] Skipping init - returning to already-ready store with same data');
initStartedRef.current = true;
return;
}
initStartedRef.current = true;
lastDataFingerprintRef.current = dataFingerprint;
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
@@ -172,7 +215,7 @@ export const ValidationStep = ({
console.log('[ValidationStep] Calling initialize()');
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
console.log('[ValidationStep] initialize() called');
}, [initialData, file, initialize, initPhase, storeRows.length]);
}, [initialData, file, initialize, initPhase, dataFingerprint]);
// Update fields when options are loaded
// CRITICAL: Check store state (not ref) because initialize() resets the store

View File

@@ -215,6 +215,20 @@ export const useValidationStore = create<ValidationStore>()(
deleteRows: (rowIndexes: number[]) => {
set((state) => {
// Collect row IDs to remove from selection before deleting
const rowIdsToDelete = new Set<string>();
rowIndexes.forEach((index) => {
if (index >= 0 && index < state.rows.length) {
const rowId = state.rows[index].__index;
if (rowId) rowIdsToDelete.add(rowId);
}
});
// Clear these rows from selectedRows
rowIdsToDelete.forEach((rowId) => {
state.selectedRows.delete(rowId);
});
// Sort descending to delete from end first (preserves indices)
const sorted = [...rowIndexes].sort((a, b) => b - a);
sorted.forEach((index) => {
@@ -949,9 +963,25 @@ export const useValidationStore = create<ValidationStore>()(
getCleanedData: (): CleanRowData[] => {
const { rows } = get();
// Price fields that should be formatted to 2 decimal places for API submission
const priceFields = ['msrp', 'cost_each'];
return rows.map((row) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __index, __template, __original, __corrected, __changes, __aiSupplemental, ...cleanRow } = row;
// Format price fields to 2 decimal places for API submission
// This ensures consistent precision while internal storage keeps full precision for calculations
for (const field of priceFields) {
const value = cleanRow[field];
if (value !== undefined && value !== null && value !== '') {
const num = parseFloat(String(value));
if (!isNaN(num)) {
cleanRow[field] = num.toFixed(2);
}
}
}
return cleanRow as CleanRowData;
});
},

View File

@@ -34,10 +34,16 @@ export function calculateEanCheckDigit(eanBody: string): number {
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
const value = rawValue ?? '';
const str = typeof value === 'string' ? value.trim() : String(value);
const originalStr = typeof value === 'string' ? value : String(value);
// Strip ALL whitespace (spaces, tabs, etc.) from UPC values - not just leading/trailing
const str = originalStr.replace(/\s+/g, '');
// Track if whitespace was stripped (this alone means we changed the value)
const whitespaceStripped = str !== originalStr;
if (str === '' || !NUMERIC_REGEX.test(str)) {
return { corrected: str, changed: false };
// Return stripped version even if not numeric (so non-numeric values still get spaces removed)
return { corrected: str, changed: whitespaceStripped };
}
if (str.length === 11) {
@@ -49,15 +55,18 @@ export function correctUpcValue(rawValue: unknown): { corrected: string; changed
const body = str.slice(0, 11);
const check = calculateUpcCheckDigit(body);
const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str };
// Changed if whitespace was stripped OR if check digit was corrected
return { corrected, changed: whitespaceStripped || corrected !== str };
}
if (str.length === 13) {
const body = str.slice(0, 12);
const check = calculateEanCheckDigit(body);
const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str };
// Changed if whitespace was stripped OR if check digit was corrected
return { corrected, changed: whitespaceStripped || corrected !== str };
}
return { corrected: str, changed: false };
// For other lengths, return stripped value
return { corrected: str, changed: whitespaceStripped };
}

View File

@@ -2,7 +2,7 @@ const isDev = import.meta.env.DEV;
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
// Use proxy paths when on inventory domains to avoid CORS
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.acot.site' || window.location.hostname === 'acot.site');
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.tools.acherryontop.com' || window.location.hostname === 'tools.acherryontop.com');
const liveDashboardConfig = {
auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth',

View File

@@ -90,31 +90,31 @@ export default defineConfig(({ mode }) => {
cookieDomainRewrite: "localhost",
},
"/api/aircall": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
"/api/klaviyo": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
"/api/meta": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
"/api/gorgias": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
"/api/dashboard-analytics": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
cookieDomainRewrite: {
@@ -122,25 +122,25 @@ export default defineConfig(({ mode }) => {
},
},
"/api/typeform": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
"/api/acot": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
"/api/clarity": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
"/api": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
ws: true,
@@ -161,14 +161,14 @@ export default defineConfig(({ mode }) => {
},
},
"/dashboard-auth": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
ws: true,
rewrite: (path) => path.replace("/dashboard-auth", "/auth"),
},
"/auth-inv": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
ws: true,
@@ -195,7 +195,7 @@ export default defineConfig(({ mode }) => {
},
},
"/chat-api": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
ws: true,
@@ -216,7 +216,7 @@ export default defineConfig(({ mode }) => {
},
},
"/uploads": {
target: "https://acot.site",
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,