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 // 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 // Make sure url starts with / for path
const path = url.startsWith('/') ? url : `/${url}`; const path = url.startsWith('/') ? url : `/${url}`;
return `${baseUrl}${path}`; return `${baseUrl}${path}`;

View File

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

View File

@@ -30,7 +30,14 @@ export const Steps = () => {
const onClickStep = (stepIndex: number) => { const onClickStep = (stepIndex: number) => {
const type = stepIndexToStepType(stepIndex) 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 if (historyIdx === -1) return
const nextHistory = history.current.slice(0, historyIdx + 1) const nextHistory = history.current.slice(0, historyIdx + 1)
history.current = nextHistory history.current = nextHistory
@@ -39,7 +46,14 @@ export const Steps = () => {
} }
const onBack = () => { 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) => { const onNext = (v: StepState) => {

View File

@@ -123,11 +123,12 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
// Keep track of global selections across steps // Keep track of global selections across steps
const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>( const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>(
state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns
? state.globalSelections ? state.globalSelections
: undefined : undefined
) )
switch (state.type) { switch (state.type) {
case StepType.upload: case StepType.upload:
return ( return (

View File

@@ -16,7 +16,7 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Checkbox } from '@/components/ui/checkbox'; 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -1210,8 +1210,10 @@ interface VirtualRowProps {
columns: ColumnDef<RowData>[]; columns: ColumnDef<RowData>[];
fields: Field<string>[]; fields: Field<string>[];
totalRowCount: number; totalRowCount: number;
/** Whether table is scrolled horizontally - used for sticky column shadow */ /** Whether the name column sticky behavior is enabled */
isScrolledHorizontally: boolean; nameColumnSticky: boolean;
/** Direction for sticky name column: 'left', 'right', or null (not sticky) */
stickyDirection: 'left' | 'right' | null;
} }
const VirtualRow = memo(({ const VirtualRow = memo(({
@@ -1221,7 +1223,8 @@ const VirtualRow = memo(({
columns, columns,
fields, fields,
totalRowCount, totalRowCount,
isScrolledHorizontally, nameColumnSticky,
stickyDirection,
}: VirtualRowProps) => { }: VirtualRowProps) => {
// Subscribe to row data - this is THE subscription for all cell values in this row // Subscribe to row data - this is THE subscription for all cell values in this row
const rowData = useValidationStore( const rowData = useValidationStore(
@@ -1317,13 +1320,18 @@ const VirtualRow = memo(({
<div <div
data-row-index={rowIndex} data-row-index={rowIndex}
className={cn( 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', hasErrors && 'bg-destructive/5',
isSelected && 'bg-primary/5' isSelected && 'bg-blue-100 dark:bg-blue-900/40'
)} )}
style={{ style={{
height: ROW_HEIGHT, 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 // Elevate row when it has a visible AI suggestion so badge shows above next row
zIndex: hasVisibleAiSuggestion ? 10 : undefined, zIndex: hasVisibleAiSuggestion ? 10 : undefined,
}} }}
@@ -1331,7 +1339,7 @@ const VirtualRow = memo(({
> >
{/* Selection checkbox cell */} {/* Selection checkbox cell */}
<div <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={{ style={{
width: columns[0]?.size || 40, width: columns[0]?.size || 40,
minWidth: columns[0]?.size || 40, minWidth: columns[0]?.size || 40,
@@ -1346,7 +1354,7 @@ const VirtualRow = memo(({
{/* Template column */} {/* Template column */}
<div <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={{ style={{
width: TEMPLATE_COLUMN_WIDTH, width: TEMPLATE_COLUMN_WIDTH,
minWidth: TEMPLATE_COLUMN_WIDTH, minWidth: TEMPLATE_COLUMN_WIDTH,
@@ -1400,33 +1408,50 @@ const VirtualRow = memo(({
const isNameColumn = field.key === 'name'; 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 ( return (
<div <div
key={field.key} key={field.key}
data-cell-field={field.key} data-cell-field={field.key}
className={cn( 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 // Name column needs overflow-visible for the floating AI suggestion badge
// Description handles AI suggestions inside its popover, so no overflow needed // Description handles AI suggestions inside its popover, so no overflow needed
isNameColumn ? "overflow-visible" : "overflow-hidden", isNameColumn ? "overflow-visible" : "overflow-hidden",
// Name column is sticky - needs SOLID (opaque) background that matches row state // Name column sticky behavior - only when enabled and scrolled appropriately
// Uses gradient trick to composite semi-transparent tint onto solid background shouldBeSticky && "lg:sticky lg:z-10",
// Shadow only shows when scrolled horizontally (column is actually overlaying content) // Add left border when sticky-right since content scrolls behind from the left
isNameColumn && "lg:sticky lg:z-10", stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border))]",
isNameColumn && isScrolledHorizontally && "lg:shadow-md", // Directional drop shadow on the outside edge where content scrolls behind (combined with border shadow)
isNameColumn && ( stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
hasErrors stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]" // Solid background when sticky to overlay content
: isSelected // Use explicit [background:] syntax for consistent specificity
? "lg:[background:linear-gradient(hsl(var(--primary)/0.05),hsl(var(--primary)/0.05)),hsl(var(--background))]" // Selection (blue) takes priority over errors (red)
: "lg:bg-background" 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={{ style={{
width: columnWidth, width: columnWidth,
minWidth: columnWidth, minWidth: columnWidth,
flexShrink: 0, 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 <CellWrapper
@@ -1462,27 +1487,77 @@ VirtualRow.displayName = 'VirtualRow';
/** /**
* Header checkbox component - isolated to prevent re-renders of the entire table * 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 HeaderCheckbox = memo(() => {
const rowCount = useValidationStore((state) => state.rows.length); const filters = useFilters();
const selectedCount = useValidationStore((state) => state.selectedRows.size);
const allSelected = rowCount > 0 && selectedCount === rowCount; // Compute which rows are visible based on current filters
const someSelected = selectedCount > 0 && selectedCount < rowCount; 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 handleChange = useCallback((value: boolean | 'indeterminate') => {
const { setSelectedRows, rows } = useValidationStore.getState(); const { setSelectedRows, selectedRows: currentSelected } = useValidationStore.getState();
if (value) { if (value) {
const allIds = new Set(rows.map((row) => row.__index)); // Add all visible rows to selection (keep existing selections of non-visible rows)
setSelectedRows(allIds); const newSelection = new Set(currentSelected);
visibleRowIds.forEach((id) => newSelection.add(id));
setSelectedRows(newSelection);
} else { } 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 ( return (
<Checkbox <Checkbox
checked={allSelected || (someSelected && 'indeterminate')} checked={allVisibleSelected || (someVisibleSelected && 'indeterminate')}
onCheckedChange={handleChange} onCheckedChange={handleChange}
/> />
); );
@@ -1536,20 +1611,19 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
: 'Fill empty cells with MSRP ÷ 2'; : 'Fill empty cells with MSRP ÷ 2';
// Check if there are any cells that can be filled (called on hover) // 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 checkFillableCells = useCallback(() => {
const { rows } = useValidationStore.getState(); const { rows } = useValidationStore.getState();
return rows.some((row) => { return rows.some((row) => {
const currentValue = row[fieldKey];
const sourceValue = row[sourceField]; const sourceValue = row[sourceField];
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== ''; const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
if (isEmpty && hasSource) { if (hasSource) {
const sourceNum = parseFloat(String(sourceValue)); const sourceNum = parseFloat(String(sourceValue));
return !isNaN(sourceNum) && sourceNum > 0; return !isNaN(sourceNum) && sourceNum > 0;
} }
return false; return false;
}); });
}, [fieldKey, sourceField]); }, [sourceField]);
// Update fillable check on hover // Update fillable check on hover
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
@@ -1563,29 +1637,28 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
// Use setState() for efficient batch update with Immer // Use setState() for efficient batch update with Immer
useValidationStore.setState((draft) => { useValidationStore.setState((draft) => {
draft.rows.forEach((row, index) => { draft.rows.forEach((row, index) => {
const currentValue = row[fieldKey];
const sourceValue = row[sourceField]; const sourceValue = row[sourceField];
// Only fill if current field is empty and source has a value // Fill if source has a value (overwrite existing values based on source)
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== ''; const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
if (isEmpty && hasSource) { if (hasSource) {
const sourceNum = parseFloat(String(sourceValue)); const sourceNum = parseFloat(String(sourceValue));
if (!isNaN(sourceNum) && sourceNum > 0) { if (!isNaN(sourceNum) && sourceNum > 0) {
let msrp = sourceNum * multiplier; let msrp = sourceNum * multiplier;
if (multiplier === 2.0) { 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); const cents = Math.round((msrp % 1) * 100);
if (cents === 0) { if (cents === 0 || cents === 98) {
// .00 → subtract 1 cent to get .99 const adjustment = cents === 0 ? -0.01 : 0.01;
msrp -= 0.01; const adjusted = (msrp + adjustment).toFixed(2);
} else if (cents === 98) { // Only apply if the adjusted value actually ends in .99
// .98 → add 1 cent to get .99 if (adjusted.endsWith('.99')) {
msrp += 0.01; msrp = parseFloat(adjusted);
}
} }
// Otherwise leave as-is // Otherwise leave as-is (exact 2x)
} else if (roundNine) { } else if (roundNine) {
// For >2x with checkbox: round to nearest .X9 // For >2x with checkbox: round to nearest .X9
msrp = roundToNine(msrp); msrp = roundToNine(msrp);
@@ -1616,13 +1689,12 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
useValidationStore.setState((draft) => { useValidationStore.setState((draft) => {
draft.rows.forEach((row, index) => { draft.rows.forEach((row, index) => {
const currentValue = row[fieldKey];
const sourceValue = row[sourceField]; 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 !== ''; const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
if (isEmpty && hasSource) { if (hasSource) {
const sourceNum = parseFloat(String(sourceValue)); const sourceNum = parseFloat(String(sourceValue));
if (!isNaN(sourceNum) && sourceNum > 0) { if (!isNaN(sourceNum) && sourceNum > 0) {
draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2); draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2);
@@ -1928,6 +2000,165 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader'; 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 * Main table component
* *
@@ -1958,18 +2189,46 @@ export const ValidationTable = () => {
return offset; return offset;
}, [fields]); }, [fields]);
// Track horizontal scroll for sticky column shadow // Name column sticky toggle - when enabled, column sticks to left/right edges based on scroll
const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false); 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(() => { const handleScroll = useCallback(() => {
if (tableContainerRef.current && headerRef.current) { if (tableContainerRef.current && headerRef.current) {
const scrollLeft = tableContainerRef.current.scrollLeft; const scrollLeft = tableContainerRef.current.scrollLeft;
const viewportWidth = tableContainerRef.current.clientWidth;
headerRef.current.scrollLeft = scrollLeft; 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 // Compute filtered indices AND row IDs in a single pass
// This avoids calling getState() during render for each row // This avoids calling getState() during render for each row
@@ -2012,6 +2271,11 @@ export const ValidationTable = () => {
return { filteredIndices: indices, rowIdMap: idMap }; return { filteredIndices: indices, rowIdMap: idMap };
}, [rowCount, filters.searchText, filters.showErrorsOnly]); }, [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 // Build columns - ONLY depends on fields, NOT selection state
// Selection state is handled by isolated HeaderCheckbox component // Selection state is handled by isolated HeaderCheckbox component
const columns = useMemo<ColumnDef<RowData>[]>(() => { const columns = useMemo<ColumnDef<RowData>[]>(() => {
@@ -2034,9 +2298,21 @@ export const ValidationTable = () => {
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false; const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each'; const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height'; 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 // Determine which header component to render
const renderHeader = () => { const renderHeader = () => {
if (isNameColumn) {
return (
<NameColumnHeader
label={field.label}
isRequired={isRequired}
isSticky={nameColumnSticky}
onToggleSticky={toggleNameColumnSticky}
/>
);
}
if (isPriceColumn) { if (isPriceColumn) {
return ( return (
<PriceColumnHeader <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 ( return (
<div className="flex items-center gap-1 truncate"> <div className="flex items-center gap-1 truncate">
<span className="truncate">{field.label}</span> <span className="truncate">{field.label}</span>
@@ -2073,7 +2358,7 @@ export const ValidationTable = () => {
}); });
return [selectionColumn, templateColumn, ...dataColumns]; return [selectionColumn, templateColumn, ...dataColumns];
}, [fields]); // CRITICAL: No selection-related deps! }, [fields, nameColumnSticky, toggleNameColumnSticky]); // Added nameColumnSticky dependencies
// Calculate total table width for horizontal scrolling // Calculate total table width for horizontal scrolling
const totalTableWidth = useMemo(() => { const totalTableWidth = useMemo(() => {
@@ -2109,20 +2394,31 @@ export const ValidationTable = () => {
> >
{columns.map((column, index) => { {columns.map((column, index) => {
const isNameColumn = column.id === 'name'; 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 ( return (
<div <div
key={column.id || index} key={column.id || index}
className={cn( className={cn(
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0", "px-3 flex items-center text-left text-sm font-medium text-muted-foreground",
// Sticky header needs solid background matching the row's bg-muted/50 appearance // Use box-shadow for right border - renders more consistently
isNameColumn && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]", "shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
isNameColumn && isScrolledHorizontally && "lg:shadow-md", // 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={{ style={{
width: column.size || 150, width: column.size || 150,
minWidth: column.size || 150, minWidth: column.size || 150,
flexShrink: 0, 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' {typeof column.header === 'function'
@@ -2159,7 +2455,8 @@ export const ValidationTable = () => {
columns={columns} columns={columns}
fields={fields} fields={fields}
totalRowCount={rowCount} totalRowCount={rowCount}
isScrolledHorizontally={isScrolledHorizontally} nameColumnSticky={nameColumnSticky}
stickyDirection={stickyDirection}
/> />
); );
})} })}

View File

@@ -10,7 +10,7 @@
*/ */
import { useMemo, useCallback, useState } from 'react'; 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 { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@@ -39,6 +39,38 @@ export const ValidationToolbar = ({
const filters = useFilters(); const filters = useFilters();
const fields = useFields(); 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 // State for the product search template dialog
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
@@ -112,12 +144,24 @@ export const ValidationToolbar = ({
placeholder="Filter products..." placeholder="Filter products..."
value={filters.searchText} value={filters.searchText}
onChange={(e) => setSearchText(e.target.value)} 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> </div>
{/* Product count */} {/* 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 */} {/* Action buttons */}
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">

View File

@@ -3,9 +3,14 @@
* *
* Editable input cell for text, numbers, and price values. * Editable input cell for text, numbers, and price values.
* Memoized to prevent unnecessary re-renders when parent table updates. * 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 { Input } from '@/components/ui/input';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { import {
@@ -21,6 +26,17 @@ import type { ValidationError } from '../../store/types';
import { ErrorType } from '../../store/types'; import { ErrorType } from '../../store/types';
import { useValidationStore } from '../../store/validationStore'; 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 */ /** Time window (ms) during which this cell should not focus after a popover closes */
const POPOVER_CLOSE_DELAY = 150; const POPOVER_CLOSE_DELAY = 150;
@@ -43,10 +59,14 @@ const InputCellComponent = ({
errors, errors,
onBlur, onBlur,
}: InputCellProps) => { }: InputCellProps) => {
// Store the full precision value internally
const [localValue, setLocalValue] = useState(String(value ?? '')); const [localValue, setLocalValue] = useState(String(value ?? ''));
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); 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 // Get store state for coordinating with popover close behavior
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
@@ -57,6 +77,14 @@ const InputCellComponent = ({
} }
}, [value, isFocused]); }, [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 // PERFORMANCE: Only update local state while typing, NOT the store
// The store is updated on blur, which prevents thousands of subscription // The store is updated on blur, which prevents thousands of subscription
// checks per keystroke // checks per keystroke
@@ -86,23 +114,13 @@ const InputCellComponent = ({
}, [cellPopoverClosedAt]); }, [cellPopoverClosedAt]);
// Update store only on blur - this is when validation runs too // 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(() => { const handleBlur = useCallback(() => {
setIsFocused(false); setIsFocused(false);
// Store the full precision value - no rounding here
let valueToSave = localValue; onBlur(localValue);
}, [localValue, onBlur]);
// 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]);
// Process errors - show icon only for non-required errors when field has value // Process errors - show icon only for non-required errors when field has value
// Don't show error icon while user is actively editing (focused) // Don't show error icon while user is actively editing (focused)
@@ -129,7 +147,7 @@ const InputCellComponent = ({
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
ref={inputRef} ref={inputRef}
value={localValue} value={displayValue}
onChange={handleChange} onChange={handleChange}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}

View File

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

View File

@@ -185,22 +185,14 @@ export const useValidationActions = () => {
* *
* With 100 rows × 30 fields, the old approach would trigger ~3000 individual * With 100 rows × 30 fields, the old approach would trigger ~3000 individual
* set() calls, each cloning the entire errors Map. This approach triggers ONE. * 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 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) // Collect ALL errors in plain JS Maps (no Immer overhead)
const allErrors = new Map<number, Record<string, ValidationError[]>>(); const allErrors = new Map<number, Record<string, ValidationError[]>>();
const allStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>(); 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 // Process all rows - collect errors without touching the store
for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) { for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) {
const row = currentRows[rowIndex]; const row = currentRows[rowIndex];
@@ -221,20 +213,11 @@ export const useValidationActions = () => {
}); });
} }
// Round currency fields to 2 decimal places on initial load // NOTE: We no longer round price fields on initial load.
for (const priceFieldKey of priceFields) { // Full precision is preserved internally (e.g., "3.625") for accurate calculations.
const value = row[priceFieldKey]; // - Display: InputCell shows 2 decimals when not focused
if (value !== undefined && value !== null && value !== '') { // - Calculations: 2x button uses full precision
const numValue = parseFloat(String(value)); // - API submission: getCleanedData() formats to 2 decimals
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 // Validate each field
for (const field of currentFields) { for (const field of currentFields) {

View File

@@ -8,7 +8,7 @@
* 4. Renders the ValidationContainer once initialized * 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 { useQuery } from '@tanstack/react-query';
import { useValidationStore } from './store/validationStore'; import { useValidationStore } from './store/validationStore';
import { useInitPhase, useIsReady } from './store/selectors'; import { useInitPhase, useIsReady } from './store/selectors';
@@ -24,6 +24,36 @@ import config from '@/config';
import type { ValidationStepProps } from './store/types'; import type { ValidationStepProps } from './store/types';
import type { Field, SelectOption } from '../../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 * Fetch field options from the API
*/ */
@@ -105,6 +135,7 @@ export const ValidationStep = ({
const templatesLoadedRef = useRef(false); const templatesLoadedRef = useRef(false);
const upcValidationStartedRef = useRef(false); const upcValidationStartedRef = useRef(false);
const fieldValidationStartedRef = useRef(false); const fieldValidationStartedRef = useRef(false);
const lastDataFingerprintRef = useRef<string | null>(null);
// Debug logging // Debug logging
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady); console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
@@ -132,12 +163,25 @@ export const ValidationStep = ({
retry: 2, retry: 2,
}); });
// Get current store state to check if we're returning to an already-initialized store // Create a fingerprint of the incoming data to detect changes
const storeRows = useValidationStore((state) => state.rows); const dataFingerprint = useMemo(() => createDataFingerprint(initialData), [initialData]);
// Initialize store with data // Initialize store with data
useEffect(() => { useEffect(() => {
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase); 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) // Skip if already initialized (check both ref AND store state)
// The ref prevents double-init within the same mount cycle // 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 // IMPORTANT: Skip initialization if we're returning to an already-ready store
// This happens when navigating back from ImageUploadStep - the store still has // with the SAME data. This happens when navigating back from ImageUploadStep.
// all the validated data, so we don't need to re-run the initialization sequence. // We compare fingerprints to detect if the data has actually changed.
// We check that the store is 'ready' and has matching row count to avoid if (initPhase === 'ready' && !dataHasChanged && lastDataFingerprintRef.current === dataFingerprint) {
// false positives from stale store data. console.log('[ValidationStep] Skipping init - returning to already-ready store with same 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');
initStartedRef.current = true; initStartedRef.current = true;
return; return;
} }
initStartedRef.current = true; initStartedRef.current = true;
lastDataFingerprintRef.current = dataFingerprint;
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows'); console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
@@ -172,7 +215,7 @@ export const ValidationStep = ({
console.log('[ValidationStep] Calling initialize()'); console.log('[ValidationStep] Calling initialize()');
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file); initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
console.log('[ValidationStep] initialize() called'); console.log('[ValidationStep] initialize() called');
}, [initialData, file, initialize, initPhase, storeRows.length]); }, [initialData, file, initialize, initPhase, dataFingerprint]);
// Update fields when options are loaded // Update fields when options are loaded
// CRITICAL: Check store state (not ref) because initialize() resets the store // 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[]) => { deleteRows: (rowIndexes: number[]) => {
set((state) => { 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) // Sort descending to delete from end first (preserves indices)
const sorted = [...rowIndexes].sort((a, b) => b - a); const sorted = [...rowIndexes].sort((a, b) => b - a);
sorted.forEach((index) => { sorted.forEach((index) => {
@@ -949,9 +963,25 @@ export const useValidationStore = create<ValidationStore>()(
getCleanedData: (): CleanRowData[] => { getCleanedData: (): CleanRowData[] => {
const { rows } = get(); 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) => { return rows.map((row) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __index, __template, __original, __corrected, __changes, __aiSupplemental, ...cleanRow } = row; 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; 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 } { export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
const value = rawValue ?? ''; 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)) { 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) { if (str.length === 11) {
@@ -49,15 +55,18 @@ export function correctUpcValue(rawValue: unknown): { corrected: string; changed
const body = str.slice(0, 11); const body = str.slice(0, 11);
const check = calculateUpcCheckDigit(body); const check = calculateUpcCheckDigit(body);
const corrected = `${body}${check}`; 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) { if (str.length === 13) {
const body = str.slice(0, 12); const body = str.slice(0, 12);
const check = calculateEanCheckDigit(body); const check = calculateEanCheckDigit(body);
const corrected = `${body}${check}`; 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'; const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
// Use proxy paths when on inventory domains to avoid CORS // 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 = { const liveDashboardConfig = {
auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth', auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth',

View File

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