Product import fixes/enhancements
This commit is contained in:
@@ -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}`;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -128,6 +128,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
)
|
||||
|
||||
|
||||
|
||||
switch (state.type) {
|
||||
case StepType.upload:
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
// 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))]"
|
||||
: isSelected
|
||||
? "lg:[background:linear-gradient(hsl(var(--primary)/0.05),hsl(var(--primary)/0.05)),hsl(var(--background))]"
|
||||
: "lg:bg-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);
|
||||
}
|
||||
}, [nameColumnLeftOffset]);
|
||||
} else {
|
||||
setStickyDirection(null);
|
||||
}
|
||||
}
|
||||
}, [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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user